Make the toner forecast an order, not a table

The report answers a purchasing question, and it was answering it in seven
columns, two tables and a rowspan. What someone actually needs from it is a
short list of what to buy.

So it opens with that list, grouped by part number with a quantity. Two
cartridges of the same part in different printers is a quantity of two, which
is the number an order needs and the one a per-printer table made the reader
count by hand. It covers what is empty plus what goes within a fortnight -
ordering only what is already empty means running empty. There is a copy
button, because it ends up pasted into a mail.

Below it the cartridges sit in urgency bands rather than in one long list
sorted by a number. The question is which pile a thing is in, and a pile that
is empty is worth seeing as empty. Everything past "empty" starts collapsed;
the order list above already covers the same ground in a tenth of the height.

The row is a cartridge now, not a printer, so it can carry its own part number,
its own level bar and its own countdown. Nesting supplies under a printer meant
opening a printer to find out whether anything on it needed doing.

Cartridges with no part mapped are counted on a single line rather than given
one each. They cannot be dropped, since that would quietly shorten the order,
and they cannot be ordered from here either - the job they represent is
mapping them, which is one job however many there are.

Bands and the order horizon are decided server-side, next to the arithmetic
that produces them, so a heading cannot disagree with what got added to the
list.

Checked against a fleet of 43 dev printers with real part mappings, driven by
a stub Zabbix - live Zabbix is not reachable from the dev box.
This commit is contained in:
cproudlock
2026-08-13 13:08:39 -04:00
parent e67fe47fe2
commit 3d83806135
8 changed files with 733 additions and 134 deletions

View File

@@ -77,6 +77,19 @@ that consumed it. The theme of the release is making those failures visible:
each fix ships with the check, report or document that would have surfaced it. each fix ships with the check, report or document that would have surfaced it.
### Changed
- The toner forecast is read as an order, not as a table. It opens with what to
buy - grouped by part number, with a quantity - because two cartridges of the
same part in different printers is a quantity of two, and that was a number
the reader had to work out by hand. Below it, cartridges sit in urgency bands
(empty / two weeks / thirty days / later) rather than in one long sortable
list, since the question being asked is which pile a thing is in. A row is
now a cartridge rather than a printer, carries its own part number and a
level bar, and everything past "empty" starts collapsed. Cartridges with no
part mapped are counted on one line instead of a line each: they still have
to be ordered, but not from this page - the job is to map them.
### Fixed ### Fixed
- The toner forecast was reading the wrong end of the window. Zabbix applies a - The toner forecast was reading the wrong end of the window. Zabbix applies a

View File

@@ -2949,7 +2949,7 @@
"path": "/api/printers/supplies/forecast", "path": "/api/printers/supplies/forecast",
"auth": "jwt-optional", "auth": "jwt-optional",
"params": "days (1-365, default 90)", "params": "days (1-365, default 90)",
"purpose": "Days-to-empty per printer from Zabbix history, soonest first, with cartridge replacement counts; printers with no honest estimate are returned separately with the reason. available=false when Zabbix is unreachable", "purpose": "One row per CARTRIDGE: days-to-empty from Zabbix history (hourly trends over a long window), soonest first, banded empty/soon/month/later, each with its part numbers and replacement count. Also returns orderlist - what to buy within horizondays, grouped by part number with a quantity. Cartridges with no honest estimate come back separately with the reason. available=false when Zabbix is unreachable",
"example": "curl 'http://localhost:5001/api/printers/supplies/forecast?days=90'" "example": "curl 'http://localhost:5001/api/printers/supplies/forecast?days=90'"
} }
] ]

View File

