Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/routes_script_views.py +1 -1
- platform/aios_grid_fields.json +12 -5
- platform/core/odoo.py +26 -4
- platform/modules/product_data.py +154 -33
- platform/modules/products.py +0 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "c2d4c3a",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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 |
-
# β
|
| 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.
|
| 473 |
},
|
| 474 |
{
|
| 475 |
"key": "dos",
|
| 476 |
"label": "Days of supply",
|
| 477 |
"type": "int",
|
| 478 |
"source": "odoo",
|
| 479 |
-
"description": "Days of supply at the
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
},
|
| 497 |
{
|
| 498 |
"key": "stock_bucket",
|
| 499 |
"label": "Stock status",
|
| 500 |
"type": "select",
|
| 501 |
"source": "odoo",
|
| 502 |
-
"description": "Dead / excess / healthy bucket from the
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
else:
|
| 484 |
-
qty_ltm, dos, bucket = _rescope_inventory(
|
|
|
|
| 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 |
-
# β
|
| 516 |
-
#
|
| 517 |
-
#
|
| 518 |
-
#
|
| 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 |
-
#
|
| 524 |
-
#
|
| 525 |
-
#
|
| 526 |
-
#
|
| 527 |
-
#
|
|
|
|
|
|
|
|
|
|
| 528 |
#
|
| 529 |
-
#
|
| 530 |
-
#
|
| 531 |
-
#
|
| 532 |
-
#
|
| 533 |
-
#
|
| 534 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
lead = s.get("lead_days")
|
| 536 |
-
# β
|
| 537 |
-
# and only one
|
| 538 |
-
incoming = meta.get("incoming")
|
| 539 |
row["incoming"] = incoming
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|