265 lines
11 KiB
Python
265 lines
11 KiB
Python
"""Re-projecting the shelf at a different price.
|
|
|
|
The Inventory tab shows COSMOS's forecast at TODAY's price, so a recommendation to raise or
|
|
cut is otherwise displayed with no stock consequence at all. On a SKU near a stockout that is
|
|
the difference between a sound price move and an expensive one.
|
|
|
|
The invariants that make this safe to show:
|
|
|
|
* a RAISE leaves more stock, a CUT leaves less -- the sign must never invert;
|
|
* week 0 is today's shelf and is untouched by price;
|
|
* arrivals are carried through exactly (a placed PO does not move because we re-priced);
|
|
* stock never goes negative;
|
|
* both cover columns use OUR velocity, so their difference is the price effect alone;
|
|
* bad inputs return [] rather than a fabricated table.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from dashboard.live_data import reproject_inventory
|
|
|
|
|
|
def proj(units, arrivals=None, per_unit=4.45, cover=None):
|
|
"""A COSMOS-shaped weekly projection."""
|
|
arrivals = arrivals or [0] * len(units)
|
|
return [{
|
|
"date": f"2026-08-{i + 1:02d}",
|
|
"inventory": u,
|
|
"inventory_value": u * per_unit,
|
|
"cover_days": (cover[i] if cover else None),
|
|
"warehouse_arrival": arrivals[i],
|
|
} for i, u in enumerate(units)]
|
|
|
|
|
|
# A shelf drawing down 100 units a week from 1,000, on 10 units/day.
|
|
STEADY = proj([1000, 930, 860, 790, 720])
|
|
|
|
|
|
def test_a_raise_leaves_more_stock_on_the_shelf():
|
|
rows = reproject_inventory(STEADY, units_day_now=10.0, units_day_new=8.0)
|
|
assert rows[-1]["units_new"] > rows[-1]["units_now"]
|
|
assert rows[-1]["delta_units"] > 0
|
|
# ...and monotonically so: every week after the first is at least as good.
|
|
assert all(r["delta_units"] >= 0 for r in rows)
|
|
|
|
|
|
def test_a_cut_leaves_less_stock_on_the_shelf():
|
|
rows = reproject_inventory(STEADY, units_day_now=10.0, units_day_new=12.0)
|
|
assert rows[-1]["units_new"] < rows[-1]["units_now"]
|
|
assert all(r["delta_units"] <= 0 for r in rows)
|
|
|
|
|
|
def test_week_zero_is_todays_shelf_and_price_cannot_change_it():
|
|
"""We have not sold anything yet at the new price."""
|
|
for new in (5.0, 10.0, 25.0):
|
|
rows = reproject_inventory(STEADY, 10.0, new)
|
|
assert rows[0]["units_new"] == rows[0]["units_now"] == 1000
|
|
|
|
|
|
def test_no_change_in_rate_reproduces_cosmos_exactly():
|
|
"""The identity case. If this drifts, every other number here is suspect."""
|
|
rows = reproject_inventory(STEADY, 10.0, 10.0)
|
|
assert [r["units_new"] for r in rows] == [1000, 930, 860, 790, 720]
|
|
assert all(r["delta_units"] == 0 for r in rows)
|
|
|
|
|
|
def test_arrivals_are_carried_through_untouched():
|
|
"""A purchase order already placed does not move because we re-priced."""
|
|
p = proj([1000, 950, 1400, 1350], arrivals=[0, 0, 500, 0])
|
|
rows = reproject_inventory(p, 10.0, 7.0)
|
|
assert [r["arrival"] for r in rows] == [0, 0, 500, 0]
|
|
# The arrival still lands in full even though the draw around it shrank.
|
|
assert rows[2]["units_new"] > rows[1]["units_new"]
|
|
|
|
|
|
def test_stock_is_floored_at_zero_and_the_week_is_flagged():
|
|
"""A negative shelf is not a forecast."""
|
|
p = proj([300, 200, 100, 0])
|
|
rows = reproject_inventory(p, 10.0, 40.0) # 4x the burn
|
|
assert all(r["units_new"] >= 0 for r in rows)
|
|
assert any(r["stockout"] for r in rows)
|
|
|
|
|
|
def test_a_flat_cosmos_projection_has_no_draw_to_rescale():
|
|
"""Real case: COSMOS returns the same 167 units for all 11 weeks on a slow SKU.
|
|
|
|
There is no depletion to scale, so units cannot move at any price. The UI says so rather
|
|
than showing an unexplained column of zeros.
|
|
"""
|
|
rows = reproject_inventory(proj([167] * 5), 0.5, 2.0)
|
|
assert all(r["delta_units"] == 0 for r in rows)
|
|
# Cover still responds, because it is computed from our velocity.
|
|
assert rows[0]["cover_new"] < rows[0]["cover_now"]
|
|
|
|
|
|
def test_both_cover_columns_use_our_velocity_not_cosmos_own():
|
|
"""Mixing bases made a 5% RAISE look like it SHRANK cover, 78 -> 76.
|
|
|
|
COSMOS's `coverDays` divides by its own 7-day average; the new column divides by the
|
|
modelled rate. Side by side that difference is the divisor, not the price.
|
|
"""
|
|
p = proj([1000, 930], cover=[78, 72]) # COSMOS's own figures
|
|
rows = reproject_inventory(p, 10.0, 8.0) # a raise: cover must STRETCH
|
|
assert rows[0]["cover_now"] == 100 # 1000 / 10, ours
|
|
assert rows[0]["cover_new"] == 125 # 1000 / 8, ours
|
|
assert rows[0]["cover_new"] > rows[0]["cover_now"]
|
|
# COSMOS's figure is kept alongside, never mixed into the comparison.
|
|
assert rows[0]["cover_cosmos"] == 78
|
|
|
|
|
|
def test_cover_is_floored_the_way_cosmos_floors_it():
|
|
"""5,029 units at 64/day is 78.58 days and COSMOS's grid prints 78, not 79.
|
|
|
|
Rounding put every week exactly one day above COSMOS's own column — a convention
|
|
difference that reads as a discrepancy. Flooring is also the safer direction for a
|
|
stockout figure.
|
|
"""
|
|
rows = reproject_inventory(proj([5029, 4645]), 64.0, 64.0)
|
|
assert rows[0]["cover_now"] == 78 # 78.58 floored, not 79
|
|
assert rows[1]["cover_now"] == 72 # 72.58 floored, not 73
|
|
|
|
|
|
@pytest.mark.parametrize("projections,now,new", [
|
|
([], 10.0, 8.0), # no projection from COSMOS
|
|
(STEADY, 0.0, 8.0), # no current velocity -> ratio undefined
|
|
(STEADY, 10.0, 0.0), # no projected velocity
|
|
(STEADY, -1.0, 8.0), # nonsense input
|
|
])
|
|
def test_unusable_inputs_return_nothing_rather_than_a_guess(projections, now, new):
|
|
assert reproject_inventory(projections, now, new) == []
|
|
|
|
|
|
def test_value_stays_on_cosmos_cost_basis():
|
|
"""Scaled with units at COSMOS's own $/unit, not re-derived from our COGS."""
|
|
rows = reproject_inventory(proj([1000, 900], per_unit=4.45), 10.0, 5.0)
|
|
assert rows[0]["value_new"] == pytest.approx(4450, abs=1)
|
|
# week 1 keeps half the draw, so more units and proportionally more value
|
|
assert rows[1]["value_new"] > 900 * 4.45
|
|
|
|
|
|
# ---------------------------------------------------------------- the chart
|
|
# The comparison chart lives in app.py, which cannot be imported without booting Streamlit,
|
|
# so it is loaded from the module head the same way the other UI tests do.
|
|
import logging
|
|
import pathlib
|
|
import types
|
|
|
|
|
|
def _app_head():
|
|
logging.disable(logging.CRITICAL)
|
|
src = (pathlib.Path(__file__).resolve().parents[1] / "app.py").read_text(encoding="utf-8")
|
|
head = src.split("# ---------------------------------------------------------------- load data")[0]
|
|
mod = types.ModuleType("apphead")
|
|
mod.__dict__.update(__name__="apphead",
|
|
__file__=str(pathlib.Path("app.py").resolve()))
|
|
exec(compile(head, "app.py", "exec"), mod.__dict__)
|
|
return mod
|
|
|
|
|
|
def _band(fig):
|
|
"""The shaded region between the two lines, or None when the lines coincide."""
|
|
for t in fig.data:
|
|
if getattr(t, "fill", None) == "toself":
|
|
return "green" if "10,111,101" in t.fillcolor else "red"
|
|
return None
|
|
|
|
|
|
DEPLETING = proj([1000, 900, 800, 1200, 1100], arrivals=[0, 0, 0, 500, 0])
|
|
|
|
|
|
def test_a_raise_shades_green_a_cut_shades_red():
|
|
mod = _app_head()
|
|
d_up = {"current_price": 20.0, "rec_price": 21.0}
|
|
d_dn = {"current_price": 20.0, "rec_price": 19.0}
|
|
assert _band(mod.inventory_reprice_chart(d_up, reproject_inventory(DEPLETING, 10, 8))) == "green"
|
|
assert _band(mod.inventory_reprice_chart(d_dn, reproject_inventory(DEPLETING, 10, 13))) == "red"
|
|
|
|
|
|
def test_coincident_lines_are_not_shaded_at_all():
|
|
"""A band would colour a difference that does not exist.
|
|
|
|
With `>=` this painted a 5% price CUT green on a SKU whose COSMOS projection is flat,
|
|
implying stock was gained when nothing had changed.
|
|
"""
|
|
mod = _app_head()
|
|
d = {"current_price": 40.99, "rec_price": 38.94}
|
|
rows = reproject_inventory(proj([167] * 5), 0.5, 0.6)
|
|
assert all(r["delta_units"] == 0 for r in rows)
|
|
assert _band(mod.inventory_reprice_chart(d, rows)) is None
|
|
|
|
|
|
def test_both_prices_are_named_in_the_legend():
|
|
"""The reader must be able to tell which line is which without the caption."""
|
|
mod = _app_head()
|
|
fig = mod.inventory_reprice_chart({"current_price": 20.0, "rec_price": 21.0},
|
|
reproject_inventory(DEPLETING, 10, 8))
|
|
names = [t.name for t in fig.data if t.name]
|
|
assert any("$20.00" in n for n in names) and any("$21.00" in n for n in names)
|
|
|
|
|
|
def test_an_empty_reprojection_yields_an_empty_figure_not_an_error():
|
|
mod = _app_head()
|
|
fig = mod.inventory_reprice_chart({"current_price": 20.0, "rec_price": 21.0}, [])
|
|
assert len(fig.data) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------- the grid
|
|
import re as _re
|
|
|
|
|
|
def _grid_cells(html):
|
|
"""(units, value, cover, arrival) per week, straight out of the rendered markup."""
|
|
return _re.findall(
|
|
r'<div class="cell-main">([\d,]+)</div><div class="cell-val">$([\d,]+)</div>'
|
|
r'<div class="cell-sub"><span class="cov">([\d,]+)</span>'
|
|
r'<span class="arr">([\d,]+)</span>', html)
|
|
|
|
|
|
def _d(cur=20.0, rec=21.0):
|
|
return {"cfg": {"inv_class": "alpha", "cost": 4.45}, "current_price": cur,
|
|
"rec_price": rec, "delta_pct": (rec / cur - 1) * 100}
|
|
|
|
|
|
def test_the_grid_renders_the_model_not_a_recomputation():
|
|
"""Every cell must come from the same rows the chart and the table tab use."""
|
|
mod = _app_head()
|
|
rows = reproject_inventory(DEPLETING, 10, 8)
|
|
html = mod.inventory_reprice_table_html(_d(), rows, 10.0, 8.0)
|
|
cells = _grid_cells(html)
|
|
assert [int(c[0].replace(",", "")) for c in cells] == [r["units_new"] for r in rows]
|
|
assert [int(c[3].replace(",", "")) for c in cells] == [int(r["arrival"]) for r in rows]
|
|
|
|
|
|
def test_the_grid_uses_the_same_cover_bands_as_the_cosmos_table_above():
|
|
"""Two grids side by side must not shade the same cover two different ways."""
|
|
from dashboard import theme
|
|
mod = _app_head()
|
|
rows = reproject_inventory(DEPLETING, 10, 8)
|
|
html = mod.inventory_reprice_table_html(_d(), rows, 10.0, 8.0)
|
|
used = set(_re.findall(r"background:(#[0-9a-f]{6})", html))
|
|
valid = {c for _, c, _ in theme.COVER_BANDS["alpha"]}
|
|
assert used <= valid, f"shading outside the Alpha band palette: {used - valid}"
|
|
|
|
|
|
def test_the_change_row_reports_both_units_and_cover():
|
|
mod = _app_head()
|
|
rows = reproject_inventory(DEPLETING, 10, 8)
|
|
html = mod.inventory_reprice_table_html(_d(), rows, 10.0, 8.0)
|
|
assert "Change" in html and "vs today" in html
|
|
# a signed unit delta and a signed cover delta for every week
|
|
assert len(_re.findall(r'cell-main" style="color:#[0-9a-fA-F]{6}">[+-]', html)) == len(rows)
|
|
assert len(_re.findall(r'<span class="cov">[+-][\d,]+ d</span>', html)) == len(rows)
|
|
|
|
|
|
def test_the_grid_has_the_same_column_count_as_the_projection():
|
|
mod = _app_head()
|
|
rows = reproject_inventory(DEPLETING, 10, 8)
|
|
html = mod.inventory_reprice_table_html(_d(), rows, 10.0, 8.0)
|
|
assert html.count("<th>") == len(rows) + 2 # weeks + label + Daily Sale
|
|
|
|
|
|
def test_an_empty_reprojection_renders_nothing_rather_than_an_empty_shell():
|
|
mod = _app_head()
|
|
assert mod.inventory_reprice_table_html(_d(), [], 10.0, 8.0) == ""
|