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.
### 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
- 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",
"auth": "jwt-optional",
"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'"
}
]

View File

@@ -6562,8 +6562,8 @@
"tags": [
"plugin-printers"
],
"summary": "Days-to-empty per printer from Zabbix history, soonest first, with cartridge replacement counts; printers with no honest",
"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```",
"summary": "One row per CARTRIDGE: days-to-empty from Zabbix history (hourly trends over a long window), soonest first, banded empty",
"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": [
{
"bearerAuth": []

View File

@@ -1503,26 +1503,35 @@ def supplies_forecast():
is returned separately with the reason, rather than sorted as though it
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:
days = max(1, min(365, int(request.args.get('days', 90))))
except (TypeError, ValueError):
days = 90
empty = {
'cartridges': [], 'unestimated': [], 'orderlist': [], 'days': days,
'horizondays': ORDER_HORIZON_DAYS,
}
service = ZabbixService()
if not service.isconfigured or not service.isreachable:
return success_response({
'printers': [], 'unestimated': [], 'days': days,
'available': False,
'reason': 'Zabbix is not configured or not reachable',
})
return success_response(dict(
empty, available=False,
reason='Zabbix is not configured or not reachable'))
rows = (
db.session.query(Printer, Asset, Communication, Vendor)
db.session.query(Printer, Asset, Communication, Vendor, Model)
.join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid)
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
.outerjoin(Model, Model.modelnumberid == Printer.modelnumberid)
.filter(Asset.isactive == True,
Communication.ipaddress.isnot(None),
Communication.ipaddress != '')
@@ -1530,9 +1539,9 @@ def supplies_forecast():
)
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:
continue
seen.add(printer.printerid)
@@ -1543,7 +1552,9 @@ def supplies_forecast():
itemids = [s['itemid'] for s in supplies if s.get('itemid')]
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:
points = history.get(str(supply.get('itemid')), [])
# 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
# as broken.
detail = analyse(points, currentlevel=supply.get('level'))
detail['name'] = supply.get('name')
detail['color'] = supply.get('color')
analysed.append(detail)
entry = {
'printerid': printer.printerid,
'printername': asset.name or printer.hostname or '',
'assetnumber': asset.assetnumber or '',
'ipaddress': comm.ipaddress,
'vendor': vendor.vendor if vendor else None,
'supplies': analysed,
'daysleft': soonest(analysed),
'replacements': sum(s['replacements'] for s in analysed),
}
(estimated if entry['daysleft'] is not None else unestimated).append(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,
'printername': asset.name or printer.hostname or '',
'assetnumber': asset.assetnumber or '',
'ipaddress': comm.ipaddress,
'vendor': vendor.vendor if vendor else None,
'model': model.modelnumber if model else None,
'band': band(detail['daysleft']),
})
# The chart series is per cartridge and nothing on this report
# 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.
estimated.sort(key=lambda p: p['daysleft'])
unestimated.sort(key=lambda p: p['printername'])
cartridges.sort(key=lambda c: (c['daysleft'], c['printername']))
unestimated.sort(key=lambda c: (c['printername'], c['name']))
return success_response({
'printers': estimated,
'unestimated': unestimated,
'days': days,
'available': True,
'summary': {
'estimated': len(estimated),
counts = {name: 0 for name in ('empty', 'soon', 'month', 'later')}
for cartridge in cartridges:
counts[cartridge['band']] += 1
toorder = orderlist(cartridges)
return success_response(dict(
empty,
cartridges=cartridges,
unestimated=unestimated,
orderlist=toorder,
available=True,
summary={
'bands': counts,
'estimated': len(cartridges),
'unestimated': len(unestimated),
'replacements': sum(p['replacements'] for p in estimated + unestimated),
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30),
'replacements': sum(c['replacements']
for c in cartridges + unestimated),
'toorder': sum(item['quantity'] for item in toorder),
},
})
))

View File

@@ -24,73 +24,110 @@
</div>
<template v-else>
<div class="summary-row">
<div class="summary-card">
<span class="summary-number">{{ summary.duewithin30 }}</span>
<span class="summary-label">due within 30 days</span>
<!-- The answer first. Everything below it is the evidence. -->
<div class="card order-card">
<div class="order-head">
<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 class="summary-card">
<span class="summary-number">{{ summary.estimated }}</span>
<span class="summary-label">printers with an estimate</span>
</div>
<div class="summary-card">
<span class="summary-number">{{ summary.replacements }}</span>
<span class="summary-label">cartridges changed in {{ days }} days</span>
<p v-if="!orderlist.length" class="muted nothing-due">
Nothing is due in the next {{ horizondays }} days.
</p>
<ul v-else class="order-list">
<li v-for="item in mapped" :key="orderKey(item)">
<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 class="card">
<div class="table-container">
<!-- Bands, not a sortable table: the question is "which pile is this in",
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>
<thead>
<tr>
<th class="colcolor"></th>
<th>Printer</th>
<th>Runs out in</th>
<th>Supply</th>
<th>Level</th>
<th>Burn rate</th>
<th>Part</th>
<th class="collevel">Level</th>
<th>Runs out</th>
<th>Rate</th>
<th>Changed</th>
<th>Based on</th>
</tr>
</thead>
<tbody>
<!-- Sorted by days left, not by level: a cartridge at 60% falling
fast is ordered before one sitting at 8% that never moves.
Days left is per CARTRIDGE, not per printer. It used to 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>
<span v-if="s.daysleft === 0" class="days critical">empty now</span>
<span v-else-if="s.daysleft != null" class="days" :class="urgency(s.daysleft)">
{{ s.daysleft }} days
</span>
<span v-else class="muted">-</span>
</td>
<td>{{ s.name }}</td>
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
<td>{{ s.burnrateperday != null ? s.burnrateperday + '%/day' : '-' }}</td>
<td>{{ s.replacements || 0 }}</td>
<td class="muted small">
{{ s.reason ? s.reason : s.basisdays + ' days of readings' }}
</td>
</tr>
</template>
<tr v-if="!printers.length">
<td colspan="7" class="muted" style="text-align:center;">
Nothing can be estimated yet from {{ days }} days of history.
<tr v-for="c in group.items" :key="c.printerid + c.name">
<td class="colcolor">
<span class="chip" :style="chipStyle(c.color)"
:title="colorLabel(c.color)"></span>
</td>
<td>
<router-link :to="`/printers/${c.printerid}`">
{{ c.printername || c.assetnumber }}
</router-link>
<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>
</td>
<td class="collevel">
<!-- A bar reads at a glance where a percentage has to be
compared digit by digit against the row above. -->
<span class="bar" :title="levelText(c)">
<span class="bar-fill" :class="c.band"
:style="{ width: barWidth(c) }"></span>
</span>
<span class="level-text">{{ levelText(c) }}</span>
</td>
<td>
<span class="days" :class="c.band">{{ daysText(c) }}</span>
</td>
<td class="muted small">
{{ c.burnrateperday != null ? c.burnrateperday + '%/day' : '-' }}
</td>
<td class="muted small">
{{ c.replacements || 0 }}
<span v-if="c.basisdays">/ {{ c.basisdays }}d</span>
</td>
</tr>
</tbody>
@@ -98,38 +135,51 @@
</div>
</div>
<!-- Kept separate rather than sorted in as 0 or as 999: a printer with no
estimate is neither urgent nor safe, and the reason is the useful part. -->
<div v-if="unestimated.length" class="card">
<h3 class="section-title">No estimate yet ({{ unestimated.length }})</h3>
<div class="table-container">
<!-- 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 point. -->
<div v-if="unestimated.length" class="card band-card">
<button class="band-head" @click="toggle('none')">
<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>
<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>
<tbody>
<template v-for="p in unestimated" :key="p.printerid">
<tr v-for="s in p.supplies" :key="p.printerid + s.name">
<td>
<router-link :to="`/printers/${p.printerid}`">
{{ p.printername || p.assetnumber }}
</router-link>
</td>
<td>{{ s.name }}</td>
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
<td class="muted">{{ s.reason || '-' }}</td>
</tr>
</template>
<tr v-for="c in unestimated" :key="c.printerid + c.name">
<td class="colcolor">
<span class="chip" :style="chipStyle(c.color)"></span>
</td>
<td>
<router-link :to="`/printers/${c.printerid}`">
{{ c.printername || c.assetnumber }}
</router-link>
</td>
<td>{{ c.name }}</td>
<td class="collevel">{{ levelText(c) }}</td>
<td class="muted">{{ c.reason || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="muted footnote">
{{ summary.replacements }} cartridges changed across the fleet in the
last {{ days }} days.
</p>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import { apiError } from '@/utils/apiError'
@@ -137,14 +187,117 @@ const loading = ref(true)
const available = ref(false)
const reason = ref('')
const days = ref(90)
const printers = ref([])
const horizondays = ref(14)
const cartridges = 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) {
if (daysleft <= 7) return 'critical'
if (daysleft <= 30) return 'low'
return 'ok'
// Only what is already empty starts open. The order list above covers the same
// ground in a tenth of the height, so the bands are evidence, asked for.
const openBands = ref(new Set(['empty']))
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() {
@@ -154,8 +307,10 @@ async function load() {
const data = response.data.data
available.value = data.available
reason.value = data.reason || ''
printers.value = data.printers || []
cartridges.value = data.cartridges || []
unestimated.value = data.unestimated || []
orderlist.value = data.orderlist || []
horizondays.value = data.horizondays || 14
summary.value = data.summary || summary.value
} catch (err) {
available.value = false
@@ -171,21 +326,104 @@ onMounted(load)
<style scoped>
.actions { display: flex; gap: 0.5rem; align-items: center; }
.actions .form-control { width: auto; }
.summary-row { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
.summary-card {
background: var(--bg-card-solid);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem 1.5rem;
min-width: 12rem;
.order-card { padding: 0 0 0.5rem; }
.order-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 1rem;
}
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; }
.summary-label { color: var(--text-light); font-size: 0.85rem; }
.order-head .section-title { padding: 0.75rem 0; }
.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.critical { color: var(--danger); }
.days.low { color: var(--warning); }
.days.empty { color: var(--danger); }
.days.soon { color: var(--warning); }
.small { font-size: 0.8rem; }
.row-group-start td { border-top: 2px solid var(--border); }
.empty-state { padding: 2rem; text-align: center; }
.section-title { padding: 0.75rem 1rem 0; }
.footnote { margin-top: 1rem; font-size: 0.85rem; }
</style>

View File

@@ -185,6 +185,76 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
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):
"""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 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)
@@ -170,6 +171,91 @@ def test_burn_rate_needs_elapsed_time():
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):
"""A dashboard must be told the data is missing, not shown an empty list
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']
assert body['available'] is False
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