test_stock_forecast_2955.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """The backend forecast against ForecastPanel.tsx, on the same inputs (#2955).
  2. ``stock_forecast`` is the panel's arithmetic moved out of the browser so the
  3. stock alerts have something to run on. The two exist side by side until the
  4. panel reads the backend's values, so these tests pin them: ``EXPECTED`` was
  5. produced by running the panel's own functions (``computeHistoryRate``,
  6. ``computeDeltaRate`` and the block that turns them into a reorder point and the
  7. two alert flags, copied verbatim from ``ForecastPanel.tsx``) under Node with
  8. ``Date.now()`` fixed to ``NOW`` and ``TZ=UTC``, on the scenarios below. A change
  9. to either side that moves a number shows up here.
  10. ``TZ=UTC`` matters for exactly one thing: the panel parses a timezone-less
  11. ``created_at`` as browser-local time, the backend reads it as UTC, and the delta
  12. rate divides by an age in days, so the two differ by the browser's offset. The
  13. database stores UTC, so UTC is what the timestamps mean.
  14. """
  15. from datetime import datetime, timedelta, timezone
  16. import pytest
  17. from backend.app.services.stock_forecast import (
  18. SkuSettings,
  19. StockSpool,
  20. UsageRecord,
  21. delta_rate,
  22. forecast_all,
  23. forecast_sku,
  24. history_rate,
  25. sku_key,
  26. )
  27. NOW = datetime(2026, 9, 30, tzinfo=timezone.utc)
  28. def _ago(days: float) -> datetime:
  29. """A naive UTC timestamp ``days`` before NOW, the shape the database returns."""
  30. return (NOW - timedelta(days=days)).replace(tzinfo=None)
  31. def _spool(spool_id: int, used: float, age_days: float, baseline: float = 0) -> StockSpool:
  32. return StockSpool(
  33. id=spool_id,
  34. material="PLA",
  35. subtype="Basic",
  36. brand="Bambu Lab",
  37. color_name="Black",
  38. label_weight=1000,
  39. weight_used=used,
  40. weight_used_baseline=baseline,
  41. created_at=_ago(age_days),
  42. )
  43. def _history(*events: tuple[int, float, float]) -> dict[int, list[UsageRecord]]:
  44. """(spool id, days ago, grams) triples, grouped by spool the way the caller does."""
  45. out: dict[int, list[UsageRecord]] = {}
  46. for spool_id, ago, grams in events:
  47. out.setdefault(spool_id, []).append(UsageRecord(created_at=_ago(ago), weight_used=grams))
  48. return out
  49. # name -> (spools, history, settings, global lead time). The same scenarios, in the
  50. # same order, as the Node run that produced EXPECTED.
  51. SCENARIOS = {
  52. # History rate, no settings row, lead time from the global setting.
  53. "A": (
  54. [_spool(1, 600, 60), _spool(2, 0, 10)],
  55. _history((1, 40, 120), (1, 25, 90), (1, 12, 200), (1, 3, 150)),
  56. None,
  57. 7,
  58. ),
  59. # No history: the delta rate, with the margin given in days.
  60. "B": ([_spool(1, 300, 20)], {}, SkuSettings(lead_time_days=14, safety_margin_value=10), 0),
  61. # A reset spool's history is left out; the clean spool's is kept.
  62. "C": (
  63. [_spool(1, 700, 50, baseline=200), _spool(2, 300, 30)],
  64. _history((1, 20, 400), (1, 10, 50), (2, 20, 100), (2, 6, 100), (2, 1, 100)),
  65. SkuSettings(lead_time_days=5),
  66. 0,
  67. ),
  68. # Stock break from the delta rate: 100 g left, 30 days of lead time.
  69. "D": ([_spool(1, 900, 30)], {}, SkuSettings(lead_time_days=30), 0),
  70. # Margin in grams.
  71. "E": (
  72. [_spool(1, 500, 90)],
  73. _history((1, 30, 80), (1, 20, 60), (1, 10, 100)),
  74. SkuSettings(lead_time_days=10, safety_margin_value=150, safety_margin_unit="g"),
  75. 0,
  76. ),
  77. # Nothing consumed: no rate, so no dates and no alerts.
  78. "F": ([_spool(1, 0, 40)], {}, None, 7),
  79. # Younger than a day: the delta rate is not measurable. The global lead time wins over the SKU's.
  80. "G": ([_spool(1, 200, 0.5)], {}, SkuSettings(lead_time_days=3), 9),
  81. # Under the reorder point with more than the lead time of stock left: a reorder.
  82. "H": (
  83. [_spool(1, 850, 100)],
  84. _history((1, 50, 200), (1, 40, 200), (1, 20, 200)),
  85. SkuSettings(lead_time_days=6, safety_margin_value=3),
  86. 0,
  87. ),
  88. # The same history with less left: a stock break, not also a reorder.
  89. "I": (
  90. [_spool(1, 930, 100)],
  91. _history((1, 50, 200), (1, 40, 200), (1, 20, 200)),
  92. SkuSettings(lead_time_days=6, safety_margin_value=3),
  93. 0,
  94. ),
  95. # Two spools of one SKU: the delta rate divides by the age of the oldest.
  96. "J": ([_spool(1, 400, 400), _spool(2, 100, 5)], {}, SkuSettings(lead_time_days=10), 0),
  97. }
  98. EXPECTED = {
  99. "A": {
  100. "remaining_g": 1400,
  101. "daily_rate_g": 13.57710201710711,
  102. "effective_lead_time_days": 7,
  103. "reorder_point_g": 304.32790456992615,
  104. "days_remaining": 103,
  105. "days_until_reorder_point": 80,
  106. "stock_break_alert": False,
  107. "reorder_alert": False,
  108. },
  109. "B": {
  110. "remaining_g": 700,
  111. "daily_rate_g": 15,
  112. "effective_lead_time_days": 14,
  113. "reorder_point_g": 378.521204064531,
  114. "days_remaining": 46,
  115. "days_until_reorder_point": 21,
  116. "stock_break_alert": False,
  117. "reorder_alert": False,
  118. },
  119. "C": {
  120. "remaining_g": 1000,
  121. "daily_rate_g": 13.942344991570476,
  122. "effective_lead_time_days": 5,
  123. "reorder_point_g": 288.583334452561,
  124. "days_remaining": 71,
  125. "days_until_reorder_point": 51,
  126. "stock_break_alert": False,
  127. "reorder_alert": False,
  128. },
  129. "D": {
  130. "remaining_g": 100,
  131. "daily_rate_g": 30,
  132. "effective_lead_time_days": 30,
  133. "reorder_point_g": 1374.2245331930114,
  134. "days_remaining": 3,
  135. "days_until_reorder_point": -43,
  136. "stock_break_alert": True,
  137. "reorder_alert": False,
  138. },
  139. "E": {
  140. "remaining_g": 500,
  141. "daily_rate_g": 8.230026663902231,
  142. "effective_lead_time_days": 10,
  143. "reorder_point_g": 242.666532290416,
  144. "days_remaining": 60,
  145. "days_until_reorder_point": 31,
  146. "stock_break_alert": False,
  147. "reorder_alert": False,
  148. },
  149. "F": {
  150. "remaining_g": 1000,
  151. "daily_rate_g": None,
  152. "effective_lead_time_days": 7,
  153. "reorder_point_g": 0,
  154. "days_remaining": None,
  155. "days_until_reorder_point": None,
  156. "stock_break_alert": False,
  157. "reorder_alert": False,
  158. },
  159. "G": {
  160. "remaining_g": 800,
  161. "daily_rate_g": None,
  162. "effective_lead_time_days": 9,
  163. "reorder_point_g": 0,
  164. "days_remaining": None,
  165. "days_until_reorder_point": None,
  166. "stock_break_alert": False,
  167. "reorder_alert": False,
  168. },
  169. "H": {
  170. "remaining_g": 150,
  171. "daily_rate_g": 13.864882095643093,
  172. "effective_lead_time_days": 6,
  173. "reorder_point_g": 144.46457585381498,
  174. "days_remaining": 10,
  175. "days_until_reorder_point": 0,
  176. "stock_break_alert": False,
  177. "reorder_alert": True,
  178. },
  179. "I": {
  180. "remaining_g": 70,
  181. "daily_rate_g": 13.864882095643093,
  182. "effective_lead_time_days": 6,
  183. "reorder_point_g": 144.46457585381498,
  184. "days_remaining": 5,
  185. "days_until_reorder_point": -6,
  186. "stock_break_alert": True,
  187. "reorder_alert": False,
  188. },
  189. "J": {
  190. "remaining_g": 1500,
  191. "daily_rate_g": 1.25,
  192. "effective_lead_time_days": 10,
  193. "reorder_point_g": 31.304439534819455,
  194. "days_remaining": 1200,
  195. "days_until_reorder_point": 1174,
  196. "stock_break_alert": False,
  197. "reorder_alert": False,
  198. },
  199. }
  200. @pytest.mark.parametrize("name", sorted(SCENARIOS))
  201. def test_backend_matches_the_panels_numbers(name):
  202. spools, history, settings, global_lead = SCENARIOS[name]
  203. got = forecast_sku(spools, history, settings or SkuSettings(), global_lead, NOW)
  204. expected = EXPECTED[name]
  205. for field, want in expected.items():
  206. have = getattr(got, field)
  207. if isinstance(want, float):
  208. assert have == pytest.approx(want, rel=1e-9), field
  209. else:
  210. assert have == want, field
  211. def test_the_scenarios_cover_both_alerts_and_neither():
  212. """The pin means little if no scenario ever alerts."""
  213. flags = {(e["reorder_alert"], e["stock_break_alert"]) for e in EXPECTED.values()}
  214. assert flags == {(False, False), (True, False), (False, True)}