167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""Dated inventory outlook, stockout detection, and the two reason codes.
|
|
|
|
Before this, the engine knew "cover is 26 days" but not "you run out on the 24th
|
|
and the next container lands on the 7th". The weekly projection carrying those
|
|
dates was fetched and rendered in a chart, and no decision ever read it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from dashboard.live_data import (
|
|
STOCKOUT_COVER_DAYS, _hist_frame, _inventory_outlook, _proj_date,
|
|
_stockout_days,
|
|
)
|
|
|
|
TODAY = date(2026, 7, 29)
|
|
|
|
|
|
def _snap(day, inventory, arrival=0.0):
|
|
return {"date": f"2026-{day[:2]}-{day[3:]}", "inventory": inventory,
|
|
"warehouse_arrival": arrival, "cover_days": None,
|
|
"inventory_value": None, "po_inventory": 0.0}
|
|
|
|
|
|
# ---------------------------------------------------------------- date parsing
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
("2026-08-12", date(2026, 8, 12)), # ISO dataDate
|
|
("08/12/2026", date(2026, 8, 12)), # MM/DD/YYYY dateMap key
|
|
("2026-08-12T00:00:00", date(2026, 8, 12)), # timestamp
|
|
])
|
|
def test_parses_both_date_shapes_cosmos_mixes(raw, expected):
|
|
assert _proj_date(raw) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("raw", ["", None, "not a date", "13/45/2026"])
|
|
def test_unparseable_dates_are_dropped_not_guessed(raw):
|
|
assert _proj_date(raw) is None
|
|
|
|
|
|
# ---------------------------------------------------------------- stockout date
|
|
def test_stockout_date_comes_from_the_projection_when_it_reaches_zero():
|
|
proj = [_snap("07-29", 900), _snap("08-05", 400), _snap("08-12", 0),
|
|
_snap("08-19", 0)]
|
|
out = _inventory_outlook(proj, units_day=70, today=TODAY)
|
|
assert out["stockout_date"] == date(2026, 8, 12)
|
|
assert out["stockout_source"] == "projection"
|
|
assert out["days_to_stockout"] == 14
|
|
|
|
|
|
def test_falls_back_to_velocity_when_the_curve_never_empties_and_says_so():
|
|
"""A projected date and a straight-line guess must not look alike."""
|
|
proj = [_snap("07-29", 700), _snap("08-05", 650), _snap("08-12", 600)]
|
|
out = _inventory_outlook(proj, units_day=70, today=TODAY)
|
|
assert out["stockout_source"] == "velocity"
|
|
assert out["stockout_date"] == date(2026, 8, 8) # 700 / 70 = 10 days
|
|
|
|
|
|
def test_no_velocity_fallback_when_stock_is_inbound():
|
|
"""A straight line from on-hand cannot see arrivals. Observed live: a SKU whose
|
|
projected inventory RISES 52k->63k on a 10,830-unit arrival was being reported
|
|
as running out in 30 days. With stock inbound the honest answer is "not within
|
|
the horizon", not an invented date the projection disproves."""
|
|
proj = [_snap("07-29", 52_448, arrival=10_830), _snap("08-05", 55_628),
|
|
_snap("08-11", 63_176)]
|
|
out = _inventory_outlook(proj, units_day=1_700, today=TODAY)
|
|
assert out["stockout_date"] is None
|
|
assert out["stockout_source"] is None
|
|
assert out["next_arrival_date"] == date(2026, 7, 29) # relief is still reported
|
|
|
|
|
|
def test_no_velocity_means_no_invented_date():
|
|
proj = [_snap("07-29", 700)]
|
|
assert _inventory_outlook(proj, units_day=0, today=TODAY)["stockout_date"] is None
|
|
|
|
|
|
def test_horizon_is_reported_so_no_stockout_is_not_read_as_never():
|
|
proj = [_snap("07-29", 700), _snap("09-30", 600)]
|
|
out = _inventory_outlook(proj, units_day=0, today=TODAY)
|
|
assert out["horizon_end"] == date(2026, 9, 30)
|
|
assert out["horizon_days"] == 63
|
|
|
|
|
|
def test_empty_projection_returns_all_none_rather_than_raising():
|
|
out = _inventory_outlook([], units_day=100, today=TODAY)
|
|
assert out["stockout_date"] is None and out["next_arrival_date"] is None
|
|
|
|
|
|
def test_out_of_order_snapshots_are_sorted_before_reading():
|
|
proj = [_snap("08-12", 0), _snap("07-29", 900), _snap("08-05", 400)]
|
|
out = _inventory_outlook(proj, units_day=70, today=TODAY)
|
|
assert out["stockout_date"] == date(2026, 8, 12)
|
|
|
|
|
|
# ---------------------------------------------------------------- arrivals
|
|
def test_next_arrival_is_the_first_material_one():
|
|
proj = [_snap("07-29", 900), _snap("08-05", 400, arrival=5),
|
|
_snap("08-12", 0, arrival=5_000)]
|
|
out = _inventory_outlook(proj, units_day=70, today=TODAY)
|
|
assert out["next_arrival_date"] == date(2026, 8, 12) # 5 units is not relief
|
|
assert out["next_arrival_units"] == 5_000
|
|
|
|
|
|
def test_a_trivial_arrival_does_not_count_as_relief():
|
|
proj = [_snap("07-29", 900), _snap("08-05", 400, arrival=10)]
|
|
assert _inventory_outlook(proj, units_day=70, today=TODAY)["next_arrival_date"] is None
|
|
|
|
|
|
def test_a_small_arrival_is_material_for_a_slow_mover():
|
|
"""Materiality is relative to demand — 10 units matters at 1 unit/day."""
|
|
proj = [_snap("07-29", 90), _snap("08-05", 40, arrival=10)]
|
|
out = _inventory_outlook(proj, units_day=1, today=TODAY)
|
|
assert out["next_arrival_date"] == date(2026, 8, 5)
|
|
|
|
|
|
# ---------------------------------------------------------------- stockout days
|
|
def _hist(inv_units):
|
|
return pd.DataFrame({"inventory": [i for i, _ in inv_units],
|
|
"units": [u for _, u in inv_units]})
|
|
|
|
|
|
def test_counts_days_below_one_day_of_cover_not_literal_zero():
|
|
"""Amazon's feed never reports a clean 0 — the audited SKU had 90/90 readings
|
|
and not one at zero, so an `inventory <= 0` test would never fire."""
|
|
oos, known = _stockout_days(_hist([(5, 100), (3, 100), (900, 100), (800, 100)]))
|
|
assert (oos, known) == (2, 4)
|
|
assert STOCKOUT_COVER_DAYS == 1.0
|
|
|
|
|
|
def test_healthy_stock_registers_no_stockout_days():
|
|
assert _stockout_days(_hist([(900, 100)] * 10)) == (0, 10)
|
|
|
|
|
|
def test_missing_readings_are_not_treated_as_empty_shelves():
|
|
"""0 of 0 and 0 of 30 are very different claims."""
|
|
assert _stockout_days(_hist([(None, 100)] * 10)) == (0, 0)
|
|
|
|
|
|
def test_a_frame_without_the_column_degrades_quietly():
|
|
assert _stockout_days(pd.DataFrame({"units": [1, 2]})) == (0, 0)
|
|
|
|
|
|
# ---------------------------------------------------------------- hist frame
|
|
class _Day:
|
|
def __init__(self, d, price, units, inventory):
|
|
self.date, self.sale_price, self.units = d, price, units
|
|
self.inventory = inventory
|
|
self.marketing_cost = self.promotion_spend = 0.0
|
|
self.revenue = self.profit = 0.0
|
|
|
|
|
|
def test_hist_frame_keeps_the_inventory_series():
|
|
"""It was silently dropped — the only historical stock data we get."""
|
|
hist = _hist_frame([_Day("07/28/2026", 26.0, 100, 8_833.0)], 26.0, TODAY)
|
|
assert "inventory" in hist.columns
|
|
assert hist.set_index("date").loc["2026-07-28", "inventory"] == 8_833.0
|
|
|
|
|
|
def test_missing_inventory_stays_nan_and_is_not_filled_with_zero():
|
|
"""Filling with 0 would manufacture phantom stockouts on every gap day."""
|
|
hist = _hist_frame([_Day("07/28/2026", 26.0, 100, None)], 26.0, TODAY)
|
|
assert hist["inventory"].isna().all()
|
|
# ...while the genuinely-zero-able columns still fill
|
|
assert hist["units"].notna().all()
|