@@ -6562,8 +6562,8 @@
"tags": [ "tags": [
"plugin-printers" "plugin-printers"
], ],
"summary": "Days-to-empty per printer from Zabbix history, soonest first, with cartridge replacement counts; printers with no honest", "summary": "One row per CARTRIDGE: days-to-empty from Zabbix history (hourly trends over a long window), soonest first, banded empty",
"description": "Days-to-empty per printer from Zabbix history, soonest first, with cartridge replacement counts; printers with no honest estimate are returned separately with the reason. available=false when Zabbix is unreachable\n\n**Auth:** jwt-optional\n\n**Params:** days (1-365, default 90)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/supplies/forecast?days=90'\n```", "description": "One row per CARTRIDGE: days-to-empty from Zabbix history (hourly trends over a long window), soonest first, banded empty/soon/month/later, each with its part numbers and replacement count. Also returns orderlist - what to buy within horizondays, grouped by part number with a quantity. Cartridges with no honest estimate come back separately with the reason. available=false when Zabbix is unreachable\n\n**Auth:** jwt-optional\n\n**Params:** days (1-365, default 90)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/supplies/forecast?days=90'\n```",
"security": [ "security": [
{ {
"bearerAuth": [] "bearerAuth": []

View File

@@ -1503,26 +1503,35 @@ def supplies_forecast():
is returned separately with the reason, rather than sorted as though it is returned separately with the reason, rather than sorted as though it
were fine or dropped as though it did not exist. were fine or dropped as though it did not exist.
""" """
from ..services.supply_history import analyse, soonest from ..services.supply_history import (
ORDER_HORIZON_DAYS, analyse, band, orderlist,
)
from ..services.supply_parts import (
derivecolor, derivesupplytype, lookupsupplies,
)
try: try:
days = max(1, min(365, int(request.args.get('days', 90)))) days = max(1, min(365, int(request.args.get('days', 90))))
except (TypeError, ValueError): except (TypeError, ValueError):
days = 90 days = 90
empty = {
'cartridges': [], 'unestimated': [], 'orderlist': [], 'days': days,
'horizondays': ORDER_HORIZON_DAYS,
}
service = ZabbixService() service = ZabbixService()
if not service.isconfigured or not service.isreachable: if not service.isconfigured or not service.isreachable:
return success_response({ return success_response(dict(
'printers': [], 'unestimated': [], 'days': days, empty, available=False,
'available': False, reason='Zabbix is not configured or not reachable'))
'reason': 'Zabbix is not configured or not reachable',
})
rows = ( rows = (
db.session.query(Printer, Asset, Communication, Vendor) db.session.query(Printer, Asset, Communication, Vendor, Model)
.join(Asset, Asset.assetid == Printer.assetid) .join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid) .join(Communication, Communication.assetid == Asset.assetid)
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
.outerjoin(Model, Model.modelnumberid == Printer.modelnumberid)
.filter(Asset.isactive == True, .filter(Asset.isactive == True,
Communication.ipaddress.isnot(None), Communication.ipaddress.isnot(None),
Communication.ipaddress != '') Communication.ipaddress != '')
@@ -1530,9 +1539,9 @@ def supplies_forecast():
) )
seen = set() seen = set()
estimated, unestimated = [], [] cartridges, unestimated = [], []
for printer, asset, comm, vendor in rows: for printer, asset, comm, vendor, model in rows:
if printer.printerid in seen: if printer.printerid in seen:
continue continue
seen.add(printer.printerid) seen.add(printer.printerid)
@@ -1543,7 +1552,9 @@ def supplies_forecast():
itemids = [s['itemid'] for s in supplies if s.get('itemid')] itemids = [s['itemid'] for s in supplies if s.get('itemid')]
history = service.getlevelhistory(itemids, days=days) history = service.getlevelhistory(itemids, days=days)
analysed = [] # The cartridge is what gets ordered, so the cartridge is the row.
# Nesting supplies under a printer made the reader unpack a printer to
# find out whether anything on it needed doing.
for supply in supplies: for supply in supplies:
points = history.get(str(supply.get('itemid')), []) points = history.get(str(supply.get('itemid')), [])
# The live read is the level the report shows, so it is also the # The live read is the level the report shows, so it is also the
@@ -1551,35 +1562,50 @@ def supplies_forecast():
# row whose level and days-left came from different moments reads # row whose level and days-left came from different moments reads
# as broken. # as broken.
detail = analyse(points, currentlevel=supply.get('level')) detail = analyse(points, currentlevel=supply.get('level'))
detail['name'] = supply.get('name')
detail['color'] = supply.get('color')
analysed.append(detail)
entry = { name = supply.get('name') or 'Unknown'
color = derivecolor(name, supply.get('color'))
supplytype = derivesupplytype(name)
detail.update({
'name': name,
'color': color,
'supplytype': supplytype,
'partnumbers': lookupsupplies(
model.modelnumberid if model else None, color, supplytype),
'printerid': printer.printerid, 'printerid': printer.printerid,
'printername': asset.name or printer.hostname or '', 'printername': asset.name or printer.hostname or '',
'assetnumber': asset.assetnumber or '', 'assetnumber': asset.assetnumber or '',
'ipaddress': comm.ipaddress, 'ipaddress': comm.ipaddress,
'vendor': vendor.vendor if vendor else None, 'vendor': vendor.vendor if vendor else None,
'supplies': analysed, 'model': model.modelnumber if model else None,
'daysleft': soonest(analysed), 'band': band(detail['daysleft']),
'replacements': sum(s['replacements'] for s in analysed), })
} # The chart series is per cartridge and nothing on this report
(estimated if entry['daysleft'] is not None else unestimated).append(entry) # draws it yet; sending it multiplies the payload for nothing.
detail.pop('points', None)
(cartridges if detail['band'] else unestimated).append(detail)
# Soonest first: the point of the report is what to order next. # Soonest first: the point of the report is what to order next.
estimated.sort(key=lambda p: p['daysleft']) cartridges.sort(key=lambda c: (c['daysleft'], c['printername']))
unestimated.sort(key=lambda p: p['printername']) unestimated.sort(key=lambda c: (c['printername'], c['name']))
return success_response({ counts = {name: 0 for name in ('empty', 'soon', 'month', 'later')}
'printers': estimated, for cartridge in cartridges:
'unestimated': unestimated, counts[cartridge['band']] += 1
'days': days, toorder = orderlist(cartridges)
'available': True,
'summary': { return success_response(dict(
'estimated': len(estimated), empty,
cartridges=cartridges,
unestimated=unestimated,
orderlist=toorder,
available=True,
summary={
'bands': counts,
'estimated': len(cartridges),
'unestimated': len(unestimated), 'unestimated': len(unestimated),
'replacements': sum(p['replacements'] for p in estimated + unestimated), 'replacements': sum(c['replacements']
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30), for c in cartridges + unestimated),
'toorder': sum(item['quantity'] for item in toorder),
}, },
}) ))

View File

@@ -24,73 +24,110 @@
</div> </div>
<template v-else> <template v-else>
<div class="summary-row"> <!-- The answer first. Everything below it is the evidence. -->
<div class="summary-card"> <div class="card order-card">
<span class="summary-number">{{ summary.duewithin30 }}</span> <div class="order-head">
<span class="summary-label">due within 30 days</span> <h2 class="section-title">
Order now
<span class="muted">- empty, or out within {{ horizondays }} days</span>
</h2>
<button v-if="orderlist.length" class="btn btn-secondary" @click="copyOrder">
{{ copied ? 'Copied' : 'Copy list' }}
</button>
</div> </div>
<div class="summary-card">
<span class="summary-number">{{ summary.estimated }}</span> <p v-if="!orderlist.length" class="muted nothing-due">
<span class="summary-label">printers with an estimate</span> Nothing is due in the next {{ horizondays }} days.
</div> </p>
<div class="summary-card"> <ul v-else class="order-list">
<span class="summary-number">{{ summary.replacements }}</span> <li v-for="item in mapped" :key="orderKey(item)">
<span class="summary-label">cartridges changed in {{ days }} days</span> <span class="quantity">{{ item.quantity }}x</span>
<span class="partnumber">{{ item.partnumber }}</span>
<span class="muted order-detail">
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
</span>
<span class="muted order-printers">{{ printerNames(item) }}</span>
</li>
</ul>
<!-- A cartridge nobody has mapped a part to still has to be ordered,
so it cannot be hidden. It cannot be ordered from this page
either, so it does not get a line each - it is one job: map them. -->
<div v-if="unmapped.length" class="unmapped-block">
<button class="unmapped-head" @click="toggle('unmapped')">
<span class="band-caret">{{ isOpen('unmapped') ? '-' : '+' }}</span>
{{ unmappedCount }} more due, with no part number on file
</button>
<ul v-if="isOpen('unmapped')" class="order-list">
<li v-for="item in unmapped" :key="orderKey(item)">
<span class="quantity">{{ item.quantity }}x</span>
<span class="muted order-detail">
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
</span>
<span class="muted order-printers">{{ printerNames(item) }}</span>
</li>
</ul>
</div> </div>
</div> </div>
<div class="card"> <!-- Bands, not a sortable table: the question is "which pile is this in",
<div class="table-container"> and a pile with nothing in it is worth seeing as empty. -->
<div v-for="group in bands" :key="group.key" class="card band-card">
<button class="band-head" @click="toggle(group.key)">
<span class="band-caret">{{ isOpen(group.key) ? '-' : '+' }}</span>
<span class="band-title">{{ group.title }}</span>
<span class="band-count" :class="group.key">{{ group.items.length }}</span>
</button>
<div v-if="isOpen(group.key) && group.items.length" class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="colcolor"></th>
<th>Printer</th> <th>Printer</th>
<th>Runs out in</th>
<th>Supply</th> <th>Supply</th>
<th>Level</th> <th>Part</th>
<th>Burn rate</th> <th class="collevel">Level</th>
<th>Runs out</th>
<th>Rate</th>
<th>Changed</th> <th>Changed</th>
<th>Based on</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- Sorted by days left, not by level: a cartridge at 60% falling <tr v-for="c in group.items" :key="c.printerid + c.name">
fast is ordered before one sitting at 8% that never moves. <td class="colcolor">
<span class="chip" :style="chipStyle(c.color)"
Days left is per CARTRIDGE, not per printer. It used to span :title="colorLabel(c.color)"></span>
the printer's rows, so every supply displayed the soonest one
of them - a cartridge at 20% sat beside "4 days" that belonged
to the black next to it. The printer number still decides
where the printer sorts; it does not label a row it is not
about. -->
<template v-for="p in printers" :key="p.printerid">
<tr v-for="(s, index) in p.supplies" :key="p.printerid + s.name"
:class="{ 'row-group-start': index === 0 }">
<td v-if="index === 0" :rowspan="p.supplies.length">
<router-link :to="`/printers/${p.printerid}`">
{{ p.printername || p.assetnumber }}
</router-link>
<div class="muted small">{{ p.ipaddress }}</div>
</td> </td>
<td> <td>
<span v-if="s.daysleft === 0" class="days critical">empty now</span> <router-link :to="`/printers/${c.printerid}`">
<span v-else-if="s.daysleft != null" class="days" :class="urgency(s.daysleft)"> {{ c.printername || c.assetnumber }}
{{ s.daysleft }} days </router-link>
</span> <div class="muted small">{{ c.ipaddress }}</div>
</td>
<td>{{ c.name }}</td>
<td>
<span v-if="firstPart(c)" class="partnumber">{{ firstPart(c) }}</span>
<span v-else class="muted">-</span> <span v-else class="muted">-</span>
</td> </td>
<td>{{ s.name }}</td> <td class="collevel">
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td> <!-- A bar reads at a glance where a percentage has to be
<td>{{ s.burnrateperday != null ? s.burnrateperday + '%/day' : '-' }}</td> compared digit by digit against the row above. -->
<td>{{ s.replacements || 0 }}</td> <span class="bar" :title="levelText(c)">
<td class="muted small"> <span class="bar-fill" :class="c.band"
{{ s.reason ? s.reason : s.basisdays + ' days of readings' }} :style="{ width: barWidth(c) }"></span>
</span>
<span class="level-text">{{ levelText(c) }}</span>
</td> </td>
</tr> <td>
</template> <span class="days" :class="c.band">{{ daysText(c) }}</span>
<tr v-if="!printers.length"> </td>
<td colspan="7" class="muted" style="text-align:center;"> <td class="muted small">
Nothing can be estimated yet from {{ days }} days of history. {{ c.burnrateperday != null ? c.burnrateperday + '%/day' : '-' }}
</td>
<td class="muted small">
{{ c.replacements || 0 }}
<span v-if="c.basisdays">/ {{ c.basisdays }}d</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -98,38 +135,51 @@
</div> </div>
</div> </div>
<!-- Kept separate rather than sorted in as 0 or as 999: a printer with no <!-- Kept apart rather than sorted in as 0 or as 999: a cartridge with no
estimate is neither urgent nor safe, and the reason is the useful part. --> estimate is neither urgent nor safe, and the reason is the point. -->
<div v-if="unestimated.length" class="card"> <div v-if="unestimated.length" class="card band-card">
<h3 class="section-title">No estimate yet ({{ unestimated.length }})</h3> <button class="band-head" @click="toggle('none')">
<div class="table-container"> <span class="band-caret">{{ isOpen('none') ? '-' : '+' }}</span>
<span class="band-title">No estimate yet</span>
<span class="band-count">{{ unestimated.length }}</span>
</button>
<div v-if="isOpen('none')" class="table-container">
<table> <table>
<thead> <thead>
<tr><th>Printer</th><th>Supply</th><th>Level</th><th>Why</th></tr> <tr>
<th class="colcolor"></th>
<th>Printer</th><th>Supply</th><th class="collevel">Level</th><th>Why</th>
</tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="p in unestimated" :key="p.printerid"> <tr v-for="c in unestimated" :key="c.printerid + c.name">
<tr v-for="s in p.supplies" :key="p.printerid + s.name"> <td class="colcolor">
<span class="chip" :style="chipStyle(c.color)"></span>
</td>
<td> <td>
<router-link :to="`/printers/${p.printerid}`"> <router-link :to="`/printers/${c.printerid}`">
{{ p.printername || p.assetnumber }} {{ c.printername || c.assetnumber }}
</router-link> </router-link>
</td> </td>
<td>{{ s.name }}</td> <td>{{ c.name }}</td>
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td> <td class="collevel">{{ levelText(c) }}</td>
<td class="muted">{{ s.reason || '-' }}</td> <td class="muted">{{ c.reason || '-' }}</td>
</tr> </tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<p class="muted footnote">
{{ summary.replacements }} cartridges changed across the fleet in the
last {{ days }} days.
</p>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
@@ -137,14 +187,117 @@ const loading = ref(true)
const available = ref(false) const available = ref(false)
const reason = ref('') const reason = ref('')
const days = ref(90) const days = ref(90)
const printers = ref([]) const horizondays = ref(14)
const cartridges = ref([])
const unestimated = ref([]) const unestimated = ref([])
const summary = ref({ duewithin30: 0, estimated: 0, replacements: 0 }) const orderlist = ref([])
const summary = ref({ bands: {}, replacements: 0, toorder: 0 })
const copied = ref(false)
function urgency(daysleft) { // Only what is already empty starts open. The order list above covers the same
if (daysleft <= 7) return 'critical' // ground in a tenth of the height, so the bands are evidence, asked for.
if (daysleft <= 30) return 'low' const openBands = ref(new Set(['empty']))
return 'ok'
const mapped = computed(() => orderlist.value.filter(item => item.partnumber))
const unmapped = computed(() => orderlist.value.filter(item => !item.partnumber))
const unmappedCount = computed(() =>
unmapped.value.reduce((total, item) => total + item.quantity, 0))
const BAND_TITLES = {
empty: 'Empty - order now',
soon: 'Within 2 weeks',
month: 'Within 30 days',
later: 'Later',
}
const bands = computed(() =>
Object.keys(BAND_TITLES).map(key => ({
key,
title: BAND_TITLES[key],
items: cartridges.value.filter(c => c.band === key),
})))
function isOpen(key) {
return openBands.value.has(key)
}
function toggle(key) {
const next = new Set(openBands.value)
next.has(key) ? next.delete(key) : next.add(key)
openBands.value = next
}
// Toner colours are the one place a literal colour is the data rather than a
// theme choice - a cyan chip has to be cyan in both light and dark mode.
const CHIP_COLORS = {
black: '#2b2b2b', cyan: '#00b7eb', magenta: '#d6006e', yellow: '#f2c200',
}
function chipStyle(color) {
const known = CHIP_COLORS[(color || '').toLowerCase()]
return known
? { background: known }
: { background: 'var(--bg)', border: '1px solid var(--border)' }
}
function colorLabel(color) {
if (!color || color === 'none') return 'Supply'
return color.charAt(0).toUpperCase() + color.slice(1)
}
function firstPart(cartridge) {
const parts = cartridge.partnumbers || []
return parts.length ? parts[0].partnumber : null
}
function levelText(cartridge) {
return cartridge.currentlevel != null
? Math.round(cartridge.currentlevel) + '%'
: '-'
}
function barWidth(cartridge) {
const level = cartridge.currentlevel
if (level == null) return '0%'
return Math.max(0, Math.min(100, level)) + '%'
}
function daysText(cartridge) {
if (cartridge.daysleft == null) return '-'
if (cartridge.daysleft === 0) return 'empty'
if (cartridge.daysleft === 1) return '1 day'
return cartridge.daysleft + ' days'
}
function orderKey(item) {
return [item.partnumber || 'unmapped', item.color, item.model].join('|')
}
// Enough to recognise which printers are meant, without a line of hostnames
// wrapping over the quantity that is the point of the row.
const NAMES_SHOWN = 3
function printerNames(item) {
const names = (item.printers || []).map(p => p.printername).filter(Boolean)
if (names.length <= NAMES_SHOWN) return names.join(', ')
return `${names.slice(0, NAMES_SHOWN).join(', ')} +${names.length - NAMES_SHOWN} more`
}
// Purchasing wants it as text in a mail, not as a screenshot of a table.
async function copyOrder() {
const lines = orderlist.value.map(item => {
const part = item.partnumber || 'NO PART NUMBER ON FILE'
const where = printerNames(item)
return `${item.quantity}x ${part} (${colorLabel(item.color)}` +
`${item.model ? ', ' + item.model : ''}) - ${where}`
})
try {
await navigator.clipboard.writeText(lines.join('\n'))
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
copied.value = false
}
} }
async function load() { async function load() {
@@ -154,8 +307,10 @@ async function load() {
const data = response.data.data const data = response.data.data
available.value = data.available available.value = data.available
reason.value = data.reason || '' reason.value = data.reason || ''
printers.value = data.printers || [] cartridges.value = data.cartridges || []
unestimated.value = data.unestimated || [] unestimated.value = data.unestimated || []
orderlist.value = data.orderlist || []
horizondays.value = data.horizondays || 14
summary.value = data.summary || summary.value summary.value = data.summary || summary.value
} catch (err) { } catch (err) {
available.value = false available.value = false
@@ -171,21 +326,104 @@ onMounted(load)
<style scoped> <style scoped>
.actions { display: flex; gap: 0.5rem; align-items: center; } .actions { display: flex; gap: 0.5rem; align-items: center; }
.actions .form-control { width: auto; } .actions .form-control { width: auto; }
.summary-row { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
.summary-card { .order-card { padding: 0 0 0.5rem; }
background: var(--bg-card-solid); .order-head {
border: 1px solid var(--border); display: flex;
border-radius: 0.5rem; align-items: center;
padding: 1rem 1.5rem; justify-content: space-between;
min-width: 12rem; gap: 1rem;
padding: 0 1rem;
} }
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; } .order-head .section-title { padding: 0.75rem 0; }
.summary-label { color: var(--text-light); font-size: 0.85rem; } .nothing-due { padding: 0 1rem 0.75rem; }
.order-list { list-style: none; margin: 0; padding: 0 1rem; }
.order-list li {
display: flex;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.4rem 0;
border-top: 1px solid var(--border);
}
.quantity { font-weight: 700; min-width: 2.5rem; }
.unmapped-block { border-top: 1px solid var(--border); }
.unmapped-head {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem 1rem;
background: none;
border: none;
color: var(--warning);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
}
.unmapped-block .order-list li { border-top: 1px dashed var(--border); }
.partnumber { font-family: monospace; font-weight: 700; }
.partnumber.unmapped { font-weight: 400; color: var(--warning); }
.order-detail { font-size: 0.9rem; }
.order-printers { font-size: 0.8rem; margin-left: auto; text-align: right; }
.band-card { padding: 0; margin-bottom: 1rem; }
.band-head {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
color: var(--text);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.band-caret { width: 1rem; color: var(--text-light); }
.band-title { flex: 1; }
.band-count {
min-width: 1.8rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: var(--bg);
border: 1px solid var(--border);
text-align: center;
font-size: 0.85rem;
}
.band-count.empty { background: var(--danger); border-color: var(--danger); color: #fff; }
.band-count.soon { background: var(--warning); border-color: var(--warning); color: #fff; }
.colcolor { width: 1.5rem; }
.chip {
display: inline-block;
width: 0.9rem;
height: 0.9rem;
border-radius: 50%;
vertical-align: middle;
}
.collevel { width: 9rem; white-space: nowrap; }
.bar {
display: inline-block;
width: 5rem;
height: 0.6rem;
border-radius: 0.3rem;
background: var(--bg);
border: 1px solid var(--border);
overflow: hidden;
vertical-align: middle;
}
.bar-fill { display: block; height: 100%; background: var(--success); }
.bar-fill.empty { background: var(--danger); }
.bar-fill.soon { background: var(--warning); }
.level-text { margin-left: 0.4rem; font-size: 0.85rem; }
.days { font-weight: 700; } .days { font-weight: 700; }
.days.critical { color: var(--danger); } .days.empty { color: var(--danger); }
.days.low { color: var(--warning); } .days.soon { color: var(--warning); }
.small { font-size: 0.8rem; } .small { font-size: 0.8rem; }
.row-group-start td { border-top: 2px solid var(--border); }
.empty-state { padding: 2rem; text-align: center; } .empty-state { padding: 2rem; text-align: center; }
.section-title { padding: 0.75rem 1rem 0; } .footnote { margin-top: 1rem; font-size: 0.85rem; }
</style> </style>

View File

@@ -185,6 +185,76 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
return result return result
# Urgency bands. The report groups by these and the order list is drawn from
# the first two, so they are defined once here rather than in the view - a
# heading that disagrees with what got added to the list is worse than either.
SOON_DAYS = 14
MONTH_DAYS = 30
# What goes on the order list. Two weeks is the horizon that survives a
# delivery: ordering only what is already empty means running empty.
ORDER_HORIZON_DAYS = SOON_DAYS
BANDS = ('empty', 'soon', 'month', 'later')
def band(daysleft):
"""Which urgency band a cartridge belongs in, or None with no estimate."""
if daysleft is None:
return None
if daysleft <= 0:
return 'empty'
if daysleft <= SOON_DAYS:
return 'soon'
if daysleft <= MONTH_DAYS:
return 'month'
return 'later'
def orderlist(cartridges, horizon=ORDER_HORIZON_DAYS):
"""What to buy, grouped by part number: [{partnumber, quantity, ...}].
The report's whole purpose reduced to a list someone can hand to
purchasing. Two cartridges of the same part in different printers is a
quantity of two, which is the number an order needs and the one a
per-printer table makes you count by hand.
A cartridge with no part mapped is still listed, under its printer's model
and colour. Dropping it would quietly shorten the order.
"""
groups = {}
for cartridge in cartridges:
if cartridge.get('daysleft') is None or cartridge['daysleft'] > horizon:
continue
parts = cartridge.get('partnumbers') or []
# Several capacity tiers can match; the first is the standard one and
# is what the low-supplies report shows first too.
partnumber = parts[0]['partnumber'] if parts else None
key = (partnumber, cartridge.get('color'), cartridge.get('model'))
group = groups.setdefault(key, {
'partnumber': partnumber,
'color': cartridge.get('color'),
'supplytype': cartridge.get('supplytype'),
'model': cartridge.get('model'),
'marketingname': parts[0].get('marketingname') if parts else None,
'alternates': [p['partnumber'] for p in parts[1:]],
'quantity': 0,
'printers': [],
})
group['quantity'] += 1
group['printers'].append({
'printerid': cartridge.get('printerid'),
'printername': cartridge.get('printername'),
'daysleft': cartridge.get('daysleft'),
})
# Unmapped parts last: they need a decision before they can be ordered.
return sorted(groups.values(),
key=lambda g: (g['partnumber'] is None,
-g['quantity'],
g['partnumber'] or ''))
def soonest(supplies): def soonest(supplies):
"""Days-left of the supply that runs out first, or None if none estimate. """Days-left of the supply that runs out first, or None if none estimate.

View File

@@ -9,7 +9,8 @@ purchasing decision.
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from plugins.printers.services.supply_history import ( from plugins.printers.services.supply_history import (
analyse, burn_rate, current_run, find_replacements, normalise, soonest, analyse, band, burn_rate, current_run, find_replacements, normalise,
orderlist, soonest,
) )
START = datetime(2026, 6, 1, tzinfo=timezone.utc) START = datetime(2026, 6, 1, tzinfo=timezone.utc)
@@ -170,6 +171,91 @@ def test_burn_rate_needs_elapsed_time():
assert burn_rate(points) is None assert burn_rate(points) is None
def cartridge(daysleft, partnumber='CF258X', color='black', printername='PRN',
model='HP 428', printerid=1):
parts = [{'partnumber': partnumber, 'marketingname': None,
'capacitytier': 'standard'}] if partnumber else []
return {'daysleft': daysleft, 'partnumbers': parts, 'color': color,
'supplytype': 'toner', 'model': model, 'printerid': printerid,
'printername': printername}
def test_bands_split_at_two_weeks_and_a_month():
assert band(0) == 'empty'
assert band(1) == 'soon'
assert band(14) == 'soon'
assert band(15) == 'month'
assert band(30) == 'month'
assert band(31) == 'later'
def test_no_estimate_belongs_to_no_band():
assert band(None) is None
def test_the_same_part_in_two_printers_is_a_quantity_of_two():
"""The number purchasing needs, and the one a per-printer table makes you
count by hand."""
result = orderlist([cartridge(0, printername='A', printerid=1),
cartridge(3, printername='B', printerid=2)])
assert len(result) == 1
assert result[0]['quantity'] == 2
assert [p['printername'] for p in result[0]['printers']] == ['A', 'B']
def test_the_order_list_stops_at_the_horizon():
"""Ordering only what is already empty means running empty; ordering three
months out is a stock cupboard."""
result = orderlist([cartridge(0), cartridge(90, partnumber='W2021A')])
assert [item['partnumber'] for item in result] == ['CF258X']
def test_a_cartridge_with_no_part_mapped_is_still_on_the_list():
"""Dropping it would quietly shorten the order."""
result = orderlist([cartridge(2, partnumber=None)])
assert len(result) == 1
assert result[0]['partnumber'] is None
assert result[0]['quantity'] == 1
def test_unmapped_parts_sort_last():
"""They need a decision before anything can be ordered, so they do not sit
at the top of a list meant to be read straight down."""
result = orderlist([cartridge(1, partnumber=None), cartridge(1)])
assert result[0]['partnumber'] == 'CF258X'
assert result[-1]['partnumber'] is None
def test_alternate_capacity_tiers_are_offered_not_counted_separately():
item = {**cartridge(1), 'partnumbers': [
{'partnumber': 'CF258A', 'marketingname': None, 'capacitytier': 'standard'},
{'partnumber': 'CF258X', 'marketingname': None, 'capacitytier': 'high'},
]}
result = orderlist([item])
assert len(result) == 1
assert result[0]['partnumber'] == 'CF258A'
assert result[0]['alternates'] == ['CF258X']
def test_the_same_part_for_a_different_model_is_ordered_separately():
"""Two models sharing a part number is a mapping error worth seeing, not a
quantity to merge."""
result = orderlist([cartridge(1, model='HP 428'),
cartridge(1, model='HP M454')])
assert len(result) == 2
def test_no_estimate_never_reaches_the_order_list():
assert orderlist([cartridge(None)]) == []
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db): def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
"""A dashboard must be told the data is missing, not shown an empty list """A dashboard must be told the data is missing, not shown an empty list
that reads as 'nothing runs out soon'.""" that reads as 'nothing runs out soon'."""
@@ -179,4 +265,5 @@ def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
body = response.get_json()['data'] body = response.get_json()['data']
assert body['available'] is False assert body['available'] is False
assert 'not configured' in body['reason'] assert 'not configured' in body['reason']
assert body['printers'] == [] assert body['cartridges'] == []
assert body['orderlist'] == []

View File

@@ -0,0 +1,165 @@
"""Tests for the assembled toner forecast response.
The arithmetic is covered in test_supply_history; this is about the shape the
report is read through - one row per cartridge, sorted into urgency bands, and
an order list a person can hand to purchasing. Zabbix is stubbed, because what
is being checked is the assembly, not the client.
"""
import pytest
from plugins.printers.services.zabbix_service import ZabbixService
from shopdb.core.models import Asset, AssetType, Communication, CommunicationType
from shopdb.core.models import Model, Vendor
from plugins.printers.models import ModelSupply, Printer
LEVELS = {
# itemid: (name, live level, history of daily readings)
'10': ('Black Cartridge', 1, [6, 5, 4, 3]),
'11': ('Cyan Cartridge', 40, [100, 90, 80, 70, 60, 50, 40]),
'12': ('Yellow Cartridge', 92, [95, 94, 93, 92]),
}
START = 1780000000
class StubZabbix(ZabbixService):
"""Answers from LEVELS, so no network and no Zabbix instance."""
@property
def isconfigured(self):
return True
@property
def isreachable(self):
return True
def getsuppliesbyip_cached(self, ip):
return [{'name': name, 'level': level, 'color': None, 'itemid': itemid}
for itemid, (name, level, _) in LEVELS.items()]
def getlevelhistory(self, itemids, days=90):
return {
itemid: [(str(START + i * 86400), str(value))
for i, value in enumerate(history)]
for itemid, (_, _, history) in LEVELS.items()
if itemid in [str(i) for i in itemids]
}
@pytest.fixture
def printer_with_parts(app, db, monkeypatch):
"""One HP printer, three cartridges, two of them with a part on file."""
monkeypatch.setattr('plugins.printers.api.asset_routes.ZabbixService',
StubZabbix)
with app.app_context():
vendor = Vendor(vendor='HP')
db.session.add(vendor)
db.session.flush()
model = Model(modelnumber='HP M454', vendorid=vendor.vendorid)
db.session.add(model)
db.session.flush()
assettype = AssetType.query.filter_by(assettype='printer').first()
if not assettype:
assettype = AssetType(assettype='printer', pluginname='printers',
tablename='printers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber='PRN-1', name='WJ-PRN-0122',
assettypeid=assettype.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(Printer(assetid=asset.assetid, vendorid=vendor.vendorid,
modelnumberid=model.modelnumberid))
comtype = CommunicationType.query.filter_by(comtype='IP').first()
if not comtype:
comtype = CommunicationType(comtype='IP')
db.session.add(comtype)
db.session.flush()
db.session.add(Communication(assetid=asset.assetid,
comtypeid=comtype.comtypeid,
ipaddress='10.20.30.40', isprimary=True))
# Black and cyan are mapped; yellow deliberately is not.
db.session.add_all([
ModelSupply(modelnumberid=model.modelnumberid, supplytype='toner',
color='black', capacitytier='standard',
partnumber='W2020A'),
ModelSupply(modelnumberid=model.modelnumberid, supplytype='toner',
color='cyan', capacitytier='standard',
partnumber='W2021A'),
])
db.session.commit()
yield
def forecast(client):
response = client.get('/api/printers/supplies/forecast?days=90')
assert response.status_code == 200
return response.get_json()['data']
def test_a_row_is_a_cartridge_not_a_printer(client, printer_with_parts):
"""The thing being ordered is the cartridge, so it is the unit of the
report. Nested under a printer, a reader had to open a printer to find out
whether anything on it needed doing."""
data = forecast(client)
names = sorted(c['name'] for c in data['cartridges'] + data['unestimated'])
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Yellow Cartridge']
def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
"""The defect this replaces: one printer-level figure printed beside every
cartridge, so a healthy one wore its neighbour's deadline."""
data = forecast(client)
bylevel = {c['name']: c for c in data['cartridges']}
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 1%
assert bylevel['Cyan Cartridge']['daysleft'] == 4 # 40% at 10%/day
assert len({c['daysleft'] for c in data['cartridges']}) > 1
def test_cartridges_are_banded_by_urgency(client, printer_with_parts):
data = forecast(client)
byname = {c['name']: c['band'] for c in data['cartridges']}
assert byname['Black Cartridge'] == 'empty'
assert byname['Cyan Cartridge'] == 'soon'
assert data['summary']['bands']['empty'] == 1
def test_the_order_list_names_the_part_and_the_quantity(client, printer_with_parts):
data = forecast(client)
byparts = {item['partnumber']: item for item in data['orderlist']}
assert byparts['W2020A']['quantity'] == 1
assert byparts['W2020A']['printers'][0]['printername'] == 'WJ-PRN-0122'
assert 'W2021A' in byparts
def test_a_healthy_cartridge_stays_off_the_order_list(client, printer_with_parts):
"""Yellow at 92% is months away and belongs in the tail, not in an order."""
data = forecast(client)
assert all(item['color'] != 'yellow' for item in data['orderlist'])
def test_cartridges_are_sorted_soonest_first(client, printer_with_parts):
data = forecast(client)
daysleft = [c['daysleft'] for c in data['cartridges']]
assert daysleft == sorted(daysleft)
def test_the_horizon_is_published_so_the_page_can_name_it(client, printer_with_parts):
"""The heading says "within N days"; N has to come from whatever decided
the list, or the two drift apart."""
data = forecast(client)
assert data['horizondays'] == 14