diff --git a/CHANGELOG.md b/CHANGELOG.md index 6343126..228ac33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api-inventory.json b/docs/api-inventory.json index 0456c24..03429f6 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -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'" } ] diff --git a/docs/openapi.json b/docs/openapi.json index cd4f381..8751581 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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": [] diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 2b9ba9f..d804658 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -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), }, - }) + )) diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index 8d557ab..f9b4e2e 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -24,73 +24,110 @@