From 84162554d419654131a23da3b0d511067790d574 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Fri, 31 Jul 2026 20:29:41 +0500 Subject: [PATCH] fix Inventory numbers --- app.py | 190 +++++++++++++++++++++++++++++- dashboard/live_data.py | 8 +- tests/test_reproject_inventory.py | 138 ++++++++++++++++++++++ 3 files changed, 332 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index bc7f58c..cdc8fdf 100644 --- a/app.py +++ b/app.py @@ -745,6 +745,127 @@ def inventory_chart(d) -> go.Figure: return fig +def inventory_reprice_table_html(d, rows: list, u_now: float, u_new: float) -> str: + """The same weekly grid as `inventory_table_html`, re-run at the recommended price. + + Deliberately identical markup and identical cover-band shading, so the two tables can be + read against each other cell for cell — a different layout for the same quantities would + make the comparison work the reader has to do. + + A third row carries the CHANGE, because the interesting number is the difference and + making someone subtract two grids by eye is how a stock risk gets missed. + """ + if not rows: + return "" + cfg = d["cfg"] + cls = cfg["inv_class"] + heads = "".join(f'{pd.Timestamp(r["date"]):%m/%d/%Y}' for r in rows) + + at_new, delta = [], [] + for r in rows: + cover = float(r["cover_new"] or 0) + at_new.append( + f'' + f'
{r["units_new"]:,.0f}
' + f'
${r["value_new"]:,.0f}
' + f'
{cover:,.0f}' + f'{r["arrival"]:,.0f}
' + ) + # The delta row is intentionally UNSHADED: colour here would compete with the cover + # bands above and imply a second, unrelated scale. + du, dc = r["delta_units"], (r["cover_new"] or 0) - (r["cover_now"] or 0) + colour = (theme.DELTA_GOOD if du > 0 else + ("#c9442b" if du < 0 else theme.MUTED)) + delta.append( + f'
{du:+,.0f}
' + f'
{dc:+,.0f} d
' + ) + + return ( + '
' + f'{heads}' + "" + # --- row 1: the shelf at the new price + "" + f'' + f'' + f'{"".join(at_new)}' + "" + # --- row 2: the difference against COSMOS's own projection + "" + '' + '' + f'{"".join(delta)}' + "" + "
At the recommended priceDaily Sale
${d["rec_price"]:,.2f}
' + f'
was ${d["current_price"]:,.2f} · {d["delta_pct"]:+.1f}%
' + f'
our projection · class {cls.capitalize()}
{u_new:,.0f}
' + f'
was {u_now:,.0f}/day
' + f'
{(u_new / u_now - 1) if u_now else 0:+.1%} demand
Change
' + '
vs today\'s price
' + '
units · cover days
 
" + ) + + +def inventory_reprice_chart(d, rows: list) -> go.Figure: + """The same shelf under BOTH prices — today's, and the one being recommended. + + Sits under the projection above so the consequence of the price move is read in the same + glance as the move itself. COSMOS's own line is the solid one; ours is dashed, because one + is their forecast and the other is our model of it and the chart should not pretend + otherwise. The gap between the two IS the price effect. + """ + fig = go.Figure() + if not rows: + return fig + dates = [pd.Timestamp(r["date"]) for r in rows] + now = [r["units_now"] for r in rows] + new = [r["units_new"] for r in rows] + + # Shaded band between the two lines: green when the move leaves more stock on the shelf, + # red when it burns through faster. Colour is never the only cue — the legend names both + # lines and the caption states which is which. + # + # NOT shaded when the two lines coincide. COSMOS returns a flat projection for some very + # low-velocity SKUs (the same units every week), so there is no draw to re-scale and the + # lines sit on top of each other. A band there would colour a difference that does not + # exist — and with `>=` it painted a 5% price CUT green, implying stock gained. + if any(a != b for a, b in zip(now, new)): + gained = new[-1] > now[-1] + fig.add_trace(go.Scatter( + x=dates + dates[::-1], y=new + now[::-1], fill="toself", + fillcolor=("rgba(10,111,101,0.10)" if gained else "rgba(201,68,43,0.10)"), + line=dict(width=0), hoverinfo="skip", showlegend=False)) + + fig.add_trace(go.Scatter( + x=dates, y=now, name=f"At ${d['current_price']:,.2f} (today)", + mode="lines+markers", line=dict(color=theme.SERIES["us"], width=2), + hovertemplate="%{x|%d %b}
%{y:,.0f} unitstoday's price")) + fig.add_trace(go.Scatter( + x=dates, y=new, name=f"At ${d['rec_price']:,.2f} (recommended)", + mode="lines+markers", line=dict(color=theme.SERIES["alt"], width=2, dash="dash"), + hovertemplate="%{x|%d %b}
%{y:,.0f} unitsrecommended price")) + + fig.add_hline(y=0, line_color=theme.STATUS["critical"], line_dash="dot", line_width=1) + # Arrivals move the shelf on BOTH lines, so they are annotated once, on the axis. + for r, dt in zip(rows, dates): + if r["arrival"] > 0: + fig.add_annotation(x=dt, y=0, yref="y", text=f"🚚 +{r['arrival']:,.0f}", + showarrow=False, yshift=-14, + font=dict(size=10, color=theme.INK2)) + # The first week the shelf empties under the new price — the whole point of the chart. + out = next((r for r in rows if r["stockout"]), None) + if out: + fig.add_vline(x=pd.Timestamp(out["date"]), line_color=theme.STATUS["critical"], + line_dash="dot", line_width=1) + fig.add_annotation(x=pd.Timestamp(out["date"]), y=max(now + new), + text="empty at the new price", showarrow=False, + font=dict(size=10, color=theme.STATUS["critical"])) + fig.update_yaxes(rangemode="tozero", title_text="units") + fig.update_layout(height=300, legend=dict(orientation="h", y=1.12, x=0)) + return fig + + # Cover-day color bands (Alpha/Beta scheme, matched to the planning sheet) def _bucket_bg(cover: float, inv_class: str) -> str: """Cell shading by projected cover days, per the SKU's Alpha/Beta band scheme. @@ -1656,6 +1777,68 @@ for _, r in rows.iterrows(): st.plotly_chart(inventory_chart(d), use_container_width=True, config={"displayModeBar": False}, key=f"inv_{sku}") + # ---- and the same shelf at the recommended price, directly below ---- + # The projection above is COSMOS's, at TODAY's price, so the price move is + # otherwise shown with no stock consequence at all. Raising slows the burn; + # cutting empties the shelf sooner, which on a SKU near a stockout is the + # difference between a sound move and an expensive one. + _ed = d.get("elasticity_detail") or {} + _el = _ed.get("elasticity") or FALLBACK_ELASTICITY + # COSMOS's OWN daily sale, the same figure the grid above prints — not our 30-day + # mean from sales history. They differ (64 vs 63.3 on the audited SKU), and using + # ours put two different "daily sale" numbers in adjacent tables AND shifted every + # cover day: 5,029 / 63.3 = 79 where COSMOS says 78. + # + # Driving both off COSMOS's rate makes the cover column reproduce COSMOS's own + # coverDays exactly, so the two grids reconcile line for line. + _u_now = float((d.get("invp_stats") or {}).get("avg_sale") + or d.get("units_day") or 0.0) + _ratio = ((d["rec_price"] / d["current_price"]) ** _el + if d.get("current_price") else 1.0) + _rows = reproject_inventory(d.get("inv_projection") or [], + _u_now, _u_now * _ratio) + _moved = abs(d["rec_price"] - d["current_price"]) >= 0.005 + + st.markdown("---") + if not _moved: + st.caption("**No price change is recommended**, so the shelf above is the " + "outlook. Nothing to re-project.") + elif not _rows: + st.caption(esc( + "Cannot re-project the shelf at the new price for this SKU — that needs " + "COSMOS's weekly projection and a usable demand response, and one of " + "them is missing.")) + else: + _last = _rows[-1] + st.markdown(esc( + f"**At the recommended ${d['rec_price']:,.2f}** " + f"({d['delta_pct']:+.1f}%), demand moves {_ratio - 1:+.1%} — " + f"**{_last['units_new']:,} units** left at the end of the horizon " + f"against {_last['units_now']:,} at today's price " + f"(**{_last['delta_units']:+,}**).")) + if all(r["delta_units"] == 0 for r in _rows): + st.caption(esc( + f"COSMOS projects no depletion for this SKU — the same " + f"{_rows[0]['units_now']:,} units in every snapshot — so there is no " + f"draw to re-scale and the two lines coincide. Only cover days move.")) + # The same grid as above, at the new price, so the two read cell for cell. + st.markdown( + inventory_reprice_table_html(d, _rows, _u_now, _u_now * _ratio), + unsafe_allow_html=True) + st.caption( + "Same layout as the COSMOS grid above and the same Alpha/Beta cover " + "shading, so the two compare cell for cell. **Change** is this projection " + "minus COSMOS's — green = more stock left, red = burns through faster.") + st.plotly_chart(inventory_reprice_chart(d, _rows), + use_container_width=True, + config={"displayModeBar": False}, key=f"invrp_{sku}") + st.caption( + "Solid = COSMOS's projection at today's price. Dashed = **our** model of " + "the same shelf at the recommended price, scaling each week's SALES by " + "the elasticity the Scenarios tab uses. Arrivals are unchanged — a PO " + "already placed does not move because we re-priced — and stock is floored " + "at zero. Full week-by-week numbers are on **🔁 Stock at the new price**.") + elif sel == "repricing": # What the recommended price does to the SHELF. The Inventory tab above is # COSMOS's projection at TODAY's price, so a raise or a cut is otherwise shown @@ -1677,7 +1860,10 @@ for _, r in rows.iterrows(): # two rows, so this cannot drift from that table. _ed = d.get("elasticity_detail") or {} el = _ed.get("elasticity") or FALLBACK_ELASTICITY - u_now = float(d.get("units_day") or 0.0) + # COSMOS's own daily sale — the figure its Inventory Planning grid prints — so the + # cover days here reproduce COSMOS's coverDays rather than sitting a day or two off. + u_now = float((d.get("invp_stats") or {}).get("avg_sale") + or d.get("units_day") or 0.0) ratio = ((d["rec_price"] / d["current_price"]) ** el if d.get("current_price") else 1.0) u_new = u_now * ratio @@ -1698,7 +1884,7 @@ for _, r in rows.iterrows(): r1.metric("Price", f"${d['rec_price']:,.2f}", f"{d['delta_pct']:+.1f}% vs ${d['current_price']:,.2f}", delta_color="off") - r2.metric("Units/day", f"{u_new:,.1f}", f"{pct:+.1f}% vs {u_now:,.1f} today", + r2.metric("Units/day", f"{u_new:,.0f}", f"{pct:+.1f}% vs {u_now:,.0f} today", delta_color="off") last = rows[-1] r3.metric("Stock at horizon", f"{last['units_new']:,}", diff --git a/dashboard/live_data.py b/dashboard/live_data.py index beff0f0..7b65323 100644 --- a/dashboard/live_data.py +++ b/dashboard/live_data.py @@ -1235,6 +1235,10 @@ def reproject_inventory(projections: list, units_day_now: float, units_day_new: # # The consequence is that this column can differ by a day or two from the Inventory # tab, which shows COSMOS's figure verbatim. That is stated in the caption. + # FLOORED, not rounded, because that is what COSMOS does: 5,029 units over a 64/day + # rate is 78.58 days and its grid prints 78. Rounding put every week exactly one day + # above COSMOS's own column, which reads as a discrepancy rather than a convention. + # Flooring is also the safer direction for a stockout figure. cover_now = (units / units_day_now) if units_day_now > 0 else None cover_new = (running / units_day_new) if units_day_new > 0 else None rows.append({ @@ -1243,8 +1247,8 @@ def reproject_inventory(projections: list, units_day_now: float, units_day_new: "units_new": int(round(running)), "delta_units": int(round(running - units)), "value_new": round(running * per_unit), - "cover_now": int(round(cover_now)) if cover_now is not None else None, - "cover_new": int(round(cover_new)) if cover_new is not None else None, + "cover_now": int(cover_now) if cover_now is not None else None, + "cover_new": int(cover_new) if cover_new is not None else None, # COSMOS's own figure, carried alongside rather than mixed into the comparison. "cover_cosmos": (int(p["cover_days"]) if p.get("cover_days") is not None else None), "arrival": arrival, diff --git a/tests/test_reproject_inventory.py b/tests/test_reproject_inventory.py index 7631d9f..5d7efe6 100644 --- a/tests/test_reproject_inventory.py +++ b/tests/test_reproject_inventory.py @@ -108,6 +108,18 @@ def test_both_cover_columns_use_our_velocity_not_cosmos_own(): 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 @@ -124,3 +136,129 @@ def test_value_stays_on_cosmos_cost_basis(): 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'
([\d,]+)
$([\d,]+)
' + r'
([\d,]+)' + r'([\d,]+)', 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'[+-][\d,]+ d', 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("") == 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) == ""