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:
@@ -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'] == []
|
||||
|
||||
Reference in New Issue
Block a user