AI-Pricing-Agent/tests/test_inventory_outlook.py

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()