172 lines
6.9 KiB
Python
172 lines
6.9 KiB
Python
"""The two inventory reason codes, and a guard that behaviour did not change.
|
|
|
|
`STOCKOUT_BEFORE_ARRIVAL` and `INBOUND_PO` had labels in REASON_LABELS and no
|
|
condition anywhere — dead codes. They are ADDITIVE by design: the low-stock
|
|
branch already recommends +5%, which is the step cap, so there is nothing to
|
|
escalate to. These tests pin that intent, so a later reader does not "fix" the
|
|
missing escalation and silently start moving prices further.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from dashboard.live_data import (
|
|
HIGH_COVER_DAYS, LOW_COVER_DAYS, REASON_LABELS, _decide_raw,
|
|
)
|
|
|
|
TODAY = date(2026, 7, 29)
|
|
SCEN = pd.DataFrame([
|
|
{"scenario": "current", "price": 26.07}, {"scenario": "up_2", "price": 26.59},
|
|
{"scenario": "up_5", "price": 27.37}, {"scenario": "down_2", "price": 25.55},
|
|
{"scenario": "down_5", "price": 24.77},
|
|
])
|
|
FP = {"referral_pct": 0.15, "returns_pct": 0.02, "cost": 9.28, "fba": 8.97,
|
|
"variable": 0.11}
|
|
|
|
|
|
class _R:
|
|
"""Minimal AnalysisResult stand-in."""
|
|
|
|
def __init__(self, cover, avg_30d=100.0, avg_6m=100.0):
|
|
self.cover_days = cover
|
|
self.break_even = 22.19
|
|
self.is_losing_money = False
|
|
self.avg_30d, self.avg_6m = avg_30d, avg_6m
|
|
self.ad_cost_per_unit = 1.58
|
|
self.elasticity = None
|
|
self.profit_optimal_price = None
|
|
self.ad_curve_unrecoverable = False
|
|
self.contribution_per_dollar = 0.64
|
|
self.break_even_ad_curve = None
|
|
self.observed_price_max = 28.87
|
|
self.price_bands = []
|
|
self.best_observed_price = None
|
|
self.best_observed_profit_day = None
|
|
self.elasticity_actionable = False
|
|
self.profit_optimal_is_corner = False
|
|
|
|
|
|
def _hist(days=40, inventory=9_000.0, units=100.0):
|
|
return pd.DataFrame({
|
|
"price": [26.07] * days, "units": [units] * days,
|
|
"ad_spend": [150.0] * days, "revenue": [2_600.0] * days,
|
|
"profit": [50.0] * days, "inventory": [inventory] * days,
|
|
})
|
|
|
|
|
|
def _decide(r, outlook=None, hist=None):
|
|
action, rec, cons, aggr, reasons, root, obj = _decide_raw(
|
|
r, 26.07, hist if hist is not None else _hist(), FP, SCEN, outlook)
|
|
return {"action": action, "rec": rec, "reasons": reasons, "objective": obj,
|
|
"root": dict(root)}
|
|
|
|
|
|
def _outlook(stockout=None, arrival=None, horizon_days=63):
|
|
return {"stockout_date": stockout, "next_arrival_date": arrival,
|
|
"stockout_source": "projection" if stockout else None,
|
|
"days_to_stockout": (stockout - TODAY).days if stockout else None,
|
|
"next_arrival_units": 5_000.0 if arrival else 0.0,
|
|
"horizon_days": horizon_days, "horizon_end": None}
|
|
|
|
|
|
# ---------------------------------------------------------------- labels exist
|
|
def test_both_codes_have_labels_so_the_ui_can_render_them():
|
|
assert REASON_LABELS["STOCKOUT_BEFORE_ARRIVAL"]
|
|
assert REASON_LABELS["INBOUND_PO"]
|
|
|
|
|
|
# ---------------------------------------------------------------- the codes fire
|
|
def test_stockout_before_arrival_fires_when_the_dates_cross():
|
|
got = _decide(_R(cover=26),
|
|
_outlook(stockout=date(2026, 8, 12), arrival=date(2026, 8, 26)))
|
|
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
|
|
assert "14 days uncovered" in got["root"]["Stockout outlook"]
|
|
|
|
|
|
def test_it_does_not_fire_when_the_arrival_beats_the_stockout():
|
|
got = _decide(_R(cover=26),
|
|
_outlook(stockout=date(2026, 8, 26), arrival=date(2026, 8, 12)))
|
|
assert "STOCKOUT_BEFORE_ARRIVAL" not in got["reasons"]
|
|
assert "arrives in time" in got["root"]["Stockout outlook"]
|
|
|
|
|
|
def test_it_fires_when_no_arrival_is_scheduled_at_all():
|
|
got = _decide(_R(cover=26), _outlook(stockout=date(2026, 8, 12), arrival=None))
|
|
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
|
|
assert "no arrival scheduled" in got["root"]["Stockout outlook"]
|
|
|
|
|
|
def test_inbound_po_fires_on_a_scheduled_arrival():
|
|
got = _decide(_R(cover=26),
|
|
_outlook(stockout=date(2026, 8, 26), arrival=date(2026, 8, 12)))
|
|
assert "INBOUND_PO" in got["reasons"]
|
|
|
|
|
|
def test_inbound_po_also_reported_on_overstock():
|
|
got = _decide(_R(cover=120), _outlook(arrival=date(2026, 8, 12)))
|
|
assert got["action"] == "Decrease"
|
|
assert {"EXCESS_STOCK", "INBOUND_PO"} <= set(got["reasons"])
|
|
|
|
|
|
# ------------------------------------------------- additive, NOT escalating
|
|
def test_the_codes_do_not_change_the_action_or_the_rung():
|
|
"""+5% is already MAX_STEP_PCT — there is nothing to escalate to. If this
|
|
test fails, someone added escalation; that needs an explicit policy decision,
|
|
not a quiet code change."""
|
|
plain = _decide(_R(cover=26), _outlook())
|
|
urgent = _decide(_R(cover=26),
|
|
_outlook(stockout=date(2026, 8, 1), arrival=date(2026, 9, 30)))
|
|
assert plain["action"] == urgent["action"] == "Increase"
|
|
assert plain["rec"] == urgent["rec"] == "up_5"
|
|
assert plain["objective"] == urgent["objective"] == "low_stock_protection"
|
|
|
|
|
|
# ---------------------------------------------- stockout vs demand drop
|
|
def test_a_stockout_explained_drop_is_no_longer_an_unexplained_mystery():
|
|
"""Cutting price cannot refill a shelf. This used to land on Investigate."""
|
|
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0) # a real velocity drop
|
|
starved = _hist(days=30, inventory=5.0, units=100.0) # ...while out of stock
|
|
got = _decide(r, _outlook(), hist=starved)
|
|
assert got["action"] == "Maintain"
|
|
assert "UNEXPLAINED_DROP" not in got["reasons"]
|
|
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
|
|
assert "enough to explain" in got["root"]["Days effectively out of stock (30d)"]
|
|
|
|
|
|
def test_a_drop_with_healthy_stock_is_still_investigated():
|
|
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0)
|
|
got = _decide(r, _outlook(), hist=_hist(days=30, inventory=9_000.0))
|
|
assert got["action"] == "Investigate"
|
|
assert "UNEXPLAINED_DROP" in got["reasons"]
|
|
|
|
|
|
def test_a_drop_with_no_inventory_readings_stays_investigated():
|
|
"""Absence of data is not evidence of a stockout."""
|
|
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0)
|
|
blind = _hist(days=30)
|
|
blind["inventory"] = None
|
|
got = _decide(r, _outlook(), hist=blind)
|
|
assert got["action"] == "Investigate"
|
|
|
|
|
|
# ---------------------------------------------- unchanged-behaviour guard
|
|
@pytest.mark.parametrize("cover", [LOW_COVER_DAYS + 1, 60, HIGH_COVER_DAYS - 1])
|
|
def test_healthy_cover_is_untouched_by_any_of_this(cover):
|
|
"""A SKU between the thresholds with no stockout must behave exactly as before."""
|
|
got = _decide(_R(cover=cover), _outlook())
|
|
assert got["action"] == "Maintain"
|
|
assert got["reasons"] == ["NO_SIGNALS"]
|
|
|
|
|
|
def test_thresholds_are_unchanged():
|
|
"""These were explicitly out of scope; pin them so a refactor cannot drift."""
|
|
assert (LOW_COVER_DAYS, HIGH_COVER_DAYS) == (35, 90)
|
|
|
|
|
|
def test_outlook_is_optional_so_older_callers_keep_working():
|
|
got = _decide(_R(cover=26), outlook=None)
|
|
assert got["action"] == "Increase" and "LOW_STOCK" in got["reasons"]
|