Forecast when a printer runs out, and count what it has been through

The toner report says what is empty now. It could not say what to order, and
nothing recorded how fast anything drains - every level read was cached for
five minutes and then discarded.

Zabbix has been keeping the history all along; we simply never asked. One
history.get gives both answers, because a cartridge only goes DOWN while it is
in use: a rise is a replacement. Count the rises and you have how many
cartridges a printer has been through; fit a slope to the readings SINCE the
last rise and you have days-to-empty. Fitting across a replacement averages a
spent cartridge with a fresh one and describes neither.

Sorted by days left, which is the point. A cartridge at 60% dropping 5% a day
needs ordering before one sitting at 8% that has not moved in months, and a
level-sorted list ranks those backwards.

It refuses to guess. Too few readings, a level that has not moved enough - many
printers report in 10% steps and sit on a plateau for a fortnight - or a recent
replacement each produce no estimate and say which. Those printers are listed
separately rather than sorted in as 0 or as 999, since a printer without an
estimate is neither urgent nor safe. Estimates show what they rest on, because
"9 days from 21 days of readings" and "9 days from 2 readings" are not the same
claim.

A separate report card, not an extension of the toner report: that one is an
exceptions list a tech acts on today, this is an ordering view read monthly,
and the history query is heavier than the live read it would have slowed down.

The analysis is pure arithmetic over a list of readings, so the 14 tests cover
the noise wobble, the plateau, the swap, junk rows and division by zero without
needing Zabbix. Zabbix being unreachable is reported as such rather than
rendering an empty table that reads as "nothing is due".
This commit is contained in:
cproudlock
2026-08-12 11:45:40 -04:00
parent 2fce81f33f
commit d109314123
8 changed files with 627 additions and 0 deletions

View File

@@ -1482,3 +1482,102 @@ def dashboard_supplies():
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
return success_response(rows)
# =============================================================================
# Supply forecast
#
# The toner report answers "what is empty now". This answers "what will be, and
# what have we been getting through" - a purchasing question, on a different
# cadence, off data Zabbix has been keeping all along.
# =============================================================================
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
@jwt_required(optional=True)
def supplies_forecast():
"""Days-to-empty and replacement counts per printer.
?days=90 how far back to read (Zabbix retention is the real ceiling)
Printers sort by their soonest supply. Anything without an honest estimate
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
try:
days = max(1, min(365, int(request.args.get('days', 90))))
except (TypeError, ValueError):
days = 90
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',
})
rows = (
db.session.query(Printer, Asset, Communication, Vendor)
.join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid)
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
.filter(Asset.isactive == True,
Communication.ipaddress.isnot(None),
Communication.ipaddress != '')
.all()
)
seen = set()
estimated, unestimated = [], []
for printer, asset, comm, vendor in rows:
if printer.printerid in seen:
continue
seen.add(printer.printerid)
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
if not supplies:
continue
itemids = [s['itemid'] for s in supplies if s.get('itemid')]
history = service.gethistory(itemids, days=days)
analysed = []
for supply in supplies:
points = history.get(str(supply.get('itemid')), [])
detail = analyse(points)
detail['name'] = supply.get('name')
detail['color'] = supply.get('color')
# Trust the live read for the level; history can lag a poll behind.
detail['currentlevel'] = supply.get('level', detail['currentlevel'])
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)
# 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'])
return success_response({
'printers': estimated,
'unestimated': unestimated,
'days': days,
'available': True,
'summary': {
'estimated': len(estimated),
'unestimated': len(unestimated),
'replacements': sum(p['replacements'] for p in estimated + unestimated),
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30),
},
})