fsanyoto commited on
Commit
0429bb4
Β·
verified Β·
1 Parent(s): 374573a

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "9a6a561",
3
  "releases": [
4
  {
5
  "version": "v29",
 
1
  {
2
+ "current": "c2d4c3a",
3
  "releases": [
4
  {
5
  "version": "v29",
VERSION CHANGED
@@ -1 +1 @@
1
- 9a6a561
 
1
+ c2d4c3a
api/routes_script_views.py CHANGED
@@ -212,7 +212,7 @@ def create_script_view(body: dict = Body(default=None),
212
  if not database:
213
  raise err(400, "no_database", "a script view is bound to one database")
214
  _database_ok(session, database)
215
- # ⭐ is CREATE's alone - see . The picker mints a view the
216
  # moment a person chooses the mode, so the empty source is the normal first state, not a slip.
217
  source = _clean_source(body.get("source"), allow_empty=True)
218
  refusal = sandbox.check_source(source)
 
212
  if not database:
213
  raise err(400, "no_database", "a script view is bound to one database")
214
  _database_ok(session, database)
215
+ # ⭐ `allow_empty` is CREATE's alone - see `_clean_source`. The picker mints a view the
216
  # moment a person chooses the mode, so the empty source is the normal first state, not a slip.
217
  source = _clean_source(body.get("source"), allow_empty=True)
218
  refusal = sandbox.check_source(source)
platform/aios_grid_fields.json CHANGED
@@ -469,14 +469,14 @@
469
  "label": "Inbound units",
470
  "type": "int",
471
  "source": "odoo",
472
- "description": "Units already on order and not yet received, from Odoo's own incoming quantity on the product. In the product's STOCK unit of measure, the same unit as On hand, so the two can be added. Both cover gap columns count this as stock."
473
  },
474
  {
475
  "key": "dos",
476
  "label": "Days of supply",
477
  "type": "int",
478
  "source": "odoo",
479
- "description": "Days of supply at the LTM rate, from ON-HAND stock only; null means it never sells through. It deliberately IGNORES inbound units, because dead stock and overstock are questions about what is on the shelf. The cover gap columns are the ones that count inbound. Consolidated; absent for a BU-scoped caller."
480
  },
481
  {
482
  "key": "cover_gap_d",
@@ -484,7 +484,7 @@
484
  "type": "int",
485
  "source": "odoo",
486
  "default": false,
487
- "description": "Days of cover minus supplier lead time. Negative means it runs out before a reorder lands. Counts INBOUND units as stock (owner, 2026-08-19), so this reads longer than Days of supply on any SKU with an open purchase order."
488
  },
489
  {
490
  "key": "cover_gap_units",
@@ -492,14 +492,21 @@
492
  "type": "int",
493
  "source": "odoo",
494
  "default": false,
495
- "description": "The recommended reorder quantity: units of demand over the lead time that on-hand plus inbound does not cover. POSITIVE means buy this many; negative is surplus units; blank means we cannot say, because the SKU has no lead time on file or never sells through. Rounded AWAY from zero, so a real shortfall never rounds down to nothing."
 
 
 
 
 
 
 
496
  },
497
  {
498
  "key": "stock_bucket",
499
  "label": "Stock status",
500
  "type": "select",
501
  "source": "odoo",
502
- "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
503
  },
504
  {
505
  "key": "discontinued",
 
469
  "label": "Inbound units",
470
  "type": "int",
471
  "source": "odoo",
472
+ "description": "Units already on order and not yet received, from Odoo's own incoming quantity on the product. In the product's STOCK unit of measure, the same unit as On hand, so the two can be added. Days of supply and both cover gap columns all count this as stock (owner, 2026-08-19)."
473
  },
474
  {
475
  "key": "dos",
476
  "label": "Days of supply",
477
  "type": "int",
478
  "source": "odoo",
479
+ "description": "Days of supply at the trailing twelve month rate, counting ON HAND PLUS INBOUND units as stock; null means it never sells through. Same stock figure as the cover gap columns, so the two cannot disagree about how much you have. They still divide by different rates: this one is the trailing average, the cover gap uses the forward 8 month forecast, so a seasonal SKU reads differently in each. Consolidated; absent for a BU-scoped caller."
480
  },
481
  {
482
  "key": "cover_gap_d",
 
484
  "type": "int",
485
  "source": "odoo",
486
  "default": false,
487
+ "description": "Days of cover minus supplier lead time. Negative means it runs out before a reorder lands. It counts INBOUND units as stock, exactly as Days of supply now does. The one thing it does differently: the burn rate is the FORWARD 8 month forecast rather than the trailing twelve month average, so a seasonal SKU reads differently here."
488
  },
489
  {
490
  "key": "cover_gap_units",
 
492
  "type": "int",
493
  "source": "odoo",
494
  "default": false,
495
+ "description": "The recommended reorder quantity: units of forecast demand over the lead time that on-hand plus inbound does not cover. POSITIVE means buy this many; negative is surplus units; blank means we cannot say, because the SKU has no lead time on file or no forecast demand. Rounded AWAY from zero, so a real shortfall never rounds down to nothing."
496
+ },
497
+ {
498
+ "key": "demand_fwd",
499
+ "label": "Forecast units (8 mo)",
500
+ "type": "int",
501
+ "source": "odoo",
502
+ "description": "Units this SKU is expected to sell over the next 8 months, read as the units it actually sold in the same 8 calendar months one year ago. Seasonal on purpose: a flat annual average spreads Valentine's, Mother's Day and Christmas evenly across the year and understates the months buyers actually order for. A SKU too new to appear in that window falls back to its trailing twelve month rate scaled to 8 months. This is the burn rate both cover gap columns divide by, shown so the reorder quantity can be checked."
503
  },
504
  {
505
  "key": "stock_bucket",
506
  "label": "Stock status",
507
  "type": "select",
508
  "source": "odoo",
509
+ "description": "Dead / excess / healthy bucket, from the same days of supply figure beside it, so it counts inbound units too. One exception on purpose: 'Out of stock' still keys on the REAL shelf, because that is a present tense fact somebody can walk into the warehouse and check. A row can honestly read 'Out of stock' with a days of supply beside it when the replenishment is on the water; Inbound units is why. Consolidated; absent for a BU-scoped caller."
510
  },
511
  {
512
  "key": "discontinued",
platform/core/odoo.py CHANGED
@@ -264,12 +264,34 @@ def excluded_partner_ids():
264
  return res
265
 
266
 
267
- def sale_line_domain(date_from=None, date_to=None, team_id=None, extra=None, partner_ids=None):
 
268
  """Build a sale.order.line domain in the RI+FFS confirmed-order scope, excluding
269
  the configured house accounts, with optional date window / single team / extra clauses.
270
  partner_ids (a collection, possibly empty) restricts to those customers β€” the carrier for
271
- the Customer module's Agent filter; None = no partner restriction (an empty set matches none)."""
272
- tids = active_team_ids()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  dom = [('state', 'in', ['sale', 'done']), ('product_id', '!=', False)]
274
  if tids:
275
  dom.insert(1, ('order_id.team_id', 'in', tids))
@@ -280,7 +302,7 @@ def sale_line_domain(date_from=None, date_to=None, team_id=None, extra=None, par
280
  dom.append(('order_id.date_order', '>=', f'{date_from} 00:00:00'))
281
  if date_to:
282
  dom.append(('order_id.date_order', '<=', f'{date_to} 23:59:59'))
283
- ex = excluded_partner_ids()
284
  if ex:
285
  dom.append(('order_partner_id', 'not in', list(ex)))
286
  if partner_ids is not None:
 
264
  return res
265
 
266
 
267
+ def sale_line_domain(date_from=None, date_to=None, team_id=None, extra=None, partner_ids=None,
268
+ all_channels=False):
269
  """Build a sale.order.line domain in the RI+FFS confirmed-order scope, excluding
270
  the configured house accounts, with optional date window / single team / extra clauses.
271
  partner_ids (a collection, possibly empty) restricts to those customers β€” the carrier for
272
+ the Customer module's Agent filter; None = no partner restriction (an empty set matches none).
273
+
274
+ ⭐⭐ `all_channels=True` DROPS THE WHOLESALE FENCE, and exists for exactly one class of
275
+ question: how much stock to BUY. Owner, 2026-08-19: *"for the Cover gap we need to include
276
+ Amazon SKUs as well ... Amazon units sold through Giftware deals client"*. Those units leave
277
+ the same warehouse, so a buy quantity computed without them under-orders every SKU Amazon
278
+ moves. The default fence is right for revenue, margin and customer analysis and is unchanged.
279
+ ⚠ This is not a new policy: the standing scope rule already reads *"inventory/SKU/dead-stock
280
+ analysis uses ALL channels"*. It is the first caller that needed a way to say so in code.
281
+
282
+ β›”β›” IT TAKES **TWO** FENCES DOWN, AND MISSING THE SECOND MAKES THIS FLAG A SILENT NO-OP.
283
+ MEASURED 2026-08-19: dropping only the excluded-partner clause changed the answer by ZERO
284
+ units, because GIFTWARE DEALS is not gated by the partner list at all in practice β€” its orders
285
+ sit on **`crm.team` 7 ('Giftware Deals')** while `active_team_ids()` is `[5 Fisch, 6 Royal]`,
286
+ so the TEAM clause was already excluding them and the partner clause never bit. In the
287
+ forecast window that is **3,794 lines on team 7 against 0 on the active teams**. So this drops
288
+ the team restriction as well.
289
+ ⚠ AN EXPLICIT `team_id` STILL WINS. A BU-scoped caller asked about ONE unit and must keep
290
+ getting that unit; only a consolidated read widens.
291
+ β›” OPT-IN, never the default: flipping the default would silently widen revenue everywhere."""
292
+ # β›” `all_channels` suppresses the ACTIVE-TEAM fence, which is the one that actually excludes
293
+ # the Amazon channel (crm.team 7). An explicit `team_id` below still overrides either way.
294
+ tids = () if all_channels else active_team_ids()
295
  dom = [('state', 'in', ['sale', 'done']), ('product_id', '!=', False)]
296
  if tids:
297
  dom.insert(1, ('order_id.team_id', 'in', tids))
 
302
  dom.append(('order_id.date_order', '>=', f'{date_from} 00:00:00'))
303
  if date_to:
304
  dom.append(('order_id.date_order', '<=', f'{date_to} 23:59:59'))
305
+ ex = () if all_channels else excluded_partner_ids()
306
  if ex:
307
  dom.append(('order_partner_id', 'not in', list(ex)))
308
  if partner_ids is not None:
platform/modules/product_data.py CHANGED
@@ -311,7 +311,47 @@ def _bu_ltm_share(t, team_id):
311
  _DEFAULT_SHARE = 0.0
312
 
313
 
314
- def _rescope_inventory(e, share):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  """`(qty_ltm, dos, bucket)` recomputed for ONE business unit's sales rate.
316
 
317
  The formulas are `inventory.sku_inventory`'s, applied to a BU-shaped numerator β€” NOT a second
@@ -328,17 +368,11 @@ def _rescope_inventory(e, share):
328
  if on_hand is None:
329
  return None, None, None # no inventory row for this SKU: blank, never zero
330
  qty = float(e.get("qty_ltm") or 0.0) * float(share or 0.0)
331
- daily = qty / 365.0
332
- if daily > 0:
333
- dos_raw = on_hand / daily
334
- else:
335
- dos_raw = float('inf') if on_hand > 0 else 0.0
336
- try:
337
- import modules.inventory as inventory
338
- bucket = inventory._bucket(dos_raw, on_hand, qty)
339
- except Exception:
340
- bucket = None
341
- return qty, (None if dos_raw == float('inf') else round(float(dos_raw), 0)), bucket
342
 
343
 
344
  def _catalogue_by_code():
@@ -405,6 +439,14 @@ def pool(team_id=None, t=None):
405
  tiers, _tier_report = products.tier_prices_by_code(_prods)
406
  packs, _pack_report = products.packagings_by_code(_prods)
407
  cat = _catalogue_by_code()
 
 
 
 
 
 
 
 
408
  # The LEFT side of the join, indexed by the same code key. β›” A code here that the catalogue
409
  # does not carry belongs to a product no ACTIVE record claims β€” archived, and R12 keeps those
410
  # out. `validate()` asserts that of Odoo rather than assuming it, and reports the revenue
@@ -478,10 +520,18 @@ def pool(team_id=None, t=None):
478
  "first_cost": s.get("first_cost"),
479
  })
480
  e = inv.get(code) or {}
 
 
 
481
  if consolidated:
482
- qty_ltm, dos, bucket = e.get("qty_ltm"), e.get("dos"), e.get("bucket")
 
 
 
 
483
  else:
484
- qty_ltm, dos, bucket = _rescope_inventory(e, bu_share.get(code, _DEFAULT_SHARE))
 
485
  row.update({
486
  # UNSCOPED on purpose (owner 2026-08-11): one warehouse, no per-brand shelf.
487
  "on_hand": e.get("on_hand"),
@@ -512,32 +562,43 @@ def pool(team_id=None, t=None):
512
  # this repo has the scar already (wave 17: archiving `ar` "would have DELETED the
513
  # proof"). Two comparisons per SKU is what that costs.
514
  #
515
- # ⭐ READS THE SCOPED `dos`, NOT `e['dos']`. On a Fisch pull the cover gap must answer
516
- # "does the shelf outlast a reorder AT FISCH'S RATE" β€” reading the consolidated figure
517
- # here would print a buy signal computed from both units' velocity beside a days-of-supply
518
- # computed from one, and the two columns would disagree on the same row.
519
  # ⭐⭐ OWNER 2026-08-19 β€” INBOUND COUNTS AS STOCK FOR THE COVER GAP. Verbatim: *"Cover gap
520
  # day also should INCLUDE the incoming SKUs so it assumes those as stock even though its
521
  # inbound"*, and *"we need the 'Cover gap (units)' basically tell us how much to buy"*.
522
  #
523
- # β›” `dos` AND `stock_bucket` DELIBERATELY DO NOT MOVE. They answer "what is on the shelf",
524
- # which is the question dead-stock, excess and overstock are built on; folding inbound into
525
- # them would change the Inventory module's meaning from a buy-list request. So a row can
526
- # honestly read `dos 5, lead 30, cover gap +12` when 900 units are on the water, and the
527
- # two columns' own descriptions say which is which.
 
 
 
528
  #
529
- # ⚠ THE VELOCITY BASIS IS UNCHANGED AND IS **TRAILING** TWELVE MONTHS, not a forward
530
- # projection. `daily` here is the same `qty_ltm / 365` that produced `dos` upstream, so the
531
- # new columns cannot disagree with the old one about how fast the SKU moves. The owner
532
- # described the existing column as "based on the next 8 months Unit sales"; it is not, and
533
- # that is REPORTED rather than quietly repaired β€” changing the basis in the same edit that
534
- # adds inbound would make it impossible to tell which change moved which number.
 
 
 
 
 
 
535
  lead = s.get("lead_days")
536
- # ⚠ FROM THE CATALOGUE ROW (`meta`), NOT THE INVENTORY ROW (`e`). Both live on this SKU
537
- # and only one of them was read from Odoo's product record.
538
- incoming = meta.get("incoming")
539
  row["incoming"] = incoming
540
- daily = (qty_ltm / 365.0) if isinstance(qty_ltm, (int, float)) and qty_ltm > 0 else None
 
 
 
541
  on_shelf = row["on_hand"]
542
  effective = ((on_shelf or 0.0) + (incoming or 0.0)
543
  if isinstance(on_shelf, (int, float)) else None)
@@ -956,6 +1017,66 @@ def validate(team_id=None, t=None):
956
  "po_lines": po_report, "uom_note": "purchase uom vs stock uom; the shipped "
957
  "column is in the STOCK uom so it can be added to On hand."},
958
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
959
  checks.extend(validate_measures(t=t, team_id=team_id,
960
  pool_codes={r["code"] for r in rows}))
961
  checks.extend(validate_price_and_unit_cells(rows))
 
311
  _DEFAULT_SHARE = 0.0
312
 
313
 
314
+ def _dos_with_inbound(on_hand, incoming, qty_ltm):
315
+ """`(dos, bucket)` where DAYS OF SUPPLY COUNTS UNITS ALREADY ON ORDER as stock.
316
+
317
+ ⭐⭐ OWNER, 2026-08-19: *"Days of supply field should INCLUDE inbound quantities."* This
318
+ REVERSES the split shipped hours earlier the same day, in which `dos` stayed on-hand-only while
319
+ only the cover gap counted inbound. That split was defensible and the owner has ruled against
320
+ it: one number, one meaning, and the two columns can no longer disagree about how much stock
321
+ this SKU has.
322
+
323
+ β›” ONE DEFINITION, USED BY BOTH THE CONSOLIDATED AND THE BU-SCOPED PATH. They used to compute
324
+ days-of-supply in two places β€” `inventory.sku_inventory` for everybody and `_rescope_inventory`
325
+ for a scoped caller β€” and a change like this one is exactly how those two drift into answering
326
+ the same question differently ([[one-question-two-normalizers]]). Both roads now end here.
327
+
328
+ ⚠ `_bucket` STILL RECEIVES THE REAL SHELF, NOT THE EFFECTIVE FIGURE, and that is deliberate.
329
+ Its only use of the quantity is an `on_hand <= 0` test that yields **'Out of stock'** β€” a
330
+ present-tense fact somebody can walk into the warehouse and check. A SKU with nothing on the
331
+ shelf and 500 units on the water IS out of stock today; the `dos` beside it says how long the
332
+ cover lasts once they land, and `Inbound units` shows why the two differ.
333
+
334
+ ⚠ `on_hand is None` means no inventory row for this SKU: blank, never zero. Inbound alone
335
+ cannot manufacture a days-of-supply for a SKU the warehouse has never heard of.
336
+ """
337
+ if on_hand is None:
338
+ return None, None
339
+ effective = float(on_hand) + float(incoming or 0.0)
340
+ qty = float(qty_ltm or 0.0)
341
+ daily = qty / 365.0
342
+ if daily > 0:
343
+ dos_raw = effective / daily
344
+ else:
345
+ dos_raw = float('inf') if effective > 0 else 0.0
346
+ try:
347
+ import modules.inventory as inventory
348
+ bucket = inventory._bucket(dos_raw, float(on_hand), qty)
349
+ except Exception: # noqa: BLE001
350
+ bucket = None
351
+ return (None if dos_raw == float('inf') else round(float(dos_raw), 0)), bucket
352
+
353
+
354
+ def _rescope_inventory(e, share, incoming=None):
355
  """`(qty_ltm, dos, bucket)` recomputed for ONE business unit's sales rate.
356
 
357
  The formulas are `inventory.sku_inventory`'s, applied to a BU-shaped numerator β€” NOT a second
 
368
  if on_hand is None:
369
  return None, None, None # no inventory row for this SKU: blank, never zero
370
  qty = float(e.get("qty_ltm") or 0.0) * float(share or 0.0)
371
+ # ⭐ THE FORMULA MOVED TO `_dos_with_inbound` (owner 2026-08-19) so the scoped and consolidated
372
+ # paths cannot answer days-of-supply differently. Only the NUMERATOR is BU-shaped: `qty` is
373
+ # this unit's share of LTM units, while the shelf and the inbound are one warehouse's.
374
+ dos, bucket = _dos_with_inbound(on_hand, incoming, qty)
375
+ return qty, dos, bucket
 
 
 
 
 
 
376
 
377
 
378
  def _catalogue_by_code():
 
439
  tiers, _tier_report = products.tier_prices_by_code(_prods)
440
  packs, _pack_report = products.packagings_by_code(_prods)
441
  cat = _catalogue_by_code()
442
+ # ⭐ THE BUY LIST'S DEMAND HORIZON (owner ruling 2026-08-19). BU-shaped through `team_id`, the
443
+ # same way the revenue columns are: a Fisch reader's reorder quantity must answer what FISCH
444
+ # will sell. Degrades to `{}` on a read failure, matching every other column source here β€” a
445
+ # blank cover gap is honest, a product grid that will not render is not.
446
+ try:
447
+ fwd_demand, _fwd_report = products.forward_demand_by_code(t=t, team_id=team_id)
448
+ except Exception: # noqa: BLE001
449
+ fwd_demand, _fwd_report = {}, {"error": "forward demand unavailable"}
450
  # The LEFT side of the join, indexed by the same code key. β›” A code here that the catalogue
451
  # does not carry belongs to a product no ACTIVE record claims β€” archived, and R12 keeps those
452
  # out. `validate()` asserts that of Odoo rather than assuming it, and reports the revenue
 
520
  "first_cost": s.get("first_cost"),
521
  })
522
  e = inv.get(code) or {}
523
+ # ⭐⭐ OWNER 2026-08-19: *"Days of supply field should INCLUDE inbound quantities."* Read
524
+ # here rather than in the cover-gap block below, because `dos` now needs it too.
525
+ incoming = meta.get("incoming")
526
  if consolidated:
527
+ # β›” RECOMPUTED, NOT `e['dos']`. `inventory.sku_inventory` returns a shelf-only
528
+ # days-of-supply and knows nothing about purchase orders; taking its figure here would
529
+ # ship the pre-ruling number under the new column description.
530
+ qty_ltm = e.get("qty_ltm")
531
+ dos, bucket = _dos_with_inbound(e.get("on_hand"), incoming, qty_ltm)
532
  else:
533
+ qty_ltm, dos, bucket = _rescope_inventory(
534
+ e, bu_share.get(code, _DEFAULT_SHARE), incoming)
535
  row.update({
536
  # UNSCOPED on purpose (owner 2026-08-11): one warehouse, no per-brand shelf.
537
  "on_hand": e.get("on_hand"),
 
562
  # this repo has the scar already (wave 17: archiving `ar` "would have DELETED the
563
  # proof"). Two comparisons per SKU is what that costs.
564
  #
565
+ # ⭐ BU-SHAPED THROUGH THE DEMAND READ, not through `dos`. This block no longer divides by
566
+ # days-of-supply at all β€” `forward_demand_by_code` took `team_id`, so on a Fisch pull the
567
+ # cover gap already answers "does the stock outlast a reorder AT FISCH'S RATE". The rule
568
+ # the old comment protected still holds: never mix one unit's velocity with another's.
569
  # ⭐⭐ OWNER 2026-08-19 β€” INBOUND COUNTS AS STOCK FOR THE COVER GAP. Verbatim: *"Cover gap
570
  # day also should INCLUDE the incoming SKUs so it assumes those as stock even though its
571
  # inbound"*, and *"we need the 'Cover gap (units)' basically tell us how much to buy"*.
572
  #
573
+ # ⭐⭐ `dos` NOW COUNTS INBOUND TOO (owner, 2026-08-19), so the shelf figure behind this
574
+ # gap and the one behind Days of supply are THE SAME NUMBER. The earlier split, where only
575
+ # the cover gap counted inbound, is reversed. ⚠ What still differs between the two columns
576
+ # is the RATE, not the stock: `dos` divides by the trailing LTM rate, the cover gap by the
577
+ # forward 8-month forecast, so they still disagree on a seasonal SKU. Both say so in their
578
+ # own descriptions.
579
+ # ⚠ The INVENTORY module is untouched: `sku_inventory` is consumed only by this grid, and
580
+ # the overstock/dead-stock dashboards read `_enrich`'s own row-level `dos`, a separate path.
581
  #
582
+ # ⭐⭐ THE VELOCITY BASIS IS FORWARD 8 MONTHS (owner ruling, 2026-08-19). The owner
583
+ # described the cover gap as *"based on the next 8 months Unit sales"*; it was a TRAILING
584
+ # twelve-month rate, and asked to choose, they ruled forward. `products.forward_demand_by_
585
+ # code` is a SEASONAL read (the same 8 calendar months a year ago), which matters for a
586
+ # floral wholesaler whose year is Valentine's, Mother's Day and Christmas: a flat LTM rate
587
+ # spreads those peaks evenly and understates exactly the months a buyer is ordering for.
588
+ #
589
+ # β›” `dos` STILL USES THE LTM RATE, and the difference is deliberate rather than an
590
+ # oversight. `dos` and `stock_bucket` feed dead-stock and overstock, which ask "how long
591
+ # will what is on the shelf last at the rate it has been moving" β€” a backward-looking
592
+ # question. The cover gap asks "will it last until the reorder lands", which is forward.
593
+ # Both columns say which basis they use in their own descriptions.
594
  lead = s.get("lead_days")
595
+ # ⚠ `incoming` came from the CATALOGUE row (`meta`) above, not the inventory row (`e`):
596
+ # both live on this SKU and only one was read from Odoo's product record.
 
597
  row["incoming"] = incoming
598
+ fwd = fwd_demand.get(code)
599
+ row["demand_fwd"] = round(fwd) if isinstance(fwd, (int, float)) and fwd > 0 else None
600
+ daily = (fwd / float(products.FORWARD_DAYS)) if isinstance(fwd, (int, float)) \
601
+ and fwd > 0 else None
602
  on_shelf = row["on_hand"]
603
  effective = ((on_shelf or 0.0) + (incoming or 0.0)
604
  if isinstance(on_shelf, (int, float)) else None)
 
1017
  "po_lines": po_report, "uom_note": "purchase uom vs stock uom; the shipped "
1018
  "column is in the STOCK uom so it can be added to On hand."},
1019
  })
1020
+
1021
+ # ⭐⭐ OWNER 2026-08-19 β€” `dos` COUNTS INBOUND, and this is the leg that proves it rather
1022
+ # than trusting the expression. Two halves, because either alone is passable by accident:
1023
+ # (a) EVERY row's dos re-derives from (on_hand + incoming) / (qty_ltm / 365), and
1024
+ # (b) at least one row's dos is STRICTLY GREATER than the shelf-only figure would be.
1025
+ # Without (b) the check stays green if `incoming` silently becomes 0 everywhere β€” the
1026
+ # column would read exactly as it did before the ruling, and nothing would say so.
1027
+ moved, wrong = 0, []
1028
+ for r in rows:
1029
+ oh, inc, q = r.get("on_hand"), r.get("incoming") or 0.0, r.get("qty_ltm")
1030
+ if oh is None or not isinstance(q, (int, float)) or q <= 0:
1031
+ continue
1032
+ want = round((float(oh) + float(inc)) / (float(q) / 365.0), 0)
1033
+ if r.get("dos") != want:
1034
+ wrong.append(r["code"])
1035
+ if inc > 0 and want > round(float(oh) / (float(q) / 365.0), 0):
1036
+ moved += 1
1037
+ checks.append({
1038
+ "check": "Days of supply counts INBOUND units: every row re-derives from "
1039
+ "(on_hand + incoming) / daily, and inbound demonstrably moves it",
1040
+ "ours": moved, "theirs": len([r for r in rows if (r.get("incoming") or 0) > 0]),
1041
+ "ok": not wrong and moved > 0,
1042
+ "detail": {"rows_not_re_deriving": wrong[:10], "n_wrong": len(wrong),
1043
+ "rows_where_inbound_raised_dos": moved,
1044
+ "note": "a 0 in 'rows_where_inbound_raised_dos' means the column reads as "
1045
+ "it did BEFORE the ruling, which is the silent-regression case."},
1046
+ })
1047
+
1048
+ # ⭐ RULE 8 β€” the FORWARD demand basis against a DIRECT Odoo read_group over the same
1049
+ # reference window. β›” The oracle re-reads Odoo itself rather than re-calling
1050
+ # `forward_demand_by_code`, which would reconcile the number with itself β€” the self-sealing
1051
+ # shape this function already carries a scar for.
1052
+ fwd_map, fwd_report = products.forward_demand_by_code(t=t, team_id=team_id)
1053
+ _rf, _rt = fwd_report["reference_window"]
1054
+ # ⚠ `include_excluded_partners=True` HERE TOO, or the oracle would measure a narrower
1055
+ # universe than the column and go red on the Amazon units the owner asked to include.
1056
+ direct = O.read_group('sale.order.line',
1057
+ O.sale_line_domain(_rf, _rt, team_id, extra=products._NO_SVC,
1058
+ all_channels=True),
1059
+ ['product_uom_qty:sum'], [], lazy=False)
1060
+ theirs_u = float((direct[0] or {}).get('product_uom_qty') or 0.0) if direct else 0.0
1061
+ # ⚠ A BRACKET, NOT AN EQUALITY, and the reason is in the number itself: the shipped total
1062
+ # is the seasonal rows PLUS the LTM-fallback rows scaled onto the horizon, and the fallback
1063
+ # rows are by definition SKUs that window never saw. Asserting equality would go red
1064
+ # forever on a difference the design creates on purpose.
1065
+ shipped = sum(float(r["demand_fwd"]) for r in rows
1066
+ if isinstance(r.get("demand_fwd"), (int, float)))
1067
+ checks.append({
1068
+ "check": f"Forward {products.FORWARD_MONTHS}-month demand basis: the shipped forecast "
1069
+ f"ties to Odoo units over its own reference window {_rf} to {_rt}",
1070
+ "ours": round(shipped, 0), "theirs": round(theirs_u, 0),
1071
+ # The shipped total is seasonal rows PLUS scaled fallback rows, so it cannot equal the
1072
+ # window total exactly; what must hold is that it is a real, bounded fraction of it and
1073
+ # that the window itself returned units at all.
1074
+ "ok": theirs_u > 0 and 0.5 * theirs_u <= shipped <= 1.5 * theirs_u,
1075
+ "detail": {**fwd_report,
1076
+ "skus_with_a_forecast": len([r for r in rows if r.get("demand_fwd")]),
1077
+ "note": "shipped = seasonal rows + LTM-fallback rows scaled to the "
1078
+ "horizon, so it brackets rather than equals the window total."},
1079
+ })
1080
  checks.extend(validate_measures(t=t, team_id=team_id,
1081
  pool_codes={r["code"] for r in rows}))
1082
  checks.extend(validate_price_and_unit_cells(rows))
platform/modules/products.py CHANGED
The diff for this file is too large to render. See raw diff