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

@@ -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'] == []