fix Inventory numbers
parent
6a0ce38546
commit
84162554d4
190
app.py
190
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'<th>{pd.Timestamp(r["date"]):%m/%d/%Y}</th>' for r in rows)
|
||||
|
||||
at_new, delta = [], []
|
||||
for r in rows:
|
||||
cover = float(r["cover_new"] or 0)
|
||||
at_new.append(
|
||||
f'<td style="background:{_bucket_bg(cover, cls)}">'
|
||||
f'<div class="cell-main">{r["units_new"]:,.0f}</div>'
|
||||
f'<div class="cell-val">${r["value_new"]:,.0f}</div>'
|
||||
f'<div class="cell-sub"><span class="cov">{cover:,.0f}</span>'
|
||||
f'<span class="arr">{r["arrival"]:,.0f}</span></div></td>'
|
||||
)
|
||||
# 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'<td><div class="cell-main" style="color:{colour}">{du:+,.0f}</div>'
|
||||
f'<div class="cell-sub"><span class="cov">{dc:+,.0f} d</span></div></td>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<div class="invt-wrap"><table class="invt">'
|
||||
f'<thead><tr><th>At the recommended price</th><th>Daily Sale</th>{heads}</tr></thead>'
|
||||
"<tbody>"
|
||||
# --- row 1: the shelf at the new price
|
||||
"<tr>"
|
||||
f'<td><div class="p-t">${d["rec_price"]:,.2f}</div>'
|
||||
f'<div class="p-s">was ${d["current_price"]:,.2f} · {d["delta_pct"]:+.1f}%</div>'
|
||||
f'<div class="p-s">our projection · class {cls.capitalize()}</div></td>'
|
||||
f'<td><div class="cell-main">{u_new:,.0f}</div>'
|
||||
f'<div class="p-s">was {u_now:,.0f}/day</div>'
|
||||
f'<div class="p-s">{(u_new / u_now - 1) if u_now else 0:+.1%} demand</div></td>'
|
||||
f'{"".join(at_new)}'
|
||||
"</tr>"
|
||||
# --- row 2: the difference against COSMOS's own projection
|
||||
"<tr>"
|
||||
'<td><div class="p-t">Change</div>'
|
||||
'<div class="p-s">vs today\'s price</div>'
|
||||
'<div class="p-s">units · cover days</div></td>'
|
||||
'<td><div class="p-s"> </div></td>'
|
||||
f'{"".join(delta)}'
|
||||
"</tr>"
|
||||
"</tbody></table></div>"
|
||||
)
|
||||
|
||||
|
||||
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}<br>%{y:,.0f} units<extra>today's price</extra>"))
|
||||
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}<br>%{y:,.0f} units<extra>recommended price</extra>"))
|
||||
|
||||
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']:,}",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'<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) == ""
|
||||
|
|
|
|||
Loading…
Reference in New Issue