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

@@ -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),
},
})
))