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'| At the recommended price | Daily Sale | {heads}
'
+ ""
+ # --- row 1: the shelf at the new price
+ ""
+ f'${d["rec_price"]:,.2f} '
+ f'was ${d["current_price"]:,.2f} · {d["delta_pct"]:+.1f}% '
+ f'our projection · class {cls.capitalize()} | '
+ f'{u_new:,.0f} '
+ f'was {u_now:,.0f}/day '
+ f'{(u_new / u_now - 1) if u_now else 0:+.1%} demand | '
+ f'{"".join(at_new)}'
+ "
"
+ # --- row 2: the difference against COSMOS's own projection
+ ""
+ 'Change '
+ 'vs today\'s price '
+ 'units · cover days | '
+ ' | '
+ f'{"".join(delta)}'
+ "
"
+ "
"
+ )
+
+
+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) == ""
|