diff --git "a/platform/modules/product_data.py" "b/platform/modules/product_data.py" --- "a/platform/modules/product_data.py" +++ "b/platform/modules/product_data.py" @@ -1,1466 +1,1466 @@ -"""modules/product_data.py — the PRODUCT table's pool (wave 15 item 9/10, contract C-TOPIC). - -The second object on the table-page factory: same grid, same engine, same permission wall — the -only difference is the field schema and the identity. `modules/customer_data.pool()` is the -template and this deliberately mirrors its signature and its row shape. - -⛔ THREE DECISIONS THIS FILE HAD TO MAKE, EACH RECORDED BECAUSE A LATER READER WILL WONDER. - -1. **THE IDENTITY IS A SKU CODE, WHICH IS A STRING, AND THE GRID WANTS AN INTEGER `pid`.** - Cohort membership, `allowed_pids`, the measure channel and `rows_from_pool` all key on an - integer. So each row carries BOTH: `pid` (a stable CRC32 of the code, so the same SKU gets the - same id on every pull and across processes — never an enumeration index, which would reshuffle - whenever the catalogue changes) and `code`, the real business key, as a visible column. - `_assert_no_pid_collision` fails the BUILD rather than the read: two SKUs sharing a pid would - silently merge in every downstream set operation, and a loud build failure is the only version - of that anyone would notice. - -2. **THE SCOPE RULE — inventory columns are CONSOLIDATED and are therefore OMITTED for a - BU-scoped caller.** `products.directory(t, team_id)` is brand-shaped; `inventory.sku_inventory` - is explicitly NOT (its own docstring: "on-hand stock is one physical warehouse, not - brand-tagged"). Joining them for a Fisch-only user would put BU-shaped revenue beside - company-wide stock IN THE SAME ROW — the mixed-scope value defect wave 15's amendment 3 exists - for, arriving through a different door. The honest options were "omit" or "label the columns - company-wide"; omit is the fail-closed one, and a column that is absent asks a question, - whereas a column that is silently company-wide answers one wrongly. - -3. **WHAT IS NOT HERE, NAMED RATHER THAN QUIETLY MISSING.** R4 lists ~20 fields. Vendor and - COUNTRY are not in Odoo at all — they live in the inventory WORKBOOK - ([[odoo-vendor-country-origin]]) and need a loader this module deliberately does not invent. - Margin % comes from `modules/pricing.table`, which is a heavier build (channel-rate cost - allocation) and is left for the wave that needs it. `validate()` reconciles what SHIPS; it - does not pretend to cover columns that are absent. - -4. **THE POOL IS CATALOGUE-FIRST, WITH REVENUE LEFT-JOINED** (wave 29, owner item 22 / R12, - 2026-08-11). It was `for r in products.directory(...)` — and `directory()`'s row set IS the - union of two revenue read-groups, so **a SKU that never sold could not exist** and the grid - showed **2,717** of **5,875** active products. Three things that will be re-derived otherwise: - - · ⛔ THE CAUSE IS A JOIN, NOT A LIMIT. No row cap exists on this path. Removing the date - window from `directory()` would ALSO be wrong twice: it breaks four SKU-health metrics - that legitimately want a sales window, and it lands at 3,327 (all-time-sold), because - ~2,550 active SKUs have never sold in wholesale scope at all. - · ⭐ REVENUE ON A NEVER-SOLD ROW IS **BLANK, NOT $0** (R12). A row that appears in the - revenue universe carries measured numbers INCLUDING a real 0.0 — it sold last year and - not this one, and that zero is a measurement. A row that appears in NO revenue read - carries `None`, which the wire keeps (`aios_grid._round` passes None through) and the - client renders as an empty cell. Blank is an admission; zero is a measurement. - · ⚠ A BU-SCOPED CALLER NOW SEES THE WHOLE CATALOGUE, with ITS OWN revenue and blanks where - that BU never sold. That is not decision 2's mixed-scope defect: the catalogue is the ROW - UNIVERSE, not a company-wide VALUE sitting beside a BU-shaped one. `product.product` - carries no team, so a catalogue cannot be BU-shaped at all — which is exactly why the two - sources are JOINED rather than merged. -""" -import datetime as _dt -import json -import math as _math -import zlib -from pathlib import Path - -import core.odoo as O -import core.periods as P -import core.shared_overlay as shared_overlay -import core.table_store as table_store -import modules.products as products - -#: POOL-ROW KEYS that exist ONLY on a consolidated pull. See decision 2. -#: `cover_gap_d` / `buy_now` (wave 17) join it because both are computed FROM `dos`, and a buy -#: signal built on a stock number the caller cannot see would be a recommendation nobody could -#: check ([[no-unverifiable-aggregates]]). -#: -#: ⚠ ROW KEYS, NOT FIELDS, and the distinction started mattering on 2026-08-03. Two readers: -#: `verify_perm_scope` asserts the shape of a POOL ROW against this, and `routes_products` -#: narrows the FIELD list with it. Since owner item 5 `buy_now` is a row key with no Field — -#: computed as `validate()`'s oracle, projected onto no wire — so it belongs here for the first -#: reader and is inert for the second. Removing it would stop the scope gate proving that a -#: BU-scoped pull withholds it. -#: -#: ⭐⭐ OWNER RULING 2026-08-11 — THIS SET IS NOW EMPTY, AND THAT IS THE POINT. Verbatim: -#: *"Just scope any inventory with Sales from Fisch, leave the rest."* Withholding the whole -#: inventory block from a BU-scoped reader meant a Fisch salesperson could not see whether -#: anything was IN STOCK, which is the first question they ask. The ruling splits the block by -#: what a business unit can actually shape: -#: -#: * `on_hand` / `unit_cost` / `inv_value` — ONE physical warehouse, no Fisch shelf and no -#: Royal shelf. Served UNSCOPED to everybody, identical in both pulls. ("leave the rest") -#: * `qty_ltm` / `dos` / `stock_bucket` / `cover_gap_d` / `buy_now` — all SALES-derived, so a -#: BU-scoped pull recomputes them from THAT unit's LTM units. ("scope any inventory with -#: Sales from Fisch") -#: -#: This deliberately relaxes decision 2's "never a company-wide value beside a BU-shaped one": -#: stock is not a value a BU can own, and a column that is absent for a Fisch reader asks a -#: question they cannot answer anywhere else in the product. `verify_perm_scope` no longer -#: asserts the columns are WITHHELD — it asserts the split above, which is a stronger claim and -#: cannot pass on an empty scoped payload (the shape the old rule and an outage share). -CONSOLIDATED_ONLY = () - -#: ⛔ POOL-ROW KEYS THAT DELIBERATELY HAVE NO FIELD — they must never reach a browser. -#: -#: `rows_from_pool` projects strictly through the field contract, so "no Field" already means -#: "no cell on the wire". This tuple makes that a CHECKED fact rather than a consequence nobody -#: is watching: `verify_perm_scope` asserts every member is absent from BOTH product contracts, -#: and that every OTHER `CONSOLIDATED_ONLY` key is present in the consolidated one. -#: -#: Written because the two tuples silently diverged the moment owner item 5 retired `buy_now`'s -#: Field while keeping its computation, and the gate — which was asserting a FIELD property from -#: a ROW-key list — went red with no way to tell a deliberate divergence from a dropped column. -UNSHIPPED_ROW_KEYS = ("buy_now",) - -#: Wave 17 (owner item 13, ruling R3) — the SUPPLIER MASTER, from the curated mastersheet map -#: `procurement_suppliers.json` (3,963 SKUs; supplier on 3,624, lead time on 3,563). NOT Odoo: -#: the owner confirmed this data came from the Inventory System Mastersheet, which is why -#: `modules/product_data`'s header said Vendor/Country were "NOT HERE, named rather than -#: quietly missing" and needed "a loader this module deliberately does not invent". This is -#: that loader. -#: -#: ⛔ THESE ARE CONTRACT COLUMNS, NOT USER-CREATED FIELDS, AND THE REASON IS A PERMISSION FACT. -#: A user-created field and its values live in the PER-USER strata (`table_store.workspace` -#: reads `store.get(key)[username]`); only VIEWS are shared. So a shared "Buy list" view that -#: filtered on a user-created column would, for every OTHER account, name a column that does not -#: exist — and an unknown column is an INACTIVE condition in the tri-state engine, which IGNORES -#: it and therefore WIDENS. The buy list would silently show the whole catalogue to everyone but -#: its author. Contract columns are identical for every reader, so the view means one thing. -#: -#: ⭐⭐ 2026-08-12 (W30-T36) — THE OWNER'S ASK IS NOW DELIVERED, AND THE PARAGRAPH ABOVE IS WHY IT -#: TOOK THREE WAVES. Owner: *"turn this Excel sheet into a User created Field that we can edit."* -#: It was parked because a per-user column silently WIDENS a shared view — not because editing was -#: hard. Wave 29's `core/shared_overlay.py` (whose header quotes this very comment) is the stratum -#: that removes the objection: **one value per (row, column) for the whole tenant**, so the column -#: still means ONE thing to every reader and a shared view still filters honestly. -#: ⇒ The four columns below are now `source: "overlay"` + `shared: true` in the canonical -#: contract, their values live in `__shared`, and the master map is what SEEDS an -#: unedited cell rather than what freezes it. See `SHARED_KEYS` and `_ProductTableStore`. -_SUPPLIER_MAP_PATH = Path(__file__).resolve().parent.parent / "procurement_suppliers.json" -_SUPPLIER_CACHE = {} - - -def supplier_master(): - """`{code: {supplier, lead_days, origin_country, first_cost}}`, read once per process. - - Degrades to `{}` when the file is unreadable, matching `_inventory_by_code`: a product table - that will not render because a master map is missing is a worse failure than one with blank - supplier columns. - """ - if _SUPPLIER_CACHE: - return _SUPPLIER_CACHE - try: - raw = json.loads(_SUPPLIER_MAP_PATH.read_text(encoding="utf-8")) - except Exception: - return {} - for code, meta in (raw or {}).items(): - if not isinstance(meta, dict): - continue - lead = meta.get("lead") - _SUPPLIER_CACHE[str(code)] = { - "supplier": (meta.get("vendor") or "") or None, - "lead_days": int(lead) if isinstance(lead, (int, float)) else None, - "origin_country": (meta.get("country") or "") or None, - "first_cost": meta.get("first_cost"), - } - return _SUPPLIER_CACHE - -#: Wave 16 C-TOPIC — the PRODUCT table's OWN workspace bucket. ⛔ Never the customer one: -#: product pids are CRC32 hashes of SKU codes and customer pids are Odoo partner ids, so in a -#: SHARED overlay bucket a hash collision would silently write a product note onto somebody's -#: customer (or the reverse). Separate store keys make that structurally impossible, which is -#: the whole reason the table-page factory exists ("a new table object gets its own -#: table_store.make('_table_workspace')"). -TABLE_KEY = 'product_table_workspace' - -_GRID_FIELDS_PATH = Path(__file__).resolve().parent.parent / 'aios_grid_fields.json' -_SHARED_KEYS = None - - -def SHARED_KEYS(): - """The product columns whose values are TENANT-WIDE — derived from the canonical contract's - own `shared: true`, never typed out a second time. - - ⛔ IT DELIBERATELY DOES NOT SWALLOW A READ FAILURE. Degrading to `()` would send a shared - write into the per-user stratum with nothing going wrong anywhere — the widening defect - reappearing silently, which is the one outcome this whole mechanism exists to prevent. If the - canonical contract is unreadable the product grid cannot render at all (`pd_fields` parses the - same file with no guard), so a raise here costs nothing that was still working. - """ - global _SHARED_KEYS - if _SHARED_KEYS is None: - doc = json.loads(_GRID_FIELDS_PATH.read_text(encoding='utf-8')) - _SHARED_KEYS = tuple(f['key'] for f in (doc.get('product_data') or {}).get('fields') or [] - if f.get('shared')) - return _SHARED_KEYS - - -class _ProductTableStore(table_store.TableStore): - """The product workspace, with the SHARED columns routed to the tenant-wide stratum. - - ⭐⭐ THIS SUBCLASS IS THE WHOLE OF W30-T36's WRITE PATH, AND THE REASON IT LIVES HERE RATHER - THAN AT A ROUTE IS THAT **THE BROWSER NEVER CALLS `PATCH /products/{pid}`** — measured, zero - call sites in `aios-web/web/src`. A cell edit travels `POST /grid/events` → `grid_events. - handle_one` → `_tops(ctx).patch_overlay(...)`, and `_tops` returns `ctx.table`, which - `routes_grid._ctx` sets to `TABLE_OPS` for the product scope. So this object IS the seam both - doors pass through; intercepting at either route would have left the other one writing a - per-user value that only its author could see. - - ⚠ `st=self.st`, NEVER the module default. The shared stratum must resolve to the SAME store - handle as the per-user one it sits beside — `_tops`' own comment explains that a split, where - one side is tenant-scoped and the other is not, is worse than a stated residency error because - a user's value would vanish the moment they saved it. Reading `self.st` means both strata move - together the day that singleton gains a tenant handle. - """ - - def patch_overlay(self, username, pid, updates): - clean = dict(updates or {}) - if not clean: - return - keys = set(SHARED_KEYS()) - shared = {k: v for k, v in clean.items() if k in keys} - personal = {k: v for k, v in clean.items() if k not in keys} - if shared: - shared_overlay.put_cells(TABLE_KEY, pid, shared, st=self.st) - if personal: - super().patch_overlay(username, pid, personal) - - -TABLE_OPS = _ProductTableStore(TABLE_KEY) - - -def shared_cells(pids, st=None): - """`{"": {key: value}}` for the SHARED columns of the rows named by `pids`. - - ⚠ `pids` is required and positional all the way down — `shared_overlay.cells` refuses to serve - "everything" by design, and the caller here always holds an already-scoped pool. - """ - return shared_overlay.cells(TABLE_KEY, pids, st=st if st is not None else TABLE_OPS.st) - - -def sku_pid(code): - """A stable integer id for a SKU code. CRC32, masked to 31 bits so it is always positive and - always JSON-safe. Stable across processes and pulls, which an enumeration index is not.""" - return zlib.crc32(str(code).encode("utf-8")) & 0x7FFFFFFF - - -def _assert_no_pid_collision(rows): - """Two SKUs sharing a pid would MERGE in every set operation downstream — cohort membership, - allowed_pids, the measure channel — and nothing would report it. Fail the build instead.""" - seen = {} - for r in rows: - prior = seen.get(r["pid"]) - if prior is not None and prior != r["code"]: - raise ValueError( - f"product_data: pid collision — {prior!r} and {r['code']!r} both hash to " - f"{r['pid']}. Downstream set operations would merge them silently; widen the id " - f"before shipping this catalogue.") - seen[r["pid"]] = r["code"] - - -def _inventory_by_code(t): - """`{code: {...}}` from the inventory module, or `{}` if it cannot be read. - - Degrades to empty rather than raising, matching `customer_data._pool_build`'s treatment of - its own slow families: a product table that will not render because inventory is momentarily - unreachable is a worse failure than one with blank stock columns. - """ - try: - import modules.inventory as inventory - return inventory.sku_inventory(t=t) or {} - except Exception: - return {} - - -def _bu_ltm_share(t, team_id): - """`{code: 0.0..1.0}` — this unit's SHARE of the SKU's last-twelve-months units. - - ⭐ OWNER 2026-08-11: *"scope any inventory with Sales from Fisch"*. The velocity half of the - inventory block has to be re-shaped by a BU fact, and this is that fact. - - ⛔ A SHARE, NOT THE UNIT COUNT ITSELF — AND THE REASON IS A MEASURED IMPOSSIBILITY. My first - version returned `products._sku_rev(...)['qty']` and used it directly as the scoped `qty_ltm`, - leaving the consolidated column on `inventory.sku_inventory`'s own figure. Two readers of one - question ([[one-question-two-normalizers]]): they disagree, and on 5 SKUs of 5,871 the live - check found **Fisch's LTM units EXCEEDING the company's** — a subset larger than its superset, - which no reader could explain and no reconciliation could survive. - - Both halves of the ratio come from the SAME read here, so the share is in [0, 1] by - construction and the scoped figure can never exceed the consolidated one. It also leaves the - consolidated column exactly as it was — the Inventory page and the Product grid still agree - about company-wide units, which is what "leave the rest" asked for. - - `all_qty == 0` implies `bu_qty == 0` (same reader), so a share of 0 is the honest answer for - "this unit never sold it": `_bucket` turns that into 'No recent sales', not zero cover. - - Degrades to `{}` on any failure, matching `_inventory_by_code` — and a missing code then takes - the `_DEFAULT_SHARE` below rather than a silent 0. - """ - try: - f, to = P.ltm(t) - allq = {code: (r.get('qty') or 0.0) - for code, r in (products._sku_rev(f, to, None) or {}).items()} - buq = {code: (r.get('qty') or 0.0) - for code, r in (products._sku_rev(f, to, team_id) or {}).items()} - except Exception: - return {} - out = {} - for code, total in allq.items(): - out[code] = min(1.0, max(0.0, (buq.get(code, 0.0) / total))) if total > 0 else 0.0 - return out - - -#: What a SKU absent from the LTM sales read is worth to a business unit. ZERO — it did not sell -#: in anybody's book over the window, so no unit can claim its velocity. Named rather than -#: inlined so the choice is visible: the alternative (1.0, "assume it is all ours") would print -#: company-wide cover on a BU grid, which is the mixed-scope defect this whole rule avoids. -_DEFAULT_SHARE = 0.0 - - -def _dos_with_inbound(on_hand, incoming, qty_ltm): - """`(dos, bucket)` where DAYS OF SUPPLY COUNTS UNITS ALREADY ON ORDER as stock. - - ⭐⭐ OWNER, 2026-08-19: *"Days of supply field should INCLUDE inbound quantities."* This - REVERSES the split shipped hours earlier the same day, in which `dos` stayed on-hand-only while - only the cover gap counted inbound. That split was defensible and the owner has ruled against - it: one number, one meaning, and the two columns can no longer disagree about how much stock - this SKU has. - - ⛔ ONE DEFINITION, USED BY BOTH THE CONSOLIDATED AND THE BU-SCOPED PATH. They used to compute - days-of-supply in two places — `inventory.sku_inventory` for everybody and `_rescope_inventory` - for a scoped caller — and a change like this one is exactly how those two drift into answering - the same question differently ([[one-question-two-normalizers]]). Both roads now end here. - - ⚠ `_bucket` STILL RECEIVES THE REAL SHELF, NOT THE EFFECTIVE FIGURE, and that is deliberate. - Its only use of the quantity is an `on_hand <= 0` test that yields **'Out of stock'** — a - present-tense fact somebody can walk into the warehouse and check. A SKU with nothing on the - shelf and 500 units on the water IS out of stock today; the `dos` beside it says how long the - cover lasts once they land, and `Inbound units` shows why the two differ. - - ⚠ `on_hand is None` means no inventory row for this SKU: blank, never zero. Inbound alone - cannot manufacture a days-of-supply for a SKU the warehouse has never heard of. - """ - if on_hand is None: - return None, None - effective = float(on_hand) + float(incoming or 0.0) - qty = float(qty_ltm or 0.0) - daily = qty / 365.0 - if daily > 0: - dos_raw = effective / daily - else: - dos_raw = float('inf') if effective > 0 else 0.0 - try: - import modules.inventory as inventory - bucket = inventory._bucket(dos_raw, float(on_hand), qty) - except Exception: # noqa: BLE001 - bucket = None - return (None if dos_raw == float('inf') else round(float(dos_raw), 0)), bucket - - -def _rescope_inventory(e, share, incoming=None): - """`(qty_ltm, dos, bucket)` recomputed for ONE business unit's sales rate. - - The formulas are `inventory.sku_inventory`'s, applied to a BU-shaped numerator — NOT a second - idea of what days-of-supply means. `_bucket` is imported from there for the same reason: two - copies of a threshold table is how the Product grid and the Inventory page start disagreeing - about which SKUs are dead. - - ⚠ `on_hand` is whatever the warehouse holds, unscoped — so a Fisch reader's `dos` answers - "how long does ALL our stock last at Fisch's rate", which is the question a Fisch salesperson - actually has. It is deliberately NOT a pro-rated share of the shelf: there is no such shelf, - and inventing one would put a number on screen that no Odoo query could reproduce. - """ - on_hand = e.get("on_hand") - if on_hand is None: - return None, None, None # no inventory row for this SKU: blank, never zero - qty = float(e.get("qty_ltm") or 0.0) * float(share or 0.0) - # ⭐ THE FORMULA MOVED TO `_dos_with_inbound` (owner 2026-08-19) so the scoped and consolidated - # paths cannot answer days-of-supply differently. Only the NUMERATOR is BU-shaped: `qty` is - # this unit's share of LTM units, while the shelf and the inbound are one warehouse's. - dos, bucket = _dos_with_inbound(on_hand, incoming, qty) - return qty, dos, bucket - - -def _catalogue_by_code(): - """`{code: {'product', 'category'}}` — the CATALOGUE universe this pool is built from. - - ⛔ UNLIKE `_inventory_by_code`, THIS ONE RAISES, and the asymmetry is the whole point. - Inventory degrades to `{}` because a product table with blank stock columns beats one that - will not render. The CATALOGUE is not a column — it is the ROW SET. A catalogue read that - failed quietly would drop the grid straight back to the sold-only 2,717 with every gate - green and nothing on screen saying so, which is the defect this seam exists to end - ([[gate-can-report-green-on-nothing]]). `routes_products._pool_for` already turns the raise - into a 503 that names the cause, so the loud failure has somewhere honest to land. - - It exists as a `pd`-level function rather than an inline `products.catalogue()` call for the - same reason `_inventory_by_code` does: it is the seam `verify_perm_scope`'s section H stubs - to build a pool without Odoo. - """ - return products.catalogue() - - -def _pricelist_by_code(): - """`({code: {price_*: price}}, report)` from `products.pricelist_by_code`, or `({}, …)`. - - A SEAM for the same two reasons `_inventory_by_code` is one: it degrades rather than raises - (these are columns, not the row set), and `verify_perm_scope`'s section H stubs it to build a - pool without Odoo. ⛔ Unstubbed there, section H would reach live Odoo through the back door - and the whole file would stop being runnable offline. - """ - try: - return products.pricelist_by_code() - except Exception as e: - return {}, {"error": f"{type(e).__name__}: {str(e)[:200]}"} - - -def pool(team_id=None, t=None): - """One row per ACTIVE product — the PRODUCT analogue of `customer_data.pool`. - - CATALOGUE-FIRST, REVENUE LEFT-JOINED (R12 — see decision 4 in the module header). The row set - is `products.catalogue()`; `products.directory()` supplies the revenue columns for the SKUs - that sold in its window and contributes NO rows of its own. - - `team_id` shapes the revenue columns exactly as it does for customers (`products.directory` - passes it into `_sku_rev`), which is why `core.perm_scope.derive_pool_scope` must keep - driving it rather than a post-filter deciding the BU. It does NOT shape the row set: a - catalogue has no team. - """ - t = t or P.today() - consolidated = team_id is None - # ⭐ OWNER 2026-08-11: read inventory on EVERY pull, not just a consolidated one. The stock - # itself is company-wide; only the sales-derived half is re-scoped, by `_rescope_inventory`. - inv = _inventory_by_code(t) - bu_share = {} if consolidated else _bu_ltm_share(t, team_id) - sup = supplier_master() - prices, _price_report = _pricelist_by_code() - # ⭐ W37-T14 / T15. ⚠ MEASURED COST, stated because it lands on a scope's FIRST build: - # tier prices 15.1 s + packagings 8.1 s on top of the ~44 s consolidated build. Only the - # first build for a scope blocks (`routes_products._pool_for`'s stale-while-refresh), and - # both degrade to `{}` on a read failure rather than taking the grid down — the same - # asymmetry `pricelist_by_code` documents: a column is not the ROW SET. - try: - _prods = products.active_products() # ONE read, shared by both (see its docstring) - except Exception: # noqa: BLE001 - _prods = None - tiers, _tier_report = products.tier_prices_by_code(_prods) - packs, _pack_report = products.packagings_by_code(_prods) - cat = _catalogue_by_code() - # ⭐ THE BUY LIST'S DEMAND HORIZON (owner ruling 2026-08-19). BU-shaped through `team_id`, the - # same way the revenue columns are: a Fisch reader's reorder quantity must answer what FISCH - # will sell. Degrades to `{}` on a read failure, matching every other column source here — a - # blank cover gap is honest, a product grid that will not render is not. - try: - fwd_demand, _fwd_report = products.forward_demand_by_code(t=t, team_id=team_id) - except Exception: # noqa: BLE001 - fwd_demand, _fwd_report = {}, {"error": "forward demand unavailable"} - # The LEFT side of the join, indexed by the same code key. ⛔ A code here that the catalogue - # does not carry belongs to a product no ACTIVE record claims — archived, and R12 keeps those - # out. `validate()` asserts that of Odoo rather than assuming it, and reports the revenue - # that therefore sits outside the grid (MEASURED 2026-08-11: 3 codes, $0.00 YTD / $294.50 LY). - rev = {r["code"]: r for r in products.directory(t=t, team_id=team_id)} - - rows = [] - for code, meta in cat.items(): - r = rev.get(code) - row = { - "pid": sku_pid(code), - "code": code, - # ⭐ ONE source for the name and the category, the CATALOGUE — not the sale line's - # m2o. For a re-SKUed code the line's name can be the ARCHIVED record's; the active - # record's `display_name` is the current truth, and it is the same string for every - # SKU that is not re-SKUed. `directory()` derives the category identically. - "product": meta.get("product") or code, - "category": meta.get("category") or "(uncategorized)", - # ⭐⭐ OWNER 2026-08-19 — the Odoo Discontinued tag, surfaced so the buy list can drop - # them. ⛔ ALWAYS "Yes" OR "No", NEVER BLANK: an unknown value is an INACTIVE condition - # in the tri-state filter engine, so a view filtering on a blank column IGNORES the - # leaf and WIDENS to the whole catalogue. That is the exact failure `_seed_wave17`'s - # buy-list comment was written about, and a blank here would reintroduce it. - "discontinued": meta.get("discontinued") or "No", - # ⭐⭐ W33-T43 (R2 / amendment A2) — ODOO'S OWN PRODUCT ID, beside the hashed `pid`. - # R2 retires `ut_odoo_products` onto this key and keeps every data column it had; this - # is that column. ⛔ NOT DERIVABLE DOWNSTREAM: `pid` is `crc32(default_code)` here, - # unlike a customer row whose `pid` IS the partner id, so `aios_grid.py` cannot recover - # it and it has to be carried from `products.catalogue()`. - # ⚠ `None`, never 0, when the catalogue somehow has no id — 0 is a real Odoo id. - "product_id": meta.get("id"), - # ⭐ R12 — BLANK, NEVER $0, on a SKU no revenue read ever saw. A row that IS in `rev` - # keeps its measured numbers including a real 0.0 (it sold last year, not this one). - "rev_ytd": r.get("rev_ytd", 0.0) if r else None, - "rev_ly": r.get("rev_ly", 0.0) if r else None, - "yoy_pct": r.get("yoy_pct") if r else None, - "qty_ytd": r.get("qty_ytd", 0.0) if r else None, - "orders_ytd": r.get("orders_ytd", 0) if r else None, - } - # ⭐ WAVE 30 W30-T34 — the PRICELIST stratum, one column per declared list. Catalogue - # data like the supplier block, so it rides every pull, scoped or not: a price book is - # not a thing a business unit owns a slice of, and a Fisch reader who cannot see the - # Fisch price is exactly who this column is for. - # - # ⛔ `None`, NEVER 0 AND NEVER A FALLBACK, for the 1,883 SKUs no list prices. That is - # W29-T52's own negative control: the single `3_global` rule computes over `list_price`, - # which is 1.00 on 5,817 of 5,871 products, so the fallback is not a cheaper answer — - # it is a wrong one wearing a currency sign. - p = prices.get(code) or {} - for _col, _name in products.PRICELIST_COLUMNS: - row[_col] = p.get(_col) - # ⭐⭐ W37-T14 / T15 — THE HONEST SETS, beside the three declared columns rather than - # instead of them. The columns above answer "what does Fisch charge"; these answer "how - # many prices/units does this SKU actually have", which the columns structurally cannot: - # they are named after THIS tenant's lists, so a price on any other list is invisible. - # ⚠ A `json` cell is a STRING on the wire (the type's own contract), so these are dumped - # here rather than handed over as lists — a bare list would round-trip through the overlay - # as something `grid_events` refuses to re-parse. - # ⚠ BLANK, NOT "[]" — an empty cell reads as "sold in one unit / not priced on any list", - # and an empty JSON array on screen reads as a bug. - _tiers = tiers.get(code) - row["tier_prices"] = json.dumps(_tiers, ensure_ascii=False) if _tiers else None - _units = packs.get(code) - row["units"] = json.dumps(_units, ensure_ascii=False) if _units else None - # Wave 17 R3 — the supplier master, on every pull (it is catalogue data, not stock). - s = sup.get(code) or {} - row.update({ - "supplier": s.get("supplier") or "(none)", - "lead_days": s.get("lead_days"), - "origin_country": s.get("origin_country") or "(none)", - "first_cost": s.get("first_cost"), - }) - e = inv.get(code) or {} - # ⭐⭐ OWNER 2026-08-19: *"Days of supply field should INCLUDE inbound quantities."* Read - # here rather than in the cover-gap block below, because `dos` now needs it too. - incoming = meta.get("incoming") - if consolidated: - # ⛔ RECOMPUTED, NOT `e['dos']`. `inventory.sku_inventory` returns a shelf-only - # days-of-supply and knows nothing about purchase orders; taking its figure here would - # ship the pre-ruling number under the new column description. - qty_ltm = e.get("qty_ltm") - dos, bucket = _dos_with_inbound(e.get("on_hand"), incoming, qty_ltm) - else: - qty_ltm, dos, bucket = _rescope_inventory( - e, bu_share.get(code, _DEFAULT_SHARE), incoming) - row.update({ - # UNSCOPED on purpose (owner 2026-08-11): one warehouse, no per-brand shelf. - "on_hand": e.get("on_hand"), - "unit_cost": e.get("unit_cost"), - "inv_value": e.get("inv_value"), - # SCOPED: these three are functions of how fast THIS unit sells the SKU. - "qty_ltm": qty_ltm, - "dos": dos, - "stock_bucket": bucket, - }) - # THE BUY TRIGGER (R3), ported from `modules/procurement`'s lead-time-cover rule: - # buy when the shelf runs out before a reorder could land. - # - # ⚠ BLANK, NOT "OK", WHEN EITHER INPUT IS MISSING. 339 SKUs have no supplier and 400 - # no lead time; `dos` is null for anything that never sells through. "We don't know" - # and "you're fine" are different sentences, and only one of them is safe to print - # beside a purchasing decision. - # - # ⛔ `buy_now` SHIPS NOWHERE ANY MORE, and is computed anyway. OWNER 2026-08-03: it - # is not a preset field — it is a formula over the two columns beside it, and the - # platform has a formula field type for exactly that. Its Field is gone from - # `aios_grid_fields.json`, and `rows_from_pool` projects rows STRICTLY through that - # contract, so no Field means no cell on the wire. Nothing renders this key. - # - # It stays computed because deleting it would delete the PROOF. `validate()` below - # reconciles the formula's predicate against this one, row for row, which is the only - # thing that makes "the same exact figures" a claim rather than an assertion — and - # this repo has the scar already (wave 17: archiving `ar` "would have DELETED the - # proof"). Two comparisons per SKU is what that costs. - # - # ⭐ BU-SHAPED THROUGH THE DEMAND READ, not through `dos`. This block no longer divides by - # days-of-supply at all — `forward_demand_by_code` took `team_id`, so on a Fisch pull the - # cover gap already answers "does the stock outlast a reorder AT FISCH'S RATE". The rule - # the old comment protected still holds: never mix one unit's velocity with another's. - # ⭐⭐ OWNER 2026-08-19 — INBOUND COUNTS AS STOCK FOR THE COVER GAP. Verbatim: *"Cover gap - # day also should INCLUDE the incoming SKUs so it assumes those as stock even though its - # inbound"*, and *"we need the 'Cover gap (units)' basically tell us how much to buy"*. - # - # ⭐⭐ `dos` NOW COUNTS INBOUND TOO (owner, 2026-08-19), so the shelf figure behind this - # gap and the one behind Days of supply are THE SAME NUMBER. The earlier split, where only - # the cover gap counted inbound, is reversed. ⚠ What still differs between the two columns - # is the RATE, not the stock: `dos` divides by the trailing LTM rate, the cover gap by the - # forward 8-month forecast, so they still disagree on a seasonal SKU. Both say so in their - # own descriptions. - # ⚠ The INVENTORY module is untouched: `sku_inventory` is consumed only by this grid, and - # the overstock/dead-stock dashboards read `_enrich`'s own row-level `dos`, a separate path. - # - # ⭐⭐ THE VELOCITY BASIS IS FORWARD 8 MONTHS (owner ruling, 2026-08-19). The owner - # described the cover gap as *"based on the next 8 months Unit sales"*; it was a TRAILING - # twelve-month rate, and asked to choose, they ruled forward. `products.forward_demand_by_ - # code` is a SEASONAL read (the same 8 calendar months a year ago), which matters for a - # floral wholesaler whose year is Valentine's, Mother's Day and Christmas: a flat LTM rate - # spreads those peaks evenly and understates exactly the months a buyer is ordering for. - # - # ⛔ `dos` STILL USES THE LTM RATE, and the difference is deliberate rather than an - # oversight. `dos` and `stock_bucket` feed dead-stock and overstock, which ask "how long - # will what is on the shelf last at the rate it has been moving" — a backward-looking - # question. The cover gap asks "will it last until the reorder lands", which is forward. - # Both columns say which basis they use in their own descriptions. - lead = s.get("lead_days") - # ⚠ `incoming` came from the CATALOGUE row (`meta`) above, not the inventory row (`e`): - # both live on this SKU and only one was read from Odoo's product record. - row["incoming"] = incoming - fwd = fwd_demand.get(code) - row["demand_fwd"] = round(fwd) if isinstance(fwd, (int, float)) and fwd > 0 else None - daily = (fwd / float(products.FORWARD_DAYS)) if isinstance(fwd, (int, float)) \ - and fwd > 0 else None - on_shelf = row["on_hand"] - effective = ((on_shelf or 0.0) + (incoming or 0.0) - if isinstance(on_shelf, (int, float)) else None) - if daily and isinstance(effective, (int, float)) and isinstance(lead, (int, float)) \ - and lead > 0: - # Units of demand the lead time will consume, less everything we already hold or have - # bought. POSITIVE is a shortfall to buy; negative is surplus. Signed on purpose: a - # floor at zero would make the buy-list filter depend on the floor rather than on the - # measurement, and would flatten "covered by 2 units" into "covered by 400". - short = daily * lead - effective - # ⛔ AWAY FROM ZERO, NEVER `round()`. `cover_gap_d` is `int(round(...))` and the seed's - # own comment records why that makes it unsafe to SELECT on: a real gap of -0.4 days - # rounds to 0 and the SKU drops off a list it belongs on. A shortfall of 0.4 units is - # still a shortfall, so it must land on 1, not 0. `validate()` holds this on the - # boundary rather than trusting the expression. - row["cover_gap_units"] = int(_math.ceil(short) if short > 0 else _math.floor(short)) - row["cover_gap_d"] = int(round(effective / daily - lead)) - # ⭐ THE SIGNAL IS NOW A FUNCTION OF THE SAME NUMBER THE COLUMN SHOWS, so the words and - # the quantity on one row can no longer disagree — which they would have the moment - # the gap started counting inbound and the signal did not. - row["buy_now"] = "Buy now" if row["cover_gap_units"] > 0 else "OK" - else: - row["cover_gap_units"] = None - row["cover_gap_d"] = None - row["buy_now"] = None - rows.append(row) - - # `or 0.0` reads a BLANK as zero FOR SORTING ONLY — never for the cell. A never-sold SKU - # settles among the zero-revenue ones at the bottom, which is where it belongs; the stored - # value stays None so nothing downstream can mistake "we never saw a sale" for "we measured - # nothing sold". - rows.sort(key=lambda r: -(r["rev_ytd"] or 0.0)) - _assert_no_pid_collision(rows) - return rows - - -#: ⭐ THE BUY SIGNAL, AS THE FORMULA FIELD EVALUATES IT (owner, 2026-08-03). -#: -#: The owner's ruling was that "Buy signal" is not a preset field — it is a formula over two -#: columns the product table already carries, and the platform has a formula field type for it. -#: The formula, verbatim, is what `_seed_wave17.BUY_SIGNAL_FORMULA` creates and what the JSON -#: contract's `_product_removed_buy_now` note records: -#: -#: IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "") -#: -#: This function is a PORT of how `customer-grid/formulaEngine.ts` evaluates that tree, not a -#: restatement of the business rule — that is the whole point, because the two could drift and -#: `validate()` is where the drift must show. Three engine behaviours it reproduces exactly: -#: -#: · a `ref` to a missing/non-numeric cell is None (`case "ref"` returns null for anything -#: that is not a finite number or a string); -#: · `cmp` returns BLANK unless BOTH sides read as numbers — it never coerces a blank to 0, -#: which is the difference between this and a filter engine's `toNum(null) === 0`; -#: · `IF` with a non-boolean condition returns BLANK ("no truthiness"), so a blank comparison -#: propagates out as a blank cell rather than taking the false branch. -#: ⭐⭐ REPOINTED 2026-08-19 ONTO `cover_gap_units`, AND THE REASON IS THE OWNER'S OWN INSTRUCTION. -#: The old formula was `IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "")`. Once -#: the cover gap counts inbound stock, `{dos} < {lead_days}` is no longer the same question: it -#: would keep saying "Buy now" for a SKU whose replenishment is already on the water, which is the -#: shape of confusion the owner opened this work with. `cover_gap_units` already carries the whole -#: rule — lead time present and positive, velocity known, inbound counted, rounded away from zero -#: — so one reference replaces three and the words cannot disagree with the number beside them. -#: -#: ⚠ ONE ENGINE BEHAVIOUR CARRIES THE BLANK CASE, and it is why no `isNotEmpty` guard is needed: -#: `cmp` returns BLANK unless BOTH sides read as numbers, and `IF` with a non-boolean condition -#: returns BLANK. So a SKU with no lead time or no sell-through has `cover_gap_units = null`, the -#: comparison is blank, and the cell is blank — never the false branch, and never a coerced 0. -BUY_SIGNAL_FORMULA = 'IF({cover_gap_units} > 0, "Buy now", "OK")' - - -def _buy_signal_formula(row): - """Evaluate `BUY_SIGNAL_FORMULA` over one pool row -> 'Buy now' | 'OK' | '' (blank).""" - def num(v): - # The engine's `ref` + `asNumber`: booleans are not values here, and a non-finite - # number is null. `isinstance(True, int)` is True in Python, so bool is excluded first. - if isinstance(v, bool) or not isinstance(v, (int, float)): - return None - return v if v == v and v not in (float('inf'), float('-inf')) else None - - gap = num(row.get("cover_gap_units")) - if gap is None: # `{cover_gap_units} > 0` is blank -> IF(blank, …) is blank - return "" - return "Buy now" if gap > 0 else "OK" - - -def validate(team_id=None, t=None): - """Reconcile what SHIPS to an independent aggregate — the platform's own rule that a number - which does not tie to Odoo does not ship. - - ⛔⛔ THE POPULATION LEG IS NEW, AND IT IS HERE BECAUSE THIS FUNCTION USED TO BE SELF-SEALING - (wave 29, contract C8). It reconciled Σ per-SKU YTD revenue against `products._sku_rev` — the - same function `pool()` built its rows from. BOTH SIDES CAME FROM THE JOIN, so the oracle could - not see a missing row, and a grid holding 2,717 of 5,875 active products passed for a wave. - No gate anywhere asserted a pool row COUNT against an independent Odoo `search_count` either. - That is the leg below, and it is the control that would have caught it - ([[no-unverifiable-aggregates]]). - - ⚠ The revenue leg was NOT deleted with it — it answers a different question ("did the join - lose money?") and is still the right shape for that one. What changed is that it now has to - account for revenue belonging to codes NO ACTIVE PRODUCT CARRIES, because R12 keeps archived - products out of the grid; the total is decomposed rather than compared loosely, so a growing - "outside the grid" figure shows up as a number rather than as slack in a tolerance. - - Still deliberately NOT covered, and said rather than implied: the inventory columns are a - different module's reconciliation, and they are absent on a scoped pull anyway. - """ - t = t or P.today() - rows = pool(team_id=team_id, t=t) - - # ⭐⭐ THE POPULATION, AGAINST AN ORACLE THAT CANNOT SEE OUR JOIN. A bare Odoo count of active - # products, asked fresh — not a `len()` over anything this module or `products.catalogue()` - # built. NC: drop one row from `pool()` and this goes red; that is the whole point of it. - n_active = products.catalogue_count() - checks = [{ - "check": "the pool holds one row per ACTIVE product (R12) — row count == an INDEPENDENT " - "Odoo search_count('product.product', active=True)", - "ours": len(rows), "theirs": n_active, "ok": len(rows) == n_active, - }] - - yf, yt = P.ytd(t) - sku_rev = products._sku_rev(yf, yt, team_id) or {} - in_pool = {r["code"] for r in rows} - outside = {c: v for c, v in sku_rev.items() if c not in in_pool} - ours = round(sum(r["rev_ytd"] or 0.0 for r in rows), 2) - theirs = round(sum(v.get("rev", 0.0) for v in sku_rev.values()), 2) - dropped = round(sum(v.get("rev", 0.0) for v in outside.values()), 2) - checks.append({ - "check": "Σ per-SKU YTD revenue in the grid + Σ YTD revenue of codes no ACTIVE product " - "carries == modules.products._sku_rev over the same window", - "ours": round(ours + dropped, 2), "theirs": theirs, - "ok": abs((ours + dropped) - theirs) < 0.01, - "detail": {"in_the_grid": ours, "outside_the_grid": dropped, - "codes_outside": len(outside)}, - }) - # ⛔ AND THE EXCLUSION IS ASSERTED, NOT ASSUMED. "Everything I dropped was archived" is - # trivially true when checked against the dict that did the dropping — that is the - # self-sealing shape all over again. So it is asked of ODOO. A code here that a live ACTIVE - # product carries means `products.catalogue()` missed a row that has revenue, which is the - # 2,717 defect returning in a smaller costume. - # ⚠ `pid:N` keys are not `default_code`s and are skipped: an UNCODED active product is in the - # catalogue under its own `pid:N` key and therefore cannot be in `outside` at all. - coded_outside = sorted(c for c in outside if not str(c).startswith("pid:")) - still_active = (O.get_odoo().search_count( - 'product.product', [('active', '=', True), ('default_code', 'in', coded_outside)]) - if coded_outside else 0) - checks.append({ - "check": "every SKU with revenue but NO grid row is genuinely ARCHIVED (R12: archived " - "stay out) — asked of Odoo, never of the catalogue that did the dropping", - "ours": still_active, "theirs": 0, "ok": still_active == 0, - "detail": {"codes": coded_outside[:10], "n_codes": len(outside)}, - }) - # ── ⭐⭐ WAVE 30 W30-T34 — THE PRICELIST COLUMNS, AGAINST ORACLES THAT CANNOT SEE OUR JOIN ── - # - # Three legs, because the column can fail in three different ways and only one of them is a - # count. `products.pricelist_by_code` builds a `{code: {col: price}}` map by expanding each - # rule over the products it names, IN PYTHON — that expansion is the fragile part, so every - # leg below re-asks ODOO instead of re-reading the map. - price_report = _pricelist_by_code()[1] - _pl_rows = O.search_read('product.pricelist', [], ['id', 'name']) - _pl_id = {} - for _p in _pl_rows: - _pl_id.setdefault(str(_p.get('name') or '').strip(), _p['id']) - _today = P.today().isoformat() - - _targets_memo = {} - - def _rule_targets(list_name): - """(variant_ids, template_ids) a pricelist prices today — read FRESH from Odoo. - - Memoised for the LIFE OF THIS CALL only: legs 1 and 2 both need all three lists, and - without this `validate()` makes six identical round trips instead of three (measured: - ~48s of the run). Deliberately NOT an `lru_cache` — an oracle that survives the process - is an oracle reading yesterday's Odoo. - """ - if list_name in _targets_memo: - return _targets_memo[list_name] - var, tmpl = set(), set() - plid = _pl_id.get(list_name) - if plid is None: - return var, tmpl - for r in O.search_read( - 'product.pricelist.item', - [('pricelist_id', '=', plid), ('compute_price', '=', 'fixed'), - ('applied_on', 'in', ['0_product_variant', '1_product'])], - ['product_id', 'product_tmpl_id', 'applied_on', 'fixed_price', - 'date_start', 'date_end']): - ds, de = str(r.get('date_start') or '')[:10], str(r.get('date_end') or '')[:10] - if (ds and ds > _today) or (de and de < _today): - continue - fp = r.get('fixed_price') - if not isinstance(fp, (int, float)) or fp <= 0: - continue - if r.get('applied_on') == '0_product_variant' and r.get('product_id'): - var.add(O.m2o_id(r['product_id'])) - elif r.get('product_tmpl_id'): - tmpl.add(O.m2o_id(r['product_tmpl_id'])) - _targets_memo[list_name] = (var, tmpl) - return var, tmpl - - # LEG 1 — COVERAGE PER LIST. Ours: cells we filled. Theirs: a bare Odoo `search_count` of - # ACTIVE products a fresh read of that list's rules reaches. The rule set is shared (it IS - # the data) but the EXPANSION is not, and the expansion is what breaks. - for _col, _name in products.PRICELIST_COLUMNS: - _var, _tmpl = _rule_targets(_name) - _dom = [('active', '=', True), '|', ('id', 'in', sorted(_var)), - ('product_tmpl_id', 'in', sorted(_tmpl))] - theirs = O.get_odoo().search_count('product.product', _dom) if (_var or _tmpl) else 0 - ours = sum(1 for r in rows if isinstance(r.get(_col), (int, float))) - checks.append({ - "check": f"{_name} pricelist: SKUs priced in the grid == an INDEPENDENT Odoo count " - f"of ACTIVE products its date-valid fixed rules reach", - "ours": ours, "theirs": theirs, "ok": ours == theirs, - "detail": {"column": _col, "variant_rules": len(_var), "template_rules": len(_tmpl)}, - }) - - # LEG 2 — ⛔ THE TICKET'S OWN NEGATIVE CONTROL, AS A LEG. A SKU with no specific item must - # render BLANK. Asked of ODOO, never of the map that produced the blank — "everything I left - # empty was genuinely unpriced" is trivially true when checked against the dict that emptied - # it, which is the self-sealing shape the population leg above exists to end. - _unpriced = [r for r in rows - if not any(isinstance(r.get(c), (int, float)) - for c, _n in products.PRICELIST_COLUMNS)] - _codes = {r["code"] for r in _unpriced} - _stray = 0 - if _codes: - # Resolve those codes back to Odoo ids and ask whether ANY declared list prices them. - _ids, _tmpls = set(), set() - for _p in O.search_read('product.product', - [('active', '=', True), - ('default_code', 'in', sorted(c for c in _codes - if not c.startswith("pid:")))], - ['id', 'product_tmpl_id']): - _ids.add(_p['id']) - _tmpls.add(O.m2o_id(_p.get('product_tmpl_id'))) - for _col, _name in products.PRICELIST_COLUMNS: - _v, _t = _rule_targets(_name) - _stray += len((_v & _ids) | ({t for t in _tmpls if t in _t})) - checks.append({ - "check": "every SKU rendering a BLANK price is genuinely unpriced on all three declared " - "lists (W29-T52's NC: no fallback dressed as a price) — asked of Odoo", - "ours": _stray, "theirs": 0, "ok": _stray == 0, - "detail": {"blank_skus": len(_unpriced), "priced_skus": len(rows) - len(_unpriced)}, - }) - - # LEG 3 — ⭐ R6's SECOND SENTENCE, AS A NUMBER. *"If there is lag or it can't be done, you - # need to explicitly tell me why and recommend a fix."* Everything the reader cannot see is - # counted here rather than dropped. The ASSERTION is the one thing that must never be true — - # a price cell that is present and not a positive number, i.e. a "free" SKU — while the - # rest rides `detail` so it is reported without reddening an honest day's data. - _bad = [r["code"] for r in rows - for c, _n in products.PRICELIST_COLUMNS - if r.get(c) is not None and not (isinstance(r.get(c), (int, float)) and r[c] > 0)] - # ⛔ MEASURED 2026-08-12 AND NOT FIXABLE FROM THIS FENCE: `aios_grid.rows_from_pool` sends - # every non-text field through `_round(v)` = `round(v)` with no ndigits, i.e. to a whole - # dollar. 52.1% of fixed prices carry cents and the median relative error is 5.26%, so this - # column ties to the cent HERE and ships rounded. Reported, never silently enforced. - _cents = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS - if isinstance(r.get(c), (int, float)) and abs(r[c] - round(r[c])) > 1e-9) - _to_zero = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS - if isinstance(r.get(c), (int, float)) and round(r[c]) == 0) - checks.append({ - "check": "no price cell is present-but-not-a-positive-number (a 0 would read as FREE; " - "an unset rule must be blank), and what the reader cannot see is COUNTED", - "ours": len(_bad), "theirs": 0, "ok": not _bad, - "detail": {"bad_cells": _bad[:10], "reader_report": price_report, - "wire_rounding_loses_cents_on": _cents, - "wire_rounding_to_zero_dollars": _to_zero}, - }) - - # WAVE 17 R3 — the BUY SIGNAL must be a total, exact partition of the catalogue, and every - # member of it must be re-derivable from the two columns beside it. A signal somebody buys - # stock on cannot be "mostly right": the failure that matters is a row that says OK because - # an input was missing, so the blank leg is checked as hard as the other two. - if team_id is None: # consolidated only — the inputs exist only there - buy = [r for r in rows if r.get("buy_now") == "Buy now"] - ok_rows = [r for r in rows if r.get("buy_now") == "OK"] - blank = [r for r in rows if r.get("buy_now") is None] - # ⚠ RE-DERIVED FROM `cover_gap_units`, NOT FROM `dos < lead_days`, SINCE 2026-08-19. The - # signal counts inbound stock now; the old predicate does not, so leaving it here made the - # check disagree with the column on **796 rows** — every SKU with an open purchase order. - # It was the check that was stale, and it caught the definition move exactly as intended. - mis = sum(1 for r in buy if not (r["cover_gap_units"] > 0)) - mis += sum(1 for r in ok_rows if not (r["cover_gap_units"] <= 0)) - # A blank must be UNKNOWN — never a row we could have answered and quietly did not. - mis += sum(1 for r in blank if isinstance(r.get("cover_gap_units"), (int, float))) - checks.append({ - "check": "Buy signal partitions the catalogue (buy + ok + unknown == rows, none " - "misclassified)", - "ours": len(buy) + len(ok_rows) + len(blank) - mis, "theirs": len(rows), - "ok": mis == 0 and len(buy) + len(ok_rows) + len(blank) == len(rows), - "detail": {"buy_now": len(buy), "ok": len(ok_rows), "unknown": len(blank), - "misclassified": mis}, - }) - # ⭐ OWNER 2026-08-03 — "use the formula fields to come up to the same EXACT figures". - # - # This is that sentence, as a check. The retired preset column and the formula that - # replaces it must agree on every SKU, in all three states, or the replacement is not a - # replacement. `_buy_signal_formula` is a line-by-line port of the client formula - # engine's evaluation of - # - # IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "") - # - # including the part that is easy to get wrong: a comparison against a BLANK is blank, - # never a coerced 0. (The client engine's `cmp` returns null unless both sides read as - # numbers, and `IF` refuses a non-boolean condition — so a missing `dos` yields "" and - # not "Buy now". A filter engine would have said `0 < 30` and swept in every SKU that - # never sells through; the formula engine does not, and this check is what holds it.) - # - # ⚠ It compares SETS OF SKUs, not counts. Two different partitions can share a shape. - disagree = sorted(r["code"] for r in rows - if (r.get("buy_now") or "") != _buy_signal_formula(r)) - checks.append({ - "check": 'Buy signal as a FORMULA field == the retired preset column, per SKU ' - '(' + BUY_SIGNAL_FORMULA + ')', - "ours": len(rows) - len(disagree), "theirs": len(rows), - "ok": not disagree, - "detail": {"disagreeing_skus": disagree[:10], "n_disagree": len(disagree)}, - }) - # The other half of "the same figures": the SHARED Buy list view's conditions must select - # exactly the Buy-now SKUs. They are `discontinued neq Yes AND cover_gap_units gt 0` - # (_seed_wave17.views), so this reproduces exactly that conjunction. - # - # ⭐⭐ THE FOUR-LEAF CONJUNCTION COLLAPSED TO TWO ON 2026-08-19, and the guard the old - # leaves provided is now STRUCTURAL rather than spelled out. The two `isNotEmpty` leaves - # existed because the client filter engine's `toNum(null)` is **0**, so a bare - # `dos < lead_days` read a SKU with no days-of-supply as `0 < 30` = TRUE and put every - # never-selling product on the buy list. `cover_gap_units > 0` inverts that accident into - # a safety: a blank reads as 0, and `0 > 0` is FALSE, so an unknown SKU is EXCLUDED. The - # filter now fails CLOSED on exactly the rows the old one failed OPEN on. - # - # ⛔ AND THE ROUNDING TRAP IS THE REASON IT IS NOT `cover_gap_d < 0`. That column is - # `int(round(...))`, so a real gap of -0.4 days rounds to 0 and the SKU silently drops off - # a list it belongs on. `cover_gap_units` rounds AWAY from zero for that reason, and the - # boundary control below proves it on the rows where the two disagree. - view_rows = {r["code"] for r in rows - if (r.get("discontinued") or "") != "Yes" - and isinstance(r.get("cover_gap_units"), (int, float)) - and r["cover_gap_units"] > 0} - signal_rows = {r["code"] for r in buy if (r.get("discontinued") or "") != "Yes"} - checks.append({ - "check": "Buy list view conditions (discontinued neq Yes AND cover_gap_units gt 0) " - "select exactly the Buy-now SKUs that are not discontinued", - "ours": len(view_rows), "theirs": len(signal_rows), - "ok": view_rows == signal_rows, - "detail": {"only_in_view": sorted(view_rows - signal_rows)[:10], - "only_in_signal": sorted(signal_rows - view_rows)[:10]}, - }) - - # ⛔⛔ THE BOUNDARY CONTROL, and it is the single check that proves the collapse above did - # not silently SHRINK the list. It re-derives the rows where `cover_gap_d` rounds to 0 but - # the true gap is negative — precisely the SKUs the old comment warned a `cover_gap_d < 0` - # filter would lose — and asserts every one of them is still selected by the new predicate. - # A SKU short by 0.4 days of demand must produce `cover_gap_units >= 1`, not 0. - # ⚠ DISCONTINUED ROWS ARE OUT OF THIS POPULATION, and finding that out is why the control - # is written as a control. Its first run went RED naming SKU `15001`, which is a genuine - # sub-one-day shortfall AND carries Odoo's Discontinued tag: correctly absent from the buy - # list, for the other reason. Counting it as "dropped by rounding" would have made a - # working exclusion look like a rounding defect on every future run. - boundary = [r for r in rows - if r.get("cover_gap_d") == 0 - and (r.get("discontinued") or "") != "Yes" - and isinstance(r.get("cover_gap_units"), (int, float)) - and r["cover_gap_units"] > 0] - missed = sorted(r["code"] for r in boundary if r["code"] not in view_rows) - checks.append({ - "check": "Rounding boundary: every SKU whose cover gap rounds to 0 DAYS but is a real " - "shortfall in UNITS is still on the buy list", - "ours": len(boundary) - len(missed), "theirs": len(boundary), - "ok": not missed, - "detail": {"dropped_by_rounding": missed[:10], - # Reported, not asserted: these are the rows a `cover_gap_d < 0` filter - # would have lost. A 0 here does not make that filter correct. - "n_saved_by_rounding_away_from_zero": len(boundary)}, - }) - - # ⭐ RULE 8 — the Discontinued column against an INDEPENDENT Odoo aggregate. - # ⛔ IT RE-RESOLVES THE TAG BY NAME rather than reusing the id the column was built from. - # Sharing that binding would let the check and the column carry the same bug and agree - # about it ([[gate-and-nc-must-not-share-a-binding]]). - tag_id = products.discontinued_tag_id.__wrapped__() - ours_disc = len([r for r in rows if (r.get("discontinued") or "") == "Yes"]) - theirs_disc = O.get_odoo().search_count( - 'product.product', - [('active', '=', True), ('product_tag_ids', 'in', [tag_id])]) if tag_id else -1 - checks.append({ - "check": f"Discontinued SKUs == Odoo products carrying the {products.DISCONTINUED_TAG}" - f" tag (resolved by name, id {tag_id})", - "ours": ours_disc, "theirs": theirs_disc, - # Codes merge variants, so ours may be <= theirs; a tag that resolved to nothing is a - # hard red, because the column would read "nobody is discontinued" and look fine. - "ok": tag_id is not None and 0 < ours_disc <= theirs_disc, - "detail": {"tag_resolved": tag_id is not None, - "note": "ours counts SKU CODES, theirs counts product RECORDS; variants " - "sharing a code merge into one row, so ours <= theirs."}, - }) - - # ⭐ RULE 8 — inbound units against a SECOND, INDEPENDENTLY DERIVED source (open purchase - # order lines). ⛔ SHAPE AND DIRECTION, NOT EQUALITY: the two are in different units of - # measure on ~107 codes and asserting equality would go red forever on a correct - # difference. `products.incoming_from_po` documents the measurement. - po_units, po_report = products.incoming_from_po() - ours_inc = sum(float(r.get("incoming") or 0.0) for r in rows) - theirs_inc = sum(po_units.values()) - with_inbound = {r["code"] for r in rows if float(r.get("incoming") or 0.0) > 0} - po_codes = {c for c, v in po_units.items() if v > 0} - checks.append({ - "check": "Inbound units (Odoo incoming_qty) reconcile to open purchase order lines", - "ours": round(ours_inc, 1), "theirs": round(theirs_inc, 1), - # A truncated PO read makes the oracle itself untrustworthy, so it is a red. - "ok": (not po_report["truncated"]) and theirs_inc > 0 - and abs(ours_inc - theirs_inc) <= 0.10 * max(ours_inc, theirs_inc) - and len(with_inbound & po_codes) >= 0.85 * len(po_codes), - "detail": {"skus_with_inbound_ours": len(with_inbound), - "skus_with_inbound_po": len(po_codes), - "in_both": len(with_inbound & po_codes), - "po_lines": po_report, "uom_note": "purchase uom vs stock uom; the shipped " - "column is in the STOCK uom so it can be added to On hand."}, - }) - - # ⭐⭐ OWNER 2026-08-19 — `dos` COUNTS INBOUND, and this is the leg that proves it rather - # than trusting the expression. Two halves, because either alone is passable by accident: - # (a) EVERY row's dos re-derives from (on_hand + incoming) / (qty_ltm / 365), and - # (b) at least one row's dos is STRICTLY GREATER than the shelf-only figure would be. - # Without (b) the check stays green if `incoming` silently becomes 0 everywhere — the - # column would read exactly as it did before the ruling, and nothing would say so. - moved, wrong = 0, [] - for r in rows: - oh, inc, q = r.get("on_hand"), r.get("incoming") or 0.0, r.get("qty_ltm") - if oh is None or not isinstance(q, (int, float)) or q <= 0: - continue - want = round((float(oh) + float(inc)) / (float(q) / 365.0), 0) - if r.get("dos") != want: - wrong.append(r["code"]) - if inc > 0 and want > round(float(oh) / (float(q) / 365.0), 0): - moved += 1 - checks.append({ - "check": "Days of supply counts INBOUND units: every row re-derives from " - "(on_hand + incoming) / daily, and inbound demonstrably moves it", - "ours": moved, "theirs": len([r for r in rows if (r.get("incoming") or 0) > 0]), - "ok": not wrong and moved > 0, - "detail": {"rows_not_re_deriving": wrong[:10], "n_wrong": len(wrong), - "rows_where_inbound_raised_dos": moved, - "note": "a 0 in 'rows_where_inbound_raised_dos' means the column reads as " - "it did BEFORE the ruling, which is the silent-regression case."}, - }) - - # ⭐ RULE 8 — the FORWARD demand basis against a DIRECT Odoo read_group over the same - # reference window. ⛔ The oracle re-reads Odoo itself rather than re-calling - # `forward_demand_by_code`, which would reconcile the number with itself — the self-sealing - # shape this function already carries a scar for. - fwd_map, fwd_report = products.forward_demand_by_code(t=t, team_id=team_id) - _rf, _rt = fwd_report["reference_window"] - # ⚠ `include_excluded_partners=True` HERE TOO, or the oracle would measure a narrower - # universe than the column and go red on the Amazon units the owner asked to include. - direct = O.read_group('sale.order.line', - O.sale_line_domain(_rf, _rt, team_id, extra=products._NO_SVC, - all_channels=True), - ['product_uom_qty:sum'], [], lazy=False) - theirs_u = float((direct[0] or {}).get('product_uom_qty') or 0.0) if direct else 0.0 - # ⚠ A BRACKET, NOT AN EQUALITY, and the reason is in the number itself: the shipped total - # is the seasonal rows PLUS the LTM-fallback rows scaled onto the horizon, and the fallback - # rows are by definition SKUs that window never saw. Asserting equality would go red - # forever on a difference the design creates on purpose. - shipped = sum(float(r["demand_fwd"]) for r in rows - if isinstance(r.get("demand_fwd"), (int, float))) - checks.append({ - "check": f"Forward {products.FORWARD_MONTHS}-month demand basis: the shipped forecast " - f"ties to Odoo units over its own reference window {_rf} to {_rt}", - "ours": round(shipped, 0), "theirs": round(theirs_u, 0), - # The shipped total is seasonal rows PLUS scaled fallback rows, so it cannot equal the - # window total exactly; what must hold is that it is a real, bounded fraction of it and - # that the window itself returned units at all. - "ok": theirs_u > 0 and 0.5 * theirs_u <= shipped <= 1.5 * theirs_u, - "detail": {**fwd_report, - "skus_with_a_forecast": len([r for r in rows if r.get("demand_fwd")]), - "note": "shipped = seasonal rows + LTM-fallback rows scaled to the " - "horizon, so it brackets rather than equals the window total."}, - }) - checks.extend(validate_measures(t=t, team_id=team_id, - pool_codes={r["code"] for r in rows})) - checks.extend(validate_price_and_unit_cells(rows)) - checks.extend(validate_stock_measures(t=t)) - return checks - - -def validate_stock_measures(t=None, days=90, sample=4): - """⭐⭐ W37-T13 — `stock_in` / `stock_out` per SKU, against a DIRECT live Odoo `read_group`. - - ⛔ THE TWO-SIDED DOMAIN IS REPRODUCED ON THE LIVE SIDE, and getting it wrong there would hide - exactly the defect this validates. `location_id.usage` is a DOT-PATH FILTER, which Odoo - supports; a dot-path GROUPBY faults, which is why the direction is expressed as two separate - filtered reads rather than one grouped-by-usage read (`proto/P1-stock-moves.md`, gotcha 1). - - ⛔ AND THE DIRECTION SPLIT IS ASSERTED, NOT ASSUMED. A one-sided domain produces IN == OUT for - every internal transfer, so a run where the two columns agree everywhere is the signature of - the bug rather than of a quiet warehouse. The last leg requires a SKU where they genuinely - differ — the ticket's own `done-when` clause, and the only one a total cannot fake. - """ - from harness import datastore as DS - from harness import semantic as sem - - t = t or P.today() - checks = [] - offer = sem.entity_measures("odoo_products") - keys = [m["key"] for m in offer if m["key"].startswith("stock_")] - if not keys: - why = [r for r in sem.entity_measure_refusals("odoo_products") - if str(r.get("key", "")).startswith("stock_")] - return [{"check": "the product catalogue offers Stock moved in / out (W37-T13)", - "ours": 0, "theirs": 3, "ok": False, - # ⭐ The refusal carries its own cause — reported, not inferred from an absence. - "detail": {"refusals": why or "no stock binding declared"}}] - checks.append({"check": "the product measure catalogue offers the stock-movement keys " - "(W37-T13)", "ours": sorted(keys), "theirs": 3, - "ok": {"stock_in", "stock_out"} <= set(keys)}) - - con = DS.ro_cursor() - try: - newest = con.execute("SELECT max(date) FROM stock_move").fetchone() - finally: - con.close() - d_to = t - _dt.timedelta(days=2) - if newest and newest[0]: - try: - d_to = min(d_to, _dt.date.fromisoformat(str(newest[0])[:10]) - _dt.timedelta(days=1)) - except ValueError: - pass - d_from = d_to - _dt.timedelta(days=days) - DF, DT = d_from.isoformat(), d_to.isoformat() - ours = sem.entity_measure_values("odoo_products", ["stock_in", "stock_out"], - date_from=DF, date_to=DT, offer=offer) - - o = O.get_odoo() - base = [("state", "=", "done"), - ("date", ">=", f"{DF} 00:00:00"), ("date", "<=", f"{DT} 23:59:59")] - IN = base + [("location_dest_id.usage", "=", "internal"), - ("location_id.usage", "!=", "internal")] - OUT = base + [("location_id.usage", "=", "internal"), - ("location_dest_id.usage", "!=", "internal")] - live = {} - for dom, side in ((IN, "stock_in"), (OUT, "stock_out")): - for r in o.read_group("stock.move", dom, ["product_id", "quantity_done:sum"], - ["product_id"], lazy=False): - if not r.get("product_id"): - continue - live.setdefault(r["product_id"][0], {})[side] = r["quantity_done"] - code_of = _codes_of_odoo_products(o, list(live)) - by_code = {} - for pid_, v in live.items(): - c = code_of.get(pid_, f"pid:{pid_}") - d = by_code.setdefault(c, {"stock_in": 0.0, "stock_out": 0.0}) - for k in ("stock_in", "stock_out"): - d[k] += v.get(k, 0.0) - - for side in ("stock_in", "stock_out"): - a = round(sum(c.get(side, 0) for c in ours.values()), 2) - b = round(sum(c.get(side, 0) for c in by_code.values()), 2) - checks.append({ - "check": f"{side}: the mirror's per-SKU total vs a DIRECT Odoo read_group under the " - f"SAME two-sided location domain", - "ours": a, "theirs": b, "gap": round(a - b, 2), - "ok": bool(b) and abs(a - b) <= 0.02 * b, - "detail": {"window": [DF, DT], "skus_ours": len(ours), "skus_odoo": len(by_code)}}) - - # ⛔ THE NAMED SKU, and it is chosen for DIFFERING — see the docstring. - diff = sorted(((abs((c.get("stock_in") or 0) - (c.get("stock_out") or 0)), k) - for k, c in ours.items()), reverse=True)[:sample] - named = [] - for _d, k in diff: - named.append({"sku": k, - "ours": {s: round(ours[k].get(s, 0), 2) for s in ("stock_in", "stock_out")}, - "odoo": {s: round((by_code.get(k) or {}).get(s, 0), 2) - for s in ("stock_in", "stock_out")}}) - off = [n for n in named - if any(abs(n["ours"][s] - n["odoo"][s]) > max(0.01, 0.02 * (n["odoo"][s] or 1)) - for s in ("stock_in", "stock_out"))] - checks.append({ - "check": f"each NAMED SKU's in/out ties to Odoo ({len(named)} SKUs, picked for the " - f"largest in-vs-out difference)", - "ours": len(off), "theirs": 0, "ok": not off, - "detail": {"named": named[:3], "mismatched": off[:2]}}) - genuinely_split = [n for n in named if n["ours"]["stock_in"] != n["ours"]["stock_out"]] - checks.append({ - "check": "⛔ the DIRECTION SPLIT is real: at least one SKU where IN and OUT genuinely " - "differ. A one-sided domain makes them equal for every internal transfer, so " - "all-equal is the SIGNATURE OF THE BUG, not a quiet warehouse", - "ours": len(genuinely_split), "theirs": ">=1", "ok": bool(genuinely_split), - "detail": {"example": genuinely_split[0] if genuinely_split else None}}) - return checks - - -def validate_price_and_unit_cells(rows, sample=6): - """⭐⭐ W37-T14 / T15 — the `Tier prices` and `Units` cells, against a FRESH Odoo read. - - ⛔ THE ORACLE IS ASKED PER SKU, not in bulk, and deliberately so: the builders group a - bulk read in Python, so re-running the same bulk read would re-run the same grouping and - could only ever agree with itself. Asking Odoo for ONE SKU's price rules is a different - question shape and can actually disagree ([[no-unverifiable-aggregates]]). - - ⚠ WHAT IS NOT PROVEN HERE, said rather than implied: that a PERSON sees the cell. These are - `json` columns on the product grid and the render is the client's; the data half is what a - module `validate()` can reach. - """ - checks = [] - priced = [r for r in rows if r.get("tier_prices")] - united = [r for r in rows if r.get("units")] - checks.append({ - "check": "the product grid serves a Tier-prices cell (W37-T14) and a Units cell (T15); " - "a DECLARED column that is never filled is the defect these replace", - "ours": {"with_tier_prices": len(priced), "with_units": len(united), "rows": len(rows)}, - "theirs": ">0 each", - # ⚠ Units are legitimately sparse (19.6% measured), so the floor is existence, not a rate. - "ok": bool(priced) and bool(united), - "detail": {"multi_price_skus": sum(1 for r in priced - if len(json.loads(r["tier_prices"])) > 1)}, - }) - if not priced: - return checks - o = O.get_odoo() - pls = {p["id"]: str(p.get("name") or "").strip() - for p in O.search_read("product.pricelist", [], ["id", "name"])} - today = P.today().isoformat() - # Prefer SKUs that carry MORE THAN ONE price — the ticket's own subject. - cand = sorted(priced, key=lambda r: -len(json.loads(r["tier_prices"])))[:sample] - bad = [] - for r in cand: - mine = sorted((t["pricelist"], round(float(t["unit_price"]), 2)) - for t in json.loads(r["tier_prices"])) - pid = r.get("product_id") - tmpl = None - if pid: - rec = o.search_read("product.product", [("id", "=", pid)], ["product_tmpl_id"]) - tmpl = O.m2o_id(rec[0].get("product_tmpl_id")) if rec else None - dom = [("compute_price", "=", "fixed"), - "|", ("date_start", "=", False), ("date_start", "<=", today), - "|", ("date_end", "=", False), ("date_end", ">=", today), - ("fixed_price", ">", 0), - "|", ("product_id", "=", pid), ("product_tmpl_id", "=", tmpl)] - live = o.search_read("product.pricelist.item", dom, - ["pricelist_id", "fixed_price", "min_quantity", "applied_on"]) - best = {} - for it in live: - nm = pls.get(O.m2o_id(it.get("pricelist_id")), "?") - q = it.get("min_quantity") or 0.0 - if nm not in best or q < best[nm][0]: - best[nm] = (q, round(it.get("fixed_price") or 0.0, 2)) - theirs = sorted((nm, v) for nm, (q, v) in best.items()) - # ⚠ A code carried by TWO active products legitimately holds MORE entries than a single - # product's rules (D-309 / `2112-12`), so ours is a SUPERSET, never an equality. - if not set(theirs) <= set(mine): - bad.append({"sku": r.get("code"), "ours": mine, "odoo": theirs}) - checks.append({ - "check": f"each sampled SKU's Tier prices contain every live Odoo price for it " - f"({len(cand)} SKUs, chosen for having the MOST prices)", - "ours": len(bad), "theirs": 0, "ok": not bad, - "detail": {"mismatches": bad[:3], - "sampled": [r.get("code") for r in cand]}, - }) - return checks - - -def _codes_of_odoo_products(o, ids): - """`{odoo product id: the identity key the GRID uses}` — `default_code`, or `pid:`. - - ⚠ BATCHED, 500 at a time. One call per id is ~1,700 XML-RPC round trips on this window and - turns a 5-second reconciliation into a coffee break. - ⚠ `active in [True, False]`: a re-SKUed line points at the ARCHIVED record and the grid - merges it under the surviving code, so an active-only read would key it `pid:` and - manufacture a mismatch this check would then report as a defect. - """ - ids = sorted({i for i in ids if i}) - out = {} - for i in range(0, len(ids), 500): - for p in o.search_read('product.product', - [('id', 'in', ids[i:i + 500]), ('active', 'in', [True, False])], - ['default_code']): - out[p['id']] = p.get('default_code') or f"pid:{p['id']}" - return {i: out.get(i, f"pid:{i}") for i in ids} - - -def validate_measures(t=None, team_id=None, days=90, pool_codes=None): - """⭐⭐ W37-T10 — THE MINTED LOOKBACK MEASURES, against a DIRECT Odoo `read_group`. - - Standing rule 8: a number that does not tie to Odoo does not ship. These columns are minted - from the tenant MIRROR (`semantic.entity_measure_values`), so the oracle has to be the live - ERP and nothing derived from the mirror — otherwise both sides come from the same place and - the check cannot fail, which is the self-sealing shape `validate()`'s own header records - costing a wave. - - ⛔⛔ THE MIRROR IS BEHIND LIVE, ALWAYS, AND THAT IS NOT A DEFECT — so a bare equality here - would be RED every day and would teach everyone to ignore it. The reconciliation is therefore - two-legged, and the second leg is the one that carries the meaning: - - leg 1 totals agree within the lag, and the lag is REPORTED as a number, not a tolerance; - leg 2 ⭐ EVERY line-level difference traces to a line ODOO WROTE AFTER THE MIRROR'S OWN - WATERMARK. This is what makes the check falsifiable: a join bug produces differences - on lines the mirror holds perfectly, and leg 2 goes red on the first one. - - ⚠ Do NOT "fix" leg 2 by filtering the live side on `write_date <= watermark` and comparing - totals — MEASURED 2026-08-19, that is a far worse instrument: confirming an order touches its - lines' `write_date` without changing a value, so the filter drops thousands of lines the - mirror holds correctly and the gap grows from $425 to $140,030. - """ - from harness import datastore as DS - from harness import semantic as sem - - t = t or P.today() - checks = [] - try: - if not DS.ready(): - return [{"check": "product lookback measures reconcile to Odoo", - "ours": "no mirror", "theirs": "-", "ok": False, - "detail": "the tenant store is not readable, so the measures are UNPROVEN, " - "and an unproven aggregate is exactly what standing rule 8 bars"}] - except Exception as e: # noqa: BLE001 - return [{"check": "product lookback measures reconcile to Odoo", - "ours": f"{type(e).__name__}", "theirs": "-", "ok": False, "detail": str(e)[:200]}] - - # ⛔ THE WINDOW END COMES FROM THE MIRROR, NOT FROM `today`, and this was measured the wrong - # way round first. A window ending today reaches past what the mirror has ever seen: orders - # placed since the last sync exist live and NOWHERE in the store, so the totals leg reported - # a 2.69% "lag" that was really "the last three days do not exist here yet". Anchoring on the - # mirror's own newest order makes the comparison one about EDITS to a shared period — which - # is the only difference that could indicate a join bug. - con = DS.ro_cursor() - try: - _newest = con.execute("SELECT max(date_order) FROM sale_order").fetchone() - finally: - con.close() - d_to = t - _dt.timedelta(days=2) - if _newest and _newest[0]: - _n = str(_newest[0])[:10] - try: - # one day INSIDE the mirror's newest order: the final day may be half-synced. - d_to = min(d_to, _dt.date.fromisoformat(_n) - _dt.timedelta(days=1)) - except ValueError: - pass - d_from = d_to - _dt.timedelta(days=days) - DF, DT = d_from.isoformat(), d_to.isoformat() - - offer = sem.entity_measures("odoo_products") - checks.append({ - "check": "the product measure OFFER is non-empty and every key it names resolves in the " - "semantic model (owner item 4 / R1)", - "ours": len(offer), "theirs": ">0", "ok": len(offer) > 0, - "detail": {"keys": [m["key"] for m in offer], - # ⭐ The REPORTING half of standing rule 1: a declared key that dropped out - # says why, rather than being quietly absent from a list nobody diffs. - "refused": sem.entity_measure_refusals("odoo_products")}, - }) - if not offer: - return checks - missing_family = [m["key"] for m in offer if m.get("empty") not in ("zero", "blank")] - checks.append({ - "check": "every offered measure declares an EMPTY-WINDOW family (C1: additive->0, " - "ratio->blank), because 72% of this catalogue has no group in a 90-day window", - "ours": len(missing_family), "theirs": 0, "ok": not missing_family, - "detail": {"undeclared": missing_family}, - }) - - store = sem.entity_measure_values("odoo_products", ["revenue", "units", "margin"], - date_from=DF, date_to=DT, exclude_services=False, - offer=offer) - o = O.get_odoo() - # ⛔ ASKED OF ODOO DIRECTLY, grouped by Odoo's OWN product id — deliberately NOT by the SKU - # code the mirror joins on, so the oracle cannot inherit our join key. - g = o.read_group('sale.order.line', O.sale_line_domain(DF, DT), - ['product_id', 'price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'], - ['product_id'], lazy=False) - live_tot = {"revenue": round(sum(r['price_subtotal'] for r in g), 2), - "units": round(sum(r['product_uom_qty'] for r in g), 2), - "margin": round(sum(r['margin'] for r in g), 2)} - ours_tot = {k: round(sum(v.get(k, 0) for v in store.values()), 2) - for k in ("revenue", "units", "margin")} - - # leg 2 — the falsifiable one. Every discrepant LINE must post-date the mirror's watermark. - con = DS.ro_cursor() - try: - wm = con.execute("SELECT cursor_wd FROM _sync_state WHERE entity = 'sale_order_line'" - ).fetchone() - wm = wm[0] if wm else None - rows = con.execute( - "SELECT l.id, l.price_subtotal FROM sale_order_line l " - "JOIN sale_order o ON o.id = l.order_id " - "WHERE o.state IN ('sale','done') AND o.team_id IN (5,6) " - " AND l.product_id IS NOT NULL " - " AND l.order_partner_id NOT IN " - " (SELECT id FROM res_partner WHERE name LIKE 'GIFTWARE%') " - " AND CAST(o.date_order AS TIMESTAMP) >= ? AND CAST(o.date_order AS TIMESTAMP) <= ?", - [f"{DF} 00:00:00", f"{DT} 23:59:59"]).fetchall() - finally: - con.close() - mine = {r[0]: (r[1] or 0.0) for r in rows} - theirs = {r['id']: (r['price_subtotal'] or 0.0) for r in - o.search_read('sale.order.line', O.sale_line_domain(DF, DT), - ['id', 'price_subtotal', 'write_date'])} - wd = {r['id']: str(r['write_date']) for r in - o.search_read('sale.order.line', O.sale_line_domain(DF, DT), ['id', 'write_date'])} - discrepant = [i for i, v in theirs.items() - if i not in mine or abs(mine[i] - v) >= 0.005] - unexplained = [i for i in discrepant if not wm or wd.get(i, '') <= str(wm)] - checks.append({ - "check": "every per-SKU measure difference vs live Odoo traces to a line Odoo wrote " - "AFTER the mirror's watermark, because a join bug would differ on a mirrored line", - "ours": len(unexplained), "theirs": 0, "ok": not unexplained, - "detail": {"lines_compared": len(theirs), "discrepant": len(discrepant), - "explained_by_mirror_lag": len(discrepant) - len(unexplained), - "watermark": str(wm), "window": [DF, DT], - "unexplained_line_ids": unexplained[:10]}, - }) - # ⛔⛔ THE LEG THAT CATCHES A WRONG JOIN KEY, and neither leg above can. Both of those sum the - # same LINES whatever dim they were grouped by, so swapping `dim: product_code` for - # `dim: product` (contract C1's trap, an Odoo id where the grid carries a SKU code) leaves - # them both green while every cell on the screen goes blank. This one asks: does the answer - # arrive under a key a GRID ROW ACTUALLY HAS, and is the value right FOR THAT SKU? - code_of = _codes_of_odoo_products(o, [r['product_id'][0] for r in g if r.get('product_id')]) - by_code = {} - for r in g: - if not r.get('product_id'): - continue - c = code_of.get(r['product_id'][0], f"pid:{r['product_id'][0]}") - by_code[c] = by_code.get(c, 0.0) + r['price_subtotal'] - # ⚠ The pool is the CALLER'S when it has one (it has already paid for it), and read fresh - # otherwise — `validate_measures` is runnable on its own, and a leg that silently skips when - # called directly is a leg nobody runs ([[gate-can-report-green-on-nothing]]). - pool_codes = set(pool_codes) if pool_codes is not None else { - r["code"] for r in pool(team_id=team_id, t=t)} - keyed_to_a_row = [c for c in store if c in pool_codes] - top = sorted(store.items(), key=lambda kv: -(kv[1].get("revenue") or 0))[:10] - spot = [{"sku": c, - "ours": round(v.get("revenue") or 0.0, 2), - "odoo": round(by_code.get(c, 0.0), 2)} for c, v in top] - # A named SKU may legitimately differ by a line Odoo edited after the watermark; the - # assertion is that MOST of the top ten tie exactly and NONE is off by an order of magnitude, - # which is what a mis-keyed join looks like (0.00 against a five-figure number). - exact = sum(1 for s in spot if abs(s["ours"] - s["odoo"]) < 0.005) - checks.append({ - "check": "the measure answer is KEYED TO THE GRID'S OWN IDENTITY (C1's join-key trap): " - "every group key is a SKU code a pool row carries, and the top-10 SKUs' revenue " - "ties to Odoo for THAT SKU", - "ours": {"keys_matching_a_pool_row": len(keyed_to_a_row), "of": len(store), - "top10_exact": exact}, - "theirs": {"keys_matching_a_pool_row": len(store), "of": len(store), "top10_exact": 10}, - # ⚠ Not `== len(store)`: a code whose ONLY product record is archived legitimately has - # revenue and no grid row (R12 keeps archived out), which `validate()`'s own - # "outside the grid" leg already reconciles. A mis-keyed join lands at ~0, not at 99%. - "ok": len(store) > 0 and len(keyed_to_a_row) / len(store) > 0.95 and exact >= 8, - "detail": {"spot_checks": spot, - "keys_with_no_pool_row": sorted(set(store) - pool_codes)[:10]}, - }) - checks.append({ - "check": "product lookback totals vs a DIRECT Odoo read_group, where the residual is mirror " - "lag and is REPORTED as a figure, never absorbed into a tolerance", - "ours": ours_tot, "theirs": live_tot, - # The assertion is on leg 2; this leg is red only if the lag is implausibly large, which - # is the shape that means "the mirror stopped" rather than "the mirror is a day behind". - "ok": abs(ours_tot["revenue"] - live_tot["revenue"]) <= max( - 0.01, live_tot["revenue"] * 0.02), - "detail": {"revenue_lag": round(ours_tot["revenue"] - live_tot["revenue"], 2), - "revenue_lag_pct": round( - (ours_tot["revenue"] - live_tot["revenue"]) / live_tot["revenue"] * 100, 4) - if live_tot["revenue"] else None, - "skus_in_grid_answer": len(store)}, - }) - return checks +"""modules/product_data.py — the PRODUCT table's pool (wave 15 item 9/10, contract C-TOPIC). + +The second object on the table-page factory: same grid, same engine, same permission wall — the +only difference is the field schema and the identity. `modules/customer_data.pool()` is the +template and this deliberately mirrors its signature and its row shape. + +⛔ THREE DECISIONS THIS FILE HAD TO MAKE, EACH RECORDED BECAUSE A LATER READER WILL WONDER. + +1. **THE IDENTITY IS A SKU CODE, WHICH IS A STRING, AND THE GRID WANTS AN INTEGER `pid`.** + Cohort membership, `allowed_pids`, the measure channel and `rows_from_pool` all key on an + integer. So each row carries BOTH: `pid` (a stable CRC32 of the code, so the same SKU gets the + same id on every pull and across processes — never an enumeration index, which would reshuffle + whenever the catalogue changes) and `code`, the real business key, as a visible column. + `_assert_no_pid_collision` fails the BUILD rather than the read: two SKUs sharing a pid would + silently merge in every downstream set operation, and a loud build failure is the only version + of that anyone would notice. + +2. **THE SCOPE RULE — inventory columns are CONSOLIDATED and are therefore OMITTED for a + BU-scoped caller.** `products.directory(t, team_id)` is brand-shaped; `inventory.sku_inventory` + is explicitly NOT (its own docstring: "on-hand stock is one physical warehouse, not + brand-tagged"). Joining them for a Fisch-only user would put BU-shaped revenue beside + company-wide stock IN THE SAME ROW — the mixed-scope value defect wave 15's amendment 3 exists + for, arriving through a different door. The honest options were "omit" or "label the columns + company-wide"; omit is the fail-closed one, and a column that is absent asks a question, + whereas a column that is silently company-wide answers one wrongly. + +3. **WHAT IS NOT HERE, NAMED RATHER THAN QUIETLY MISSING.** R4 lists ~20 fields. Vendor and + COUNTRY are not in Odoo at all — they live in the inventory WORKBOOK + ([[odoo-vendor-country-origin]]) and need a loader this module deliberately does not invent. + Margin % comes from `modules/pricing.table`, which is a heavier build (channel-rate cost + allocation) and is left for the wave that needs it. `validate()` reconciles what SHIPS; it + does not pretend to cover columns that are absent. + +4. **THE POOL IS CATALOGUE-FIRST, WITH REVENUE LEFT-JOINED** (wave 29, owner item 22 / R12, + 2026-08-11). It was `for r in products.directory(...)` — and `directory()`'s row set IS the + union of two revenue read-groups, so **a SKU that never sold could not exist** and the grid + showed **2,717** of **5,875** active products. Three things that will be re-derived otherwise: + + · ⛔ THE CAUSE IS A JOIN, NOT A LIMIT. No row cap exists on this path. Removing the date + window from `directory()` would ALSO be wrong twice: it breaks four SKU-health metrics + that legitimately want a sales window, and it lands at 3,327 (all-time-sold), because + ~2,550 active SKUs have never sold in wholesale scope at all. + · ⭐ REVENUE ON A NEVER-SOLD ROW IS **BLANK, NOT $0** (R12). A row that appears in the + revenue universe carries measured numbers INCLUDING a real 0.0 — it sold last year and + not this one, and that zero is a measurement. A row that appears in NO revenue read + carries `None`, which the wire keeps (`aios_grid._round` passes None through) and the + client renders as an empty cell. Blank is an admission; zero is a measurement. + · ⚠ A BU-SCOPED CALLER NOW SEES THE WHOLE CATALOGUE, with ITS OWN revenue and blanks where + that BU never sold. That is not decision 2's mixed-scope defect: the catalogue is the ROW + UNIVERSE, not a company-wide VALUE sitting beside a BU-shaped one. `product.product` + carries no team, so a catalogue cannot be BU-shaped at all — which is exactly why the two + sources are JOINED rather than merged. +""" +import datetime as _dt +import json +import math as _math +import zlib +from pathlib import Path + +import core.odoo as O +import core.periods as P +import core.shared_overlay as shared_overlay +import core.table_store as table_store +import modules.products as products + +#: POOL-ROW KEYS that exist ONLY on a consolidated pull. See decision 2. +#: `cover_gap_d` / `buy_now` (wave 17) join it because both are computed FROM `dos`, and a buy +#: signal built on a stock number the caller cannot see would be a recommendation nobody could +#: check ([[no-unverifiable-aggregates]]). +#: +#: ⚠ ROW KEYS, NOT FIELDS, and the distinction started mattering on 2026-08-03. Two readers: +#: `verify_perm_scope` asserts the shape of a POOL ROW against this, and `routes_products` +#: narrows the FIELD list with it. Since owner item 5 `buy_now` is a row key with no Field — +#: computed as `validate()`'s oracle, projected onto no wire — so it belongs here for the first +#: reader and is inert for the second. Removing it would stop the scope gate proving that a +#: BU-scoped pull withholds it. +#: +#: ⭐⭐ OWNER RULING 2026-08-11 — THIS SET IS NOW EMPTY, AND THAT IS THE POINT. Verbatim: +#: *"Just scope any inventory with Sales from Fisch, leave the rest."* Withholding the whole +#: inventory block from a BU-scoped reader meant a Fisch salesperson could not see whether +#: anything was IN STOCK, which is the first question they ask. The ruling splits the block by +#: what a business unit can actually shape: +#: +#: * `on_hand` / `unit_cost` / `inv_value` — ONE physical warehouse, no Fisch shelf and no +#: Royal shelf. Served UNSCOPED to everybody, identical in both pulls. ("leave the rest") +#: * `qty_ltm` / `dos` / `stock_bucket` / `cover_gap_d` / `buy_now` — all SALES-derived, so a +#: BU-scoped pull recomputes them from THAT unit's LTM units. ("scope any inventory with +#: Sales from Fisch") +#: +#: This deliberately relaxes decision 2's "never a company-wide value beside a BU-shaped one": +#: stock is not a value a BU can own, and a column that is absent for a Fisch reader asks a +#: question they cannot answer anywhere else in the product. `verify_perm_scope` no longer +#: asserts the columns are WITHHELD — it asserts the split above, which is a stronger claim and +#: cannot pass on an empty scoped payload (the shape the old rule and an outage share). +CONSOLIDATED_ONLY = () + +#: ⛔ POOL-ROW KEYS THAT DELIBERATELY HAVE NO FIELD — they must never reach a browser. +#: +#: `rows_from_pool` projects strictly through the field contract, so "no Field" already means +#: "no cell on the wire". This tuple makes that a CHECKED fact rather than a consequence nobody +#: is watching: `verify_perm_scope` asserts every member is absent from BOTH product contracts, +#: and that every OTHER `CONSOLIDATED_ONLY` key is present in the consolidated one. +#: +#: Written because the two tuples silently diverged the moment owner item 5 retired `buy_now`'s +#: Field while keeping its computation, and the gate — which was asserting a FIELD property from +#: a ROW-key list — went red with no way to tell a deliberate divergence from a dropped column. +UNSHIPPED_ROW_KEYS = ("buy_now",) + +#: Wave 17 (owner item 13, ruling R3) — the SUPPLIER MASTER, from the curated mastersheet map +#: `procurement_suppliers.json` (3,963 SKUs; supplier on 3,624, lead time on 3,563). NOT Odoo: +#: the owner confirmed this data came from the Inventory System Mastersheet, which is why +#: `modules/product_data`'s header said Vendor/Country were "NOT HERE, named rather than +#: quietly missing" and needed "a loader this module deliberately does not invent". This is +#: that loader. +#: +#: ⛔ THESE ARE CONTRACT COLUMNS, NOT USER-CREATED FIELDS, AND THE REASON IS A PERMISSION FACT. +#: A user-created field and its values live in the PER-USER strata (`table_store.workspace` +#: reads `store.get(key)[username]`); only VIEWS are shared. So a shared "Buy list" view that +#: filtered on a user-created column would, for every OTHER account, name a column that does not +#: exist — and an unknown column is an INACTIVE condition in the tri-state engine, which IGNORES +#: it and therefore WIDENS. The buy list would silently show the whole catalogue to everyone but +#: its author. Contract columns are identical for every reader, so the view means one thing. +#: +#: ⭐⭐ 2026-08-12 (W30-T36) — THE OWNER'S ASK IS NOW DELIVERED, AND THE PARAGRAPH ABOVE IS WHY IT +#: TOOK THREE WAVES. Owner: *"turn this Excel sheet into a User created Field that we can edit."* +#: It was parked because a per-user column silently WIDENS a shared view — not because editing was +#: hard. Wave 29's `core/shared_overlay.py` (whose header quotes this very comment) is the stratum +#: that removes the objection: **one value per (row, column) for the whole tenant**, so the column +#: still means ONE thing to every reader and a shared view still filters honestly. +#: ⇒ The four columns below are now `source: "overlay"` + `shared: true` in the canonical +#: contract, their values live in `__shared`, and the master map is what SEEDS an +#: unedited cell rather than what freezes it. See `SHARED_KEYS` and `_ProductTableStore`. +_SUPPLIER_MAP_PATH = Path(__file__).resolve().parent.parent / "procurement_suppliers.json" +_SUPPLIER_CACHE = {} + + +def supplier_master(): + """`{code: {supplier, lead_days, origin_country, first_cost}}`, read once per process. + + Degrades to `{}` when the file is unreadable, matching `_inventory_by_code`: a product table + that will not render because a master map is missing is a worse failure than one with blank + supplier columns. + """ + if _SUPPLIER_CACHE: + return _SUPPLIER_CACHE + try: + raw = json.loads(_SUPPLIER_MAP_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + for code, meta in (raw or {}).items(): + if not isinstance(meta, dict): + continue + lead = meta.get("lead") + _SUPPLIER_CACHE[str(code)] = { + "supplier": (meta.get("vendor") or "") or None, + "lead_days": int(lead) if isinstance(lead, (int, float)) else None, + "origin_country": (meta.get("country") or "") or None, + "first_cost": meta.get("first_cost"), + } + return _SUPPLIER_CACHE + +#: Wave 16 C-TOPIC — the PRODUCT table's OWN workspace bucket. ⛔ Never the customer one: +#: product pids are CRC32 hashes of SKU codes and customer pids are Odoo partner ids, so in a +#: SHARED overlay bucket a hash collision would silently write a product note onto somebody's +#: customer (or the reverse). Separate store keys make that structurally impossible, which is +#: the whole reason the table-page factory exists ("a new table object gets its own +#: table_store.make('_table_workspace')"). +TABLE_KEY = 'product_table_workspace' + +_GRID_FIELDS_PATH = Path(__file__).resolve().parent.parent / 'aios_grid_fields.json' +_SHARED_KEYS = None + + +def SHARED_KEYS(): + """The product columns whose values are TENANT-WIDE — derived from the canonical contract's + own `shared: true`, never typed out a second time. + + ⛔ IT DELIBERATELY DOES NOT SWALLOW A READ FAILURE. Degrading to `()` would send a shared + write into the per-user stratum with nothing going wrong anywhere — the widening defect + reappearing silently, which is the one outcome this whole mechanism exists to prevent. If the + canonical contract is unreadable the product grid cannot render at all (`pd_fields` parses the + same file with no guard), so a raise here costs nothing that was still working. + """ + global _SHARED_KEYS + if _SHARED_KEYS is None: + doc = json.loads(_GRID_FIELDS_PATH.read_text(encoding='utf-8')) + _SHARED_KEYS = tuple(f['key'] for f in (doc.get('product_data') or {}).get('fields') or [] + if f.get('shared')) + return _SHARED_KEYS + + +class _ProductTableStore(table_store.TableStore): + """The product workspace, with the SHARED columns routed to the tenant-wide stratum. + + ⭐⭐ THIS SUBCLASS IS THE WHOLE OF W30-T36's WRITE PATH, AND THE REASON IT LIVES HERE RATHER + THAN AT A ROUTE IS THAT **THE BROWSER NEVER CALLS `PATCH /products/{pid}`** — measured, zero + call sites in `aios-web/web/src`. A cell edit travels `POST /grid/events` → `grid_events. + handle_one` → `_tops(ctx).patch_overlay(...)`, and `_tops` returns `ctx.table`, which + `routes_grid._ctx` sets to `TABLE_OPS` for the product scope. So this object IS the seam both + doors pass through; intercepting at either route would have left the other one writing a + per-user value that only its author could see. + + ⚠ `st=self.st`, NEVER the module default. The shared stratum must resolve to the SAME store + handle as the per-user one it sits beside — `_tops`' own comment explains that a split, where + one side is tenant-scoped and the other is not, is worse than a stated residency error because + a user's value would vanish the moment they saved it. Reading `self.st` means both strata move + together the day that singleton gains a tenant handle. + """ + + def patch_overlay(self, username, pid, updates): + clean = dict(updates or {}) + if not clean: + return + keys = set(SHARED_KEYS()) + shared = {k: v for k, v in clean.items() if k in keys} + personal = {k: v for k, v in clean.items() if k not in keys} + if shared: + shared_overlay.put_cells(TABLE_KEY, pid, shared, st=self.st) + if personal: + super().patch_overlay(username, pid, personal) + + +TABLE_OPS = _ProductTableStore(TABLE_KEY) + + +def shared_cells(pids, st=None): + """`{"": {key: value}}` for the SHARED columns of the rows named by `pids`. + + ⚠ `pids` is required and positional all the way down — `shared_overlay.cells` refuses to serve + "everything" by design, and the caller here always holds an already-scoped pool. + """ + return shared_overlay.cells(TABLE_KEY, pids, st=st if st is not None else TABLE_OPS.st) + + +def sku_pid(code): + """A stable integer id for a SKU code. CRC32, masked to 31 bits so it is always positive and + always JSON-safe. Stable across processes and pulls, which an enumeration index is not.""" + return zlib.crc32(str(code).encode("utf-8")) & 0x7FFFFFFF + + +def _assert_no_pid_collision(rows): + """Two SKUs sharing a pid would MERGE in every set operation downstream — cohort membership, + allowed_pids, the measure channel — and nothing would report it. Fail the build instead.""" + seen = {} + for r in rows: + prior = seen.get(r["pid"]) + if prior is not None and prior != r["code"]: + raise ValueError( + f"product_data: pid collision — {prior!r} and {r['code']!r} both hash to " + f"{r['pid']}. Downstream set operations would merge them silently; widen the id " + f"before shipping this catalogue.") + seen[r["pid"]] = r["code"] + + +def _inventory_by_code(t): + """`{code: {...}}` from the inventory module, or `{}` if it cannot be read. + + Degrades to empty rather than raising, matching `customer_data._pool_build`'s treatment of + its own slow families: a product table that will not render because inventory is momentarily + unreachable is a worse failure than one with blank stock columns. + """ + try: + import modules.inventory as inventory + return inventory.sku_inventory(t=t) or {} + except Exception: + return {} + + +def _bu_ltm_share(t, team_id): + """`{code: 0.0..1.0}` — this unit's SHARE of the SKU's last-twelve-months units. + + ⭐ OWNER 2026-08-11: *"scope any inventory with Sales from Fisch"*. The velocity half of the + inventory block has to be re-shaped by a BU fact, and this is that fact. + + ⛔ A SHARE, NOT THE UNIT COUNT ITSELF — AND THE REASON IS A MEASURED IMPOSSIBILITY. My first + version returned `products._sku_rev(...)['qty']` and used it directly as the scoped `qty_ltm`, + leaving the consolidated column on `inventory.sku_inventory`'s own figure. Two readers of one + question ([[one-question-two-normalizers]]): they disagree, and on 5 SKUs of 5,871 the live + check found **Fisch's LTM units EXCEEDING the company's** — a subset larger than its superset, + which no reader could explain and no reconciliation could survive. + + Both halves of the ratio come from the SAME read here, so the share is in [0, 1] by + construction and the scoped figure can never exceed the consolidated one. It also leaves the + consolidated column exactly as it was — the Inventory page and the Product grid still agree + about company-wide units, which is what "leave the rest" asked for. + + `all_qty == 0` implies `bu_qty == 0` (same reader), so a share of 0 is the honest answer for + "this unit never sold it": `_bucket` turns that into 'No recent sales', not zero cover. + + Degrades to `{}` on any failure, matching `_inventory_by_code` — and a missing code then takes + the `_DEFAULT_SHARE` below rather than a silent 0. + """ + try: + f, to = P.ltm(t) + allq = {code: (r.get('qty') or 0.0) + for code, r in (products._sku_rev(f, to, None) or {}).items()} + buq = {code: (r.get('qty') or 0.0) + for code, r in (products._sku_rev(f, to, team_id) or {}).items()} + except Exception: + return {} + out = {} + for code, total in allq.items(): + out[code] = min(1.0, max(0.0, (buq.get(code, 0.0) / total))) if total > 0 else 0.0 + return out + + +#: What a SKU absent from the LTM sales read is worth to a business unit. ZERO — it did not sell +#: in anybody's book over the window, so no unit can claim its velocity. Named rather than +#: inlined so the choice is visible: the alternative (1.0, "assume it is all ours") would print +#: company-wide cover on a BU grid, which is the mixed-scope defect this whole rule avoids. +_DEFAULT_SHARE = 0.0 + + +def _dos_with_inbound(on_hand, incoming, qty_ltm): + """`(dos, bucket)` where DAYS OF SUPPLY COUNTS UNITS ALREADY ON ORDER as stock. + + ⭐⭐ OWNER, 2026-08-19: *"Days of supply field should INCLUDE inbound quantities."* This + REVERSES the split shipped hours earlier the same day, in which `dos` stayed on-hand-only while + only the cover gap counted inbound. That split was defensible and the owner has ruled against + it: one number, one meaning, and the two columns can no longer disagree about how much stock + this SKU has. + + ⛔ ONE DEFINITION, USED BY BOTH THE CONSOLIDATED AND THE BU-SCOPED PATH. They used to compute + days-of-supply in two places — `inventory.sku_inventory` for everybody and `_rescope_inventory` + for a scoped caller — and a change like this one is exactly how those two drift into answering + the same question differently ([[one-question-two-normalizers]]). Both roads now end here. + + ⚠ `_bucket` STILL RECEIVES THE REAL SHELF, NOT THE EFFECTIVE FIGURE, and that is deliberate. + Its only use of the quantity is an `on_hand <= 0` test that yields **'Out of stock'** — a + present-tense fact somebody can walk into the warehouse and check. A SKU with nothing on the + shelf and 500 units on the water IS out of stock today; the `dos` beside it says how long the + cover lasts once they land, and `Inbound units` shows why the two differ. + + ⚠ `on_hand is None` means no inventory row for this SKU: blank, never zero. Inbound alone + cannot manufacture a days-of-supply for a SKU the warehouse has never heard of. + """ + if on_hand is None: + return None, None + effective = float(on_hand) + float(incoming or 0.0) + qty = float(qty_ltm or 0.0) + daily = qty / 365.0 + if daily > 0: + dos_raw = effective / daily + else: + dos_raw = float('inf') if effective > 0 else 0.0 + try: + import modules.inventory as inventory + bucket = inventory._bucket(dos_raw, float(on_hand), qty) + except Exception: # noqa: BLE001 + bucket = None + return (None if dos_raw == float('inf') else round(float(dos_raw), 0)), bucket + + +def _rescope_inventory(e, share, incoming=None): + """`(qty_ltm, dos, bucket)` recomputed for ONE business unit's sales rate. + + The formulas are `inventory.sku_inventory`'s, applied to a BU-shaped numerator — NOT a second + idea of what days-of-supply means. `_bucket` is imported from there for the same reason: two + copies of a threshold table is how the Product grid and the Inventory page start disagreeing + about which SKUs are dead. + + ⚠ `on_hand` is whatever the warehouse holds, unscoped — so a Fisch reader's `dos` answers + "how long does ALL our stock last at Fisch's rate", which is the question a Fisch salesperson + actually has. It is deliberately NOT a pro-rated share of the shelf: there is no such shelf, + and inventing one would put a number on screen that no Odoo query could reproduce. + """ + on_hand = e.get("on_hand") + if on_hand is None: + return None, None, None # no inventory row for this SKU: blank, never zero + qty = float(e.get("qty_ltm") or 0.0) * float(share or 0.0) + # ⭐ THE FORMULA MOVED TO `_dos_with_inbound` (owner 2026-08-19) so the scoped and consolidated + # paths cannot answer days-of-supply differently. Only the NUMERATOR is BU-shaped: `qty` is + # this unit's share of LTM units, while the shelf and the inbound are one warehouse's. + dos, bucket = _dos_with_inbound(on_hand, incoming, qty) + return qty, dos, bucket + + +def _catalogue_by_code(): + """`{code: {'product', 'category'}}` — the CATALOGUE universe this pool is built from. + + ⛔ UNLIKE `_inventory_by_code`, THIS ONE RAISES, and the asymmetry is the whole point. + Inventory degrades to `{}` because a product table with blank stock columns beats one that + will not render. The CATALOGUE is not a column — it is the ROW SET. A catalogue read that + failed quietly would drop the grid straight back to the sold-only 2,717 with every gate + green and nothing on screen saying so, which is the defect this seam exists to end + ([[gate-can-report-green-on-nothing]]). `routes_products._pool_for` already turns the raise + into a 503 that names the cause, so the loud failure has somewhere honest to land. + + It exists as a `pd`-level function rather than an inline `products.catalogue()` call for the + same reason `_inventory_by_code` does: it is the seam `verify_perm_scope`'s section H stubs + to build a pool without Odoo. + """ + return products.catalogue() + + +def _pricelist_by_code(): + """`({code: {price_*: price}}, report)` from `products.pricelist_by_code`, or `({}, …)`. + + A SEAM for the same two reasons `_inventory_by_code` is one: it degrades rather than raises + (these are columns, not the row set), and `verify_perm_scope`'s section H stubs it to build a + pool without Odoo. ⛔ Unstubbed there, section H would reach live Odoo through the back door + and the whole file would stop being runnable offline. + """ + try: + return products.pricelist_by_code() + except Exception as e: + return {}, {"error": f"{type(e).__name__}: {str(e)[:200]}"} + + +def pool(team_id=None, t=None): + """One row per ACTIVE product — the PRODUCT analogue of `customer_data.pool`. + + CATALOGUE-FIRST, REVENUE LEFT-JOINED (R12 — see decision 4 in the module header). The row set + is `products.catalogue()`; `products.directory()` supplies the revenue columns for the SKUs + that sold in its window and contributes NO rows of its own. + + `team_id` shapes the revenue columns exactly as it does for customers (`products.directory` + passes it into `_sku_rev`), which is why `core.perm_scope.derive_pool_scope` must keep + driving it rather than a post-filter deciding the BU. It does NOT shape the row set: a + catalogue has no team. + """ + t = t or P.today() + consolidated = team_id is None + # ⭐ OWNER 2026-08-11: read inventory on EVERY pull, not just a consolidated one. The stock + # itself is company-wide; only the sales-derived half is re-scoped, by `_rescope_inventory`. + inv = _inventory_by_code(t) + bu_share = {} if consolidated else _bu_ltm_share(t, team_id) + sup = supplier_master() + prices, _price_report = _pricelist_by_code() + # ⭐ W37-T14 / T15. ⚠ MEASURED COST, stated because it lands on a scope's FIRST build: + # tier prices 15.1 s + packagings 8.1 s on top of the ~44 s consolidated build. Only the + # first build for a scope blocks (`routes_products._pool_for`'s stale-while-refresh), and + # both degrade to `{}` on a read failure rather than taking the grid down — the same + # asymmetry `pricelist_by_code` documents: a column is not the ROW SET. + try: + _prods = products.active_products() # ONE read, shared by both (see its docstring) + except Exception: # noqa: BLE001 + _prods = None + tiers, _tier_report = products.tier_prices_by_code(_prods) + packs, _pack_report = products.packagings_by_code(_prods) + cat = _catalogue_by_code() + # ⭐ THE BUY LIST'S DEMAND HORIZON (owner ruling 2026-08-19). BU-shaped through `team_id`, the + # same way the revenue columns are: a Fisch reader's reorder quantity must answer what FISCH + # will sell. Degrades to `{}` on a read failure, matching every other column source here — a + # blank cover gap is honest, a product grid that will not render is not. + try: + fwd_demand, _fwd_report = products.forward_demand_by_code(t=t, team_id=team_id) + except Exception: # noqa: BLE001 + fwd_demand, _fwd_report = {}, {"error": "forward demand unavailable"} + # The LEFT side of the join, indexed by the same code key. ⛔ A code here that the catalogue + # does not carry belongs to a product no ACTIVE record claims — archived, and R12 keeps those + # out. `validate()` asserts that of Odoo rather than assuming it, and reports the revenue + # that therefore sits outside the grid (MEASURED 2026-08-11: 3 codes, $0.00 YTD / $294.50 LY). + rev = {r["code"]: r for r in products.directory(t=t, team_id=team_id)} + + rows = [] + for code, meta in cat.items(): + r = rev.get(code) + row = { + "pid": sku_pid(code), + "code": code, + # ⭐ ONE source for the name and the category, the CATALOGUE — not the sale line's + # m2o. For a re-SKUed code the line's name can be the ARCHIVED record's; the active + # record's `display_name` is the current truth, and it is the same string for every + # SKU that is not re-SKUed. `directory()` derives the category identically. + "product": meta.get("product") or code, + "category": meta.get("category") or "(uncategorized)", + # ⭐⭐ OWNER 2026-08-19 — the Odoo Discontinued tag, surfaced so the buy list can drop + # them. ⛔ ALWAYS "Yes" OR "No", NEVER BLANK: an unknown value is an INACTIVE condition + # in the tri-state filter engine, so a view filtering on a blank column IGNORES the + # leaf and WIDENS to the whole catalogue. That is the exact failure `_seed_wave17`'s + # buy-list comment was written about, and a blank here would reintroduce it. + "discontinued": meta.get("discontinued") or "No", + # ⭐⭐ W33-T43 (R2 / amendment A2) — ODOO'S OWN PRODUCT ID, beside the hashed `pid`. + # R2 retires `ut_odoo_products` onto this key and keeps every data column it had; this + # is that column. ⛔ NOT DERIVABLE DOWNSTREAM: `pid` is `crc32(default_code)` here, + # unlike a customer row whose `pid` IS the partner id, so `aios_grid.py` cannot recover + # it and it has to be carried from `products.catalogue()`. + # ⚠ `None`, never 0, when the catalogue somehow has no id — 0 is a real Odoo id. + "product_id": meta.get("id"), + # ⭐ R12 — BLANK, NEVER $0, on a SKU no revenue read ever saw. A row that IS in `rev` + # keeps its measured numbers including a real 0.0 (it sold last year, not this one). + "rev_ytd": r.get("rev_ytd", 0.0) if r else None, + "rev_ly": r.get("rev_ly", 0.0) if r else None, + "yoy_pct": r.get("yoy_pct") if r else None, + "qty_ytd": r.get("qty_ytd", 0.0) if r else None, + "orders_ytd": r.get("orders_ytd", 0) if r else None, + } + # ⭐ WAVE 30 W30-T34 — the PRICELIST stratum, one column per declared list. Catalogue + # data like the supplier block, so it rides every pull, scoped or not: a price book is + # not a thing a business unit owns a slice of, and a Fisch reader who cannot see the + # Fisch price is exactly who this column is for. + # + # ⛔ `None`, NEVER 0 AND NEVER A FALLBACK, for the 1,883 SKUs no list prices. That is + # W29-T52's own negative control: the single `3_global` rule computes over `list_price`, + # which is 1.00 on 5,817 of 5,871 products, so the fallback is not a cheaper answer — + # it is a wrong one wearing a currency sign. + p = prices.get(code) or {} + for _col, _name in products.PRICELIST_COLUMNS: + row[_col] = p.get(_col) + # ⭐⭐ W37-T14 / T15 — THE HONEST SETS, beside the three declared columns rather than + # instead of them. The columns above answer "what does Fisch charge"; these answer "how + # many prices/units does this SKU actually have", which the columns structurally cannot: + # they are named after THIS tenant's lists, so a price on any other list is invisible. + # ⚠ A `json` cell is a STRING on the wire (the type's own contract), so these are dumped + # here rather than handed over as lists — a bare list would round-trip through the overlay + # as something `grid_events` refuses to re-parse. + # ⚠ BLANK, NOT "[]" — an empty cell reads as "sold in one unit / not priced on any list", + # and an empty JSON array on screen reads as a bug. + _tiers = tiers.get(code) + row["tier_prices"] = json.dumps(_tiers, ensure_ascii=False) if _tiers else None + _units = packs.get(code) + row["units"] = json.dumps(_units, ensure_ascii=False) if _units else None + # Wave 17 R3 — the supplier master, on every pull (it is catalogue data, not stock). + s = sup.get(code) or {} + row.update({ + "supplier": s.get("supplier") or "(none)", + "lead_days": s.get("lead_days"), + "origin_country": s.get("origin_country") or "(none)", + "first_cost": s.get("first_cost"), + }) + e = inv.get(code) or {} + # ⭐⭐ OWNER 2026-08-19: *"Days of supply field should INCLUDE inbound quantities."* Read + # here rather than in the cover-gap block below, because `dos` now needs it too. + incoming = meta.get("incoming") + if consolidated: + # ⛔ RECOMPUTED, NOT `e['dos']`. `inventory.sku_inventory` returns a shelf-only + # days-of-supply and knows nothing about purchase orders; taking its figure here would + # ship the pre-ruling number under the new column description. + qty_ltm = e.get("qty_ltm") + dos, bucket = _dos_with_inbound(e.get("on_hand"), incoming, qty_ltm) + else: + qty_ltm, dos, bucket = _rescope_inventory( + e, bu_share.get(code, _DEFAULT_SHARE), incoming) + row.update({ + # UNSCOPED on purpose (owner 2026-08-11): one warehouse, no per-brand shelf. + "on_hand": e.get("on_hand"), + "unit_cost": e.get("unit_cost"), + "inv_value": e.get("inv_value"), + # SCOPED: these three are functions of how fast THIS unit sells the SKU. + "qty_ltm": qty_ltm, + "dos": dos, + "stock_bucket": bucket, + }) + # THE BUY TRIGGER (R3), ported from `modules/procurement`'s lead-time-cover rule: + # buy when the shelf runs out before a reorder could land. + # + # ⚠ BLANK, NOT "OK", WHEN EITHER INPUT IS MISSING. 339 SKUs have no supplier and 400 + # no lead time; `dos` is null for anything that never sells through. "We don't know" + # and "you're fine" are different sentences, and only one of them is safe to print + # beside a purchasing decision. + # + # ⛔ `buy_now` SHIPS NOWHERE ANY MORE, and is computed anyway. OWNER 2026-08-03: it + # is not a preset field — it is a formula over the two columns beside it, and the + # platform has a formula field type for exactly that. Its Field is gone from + # `aios_grid_fields.json`, and `rows_from_pool` projects rows STRICTLY through that + # contract, so no Field means no cell on the wire. Nothing renders this key. + # + # It stays computed because deleting it would delete the PROOF. `validate()` below + # reconciles the formula's predicate against this one, row for row, which is the only + # thing that makes "the same exact figures" a claim rather than an assertion — and + # this repo has the scar already (wave 17: archiving `ar` "would have DELETED the + # proof"). Two comparisons per SKU is what that costs. + # + # ⭐ BU-SHAPED THROUGH THE DEMAND READ, not through `dos`. This block no longer divides by + # days-of-supply at all — `forward_demand_by_code` took `team_id`, so on a Fisch pull the + # cover gap already answers "does the stock outlast a reorder AT FISCH'S RATE". The rule + # the old comment protected still holds: never mix one unit's velocity with another's. + # ⭐⭐ OWNER 2026-08-19 — INBOUND COUNTS AS STOCK FOR THE COVER GAP. Verbatim: *"Cover gap + # day also should INCLUDE the incoming SKUs so it assumes those as stock even though its + # inbound"*, and *"we need the 'Cover gap (units)' basically tell us how much to buy"*. + # + # ⭐⭐ `dos` NOW COUNTS INBOUND TOO (owner, 2026-08-19), so the shelf figure behind this + # gap and the one behind Days of supply are THE SAME NUMBER. The earlier split, where only + # the cover gap counted inbound, is reversed. ⚠ What still differs between the two columns + # is the RATE, not the stock: `dos` divides by the trailing LTM rate, the cover gap by the + # forward 8-month forecast, so they still disagree on a seasonal SKU. Both say so in their + # own descriptions. + # ⚠ The INVENTORY module is untouched: `sku_inventory` is consumed only by this grid, and + # the overstock/dead-stock dashboards read `_enrich`'s own row-level `dos`, a separate path. + # + # ⭐⭐ THE VELOCITY BASIS IS FORWARD 8 MONTHS (owner ruling, 2026-08-19). The owner + # described the cover gap as *"based on the next 8 months Unit sales"*; it was a TRAILING + # twelve-month rate, and asked to choose, they ruled forward. `products.forward_demand_by_ + # code` is a SEASONAL read (the same 8 calendar months a year ago), which matters for a + # floral wholesaler whose year is Valentine's, Mother's Day and Christmas: a flat LTM rate + # spreads those peaks evenly and understates exactly the months a buyer is ordering for. + # + # ⛔ `dos` STILL USES THE LTM RATE, and the difference is deliberate rather than an + # oversight. `dos` and `stock_bucket` feed dead-stock and overstock, which ask "how long + # will what is on the shelf last at the rate it has been moving" — a backward-looking + # question. The cover gap asks "will it last until the reorder lands", which is forward. + # Both columns say which basis they use in their own descriptions. + lead = s.get("lead_days") + # ⚠ `incoming` came from the CATALOGUE row (`meta`) above, not the inventory row (`e`): + # both live on this SKU and only one was read from Odoo's product record. + row["incoming"] = incoming + fwd = fwd_demand.get(code) + row["demand_fwd"] = round(fwd) if isinstance(fwd, (int, float)) and fwd > 0 else None + daily = (fwd / float(products.FORWARD_DAYS)) if isinstance(fwd, (int, float)) \ + and fwd > 0 else None + on_shelf = row["on_hand"] + effective = ((on_shelf or 0.0) + (incoming or 0.0) + if isinstance(on_shelf, (int, float)) else None) + if daily and isinstance(effective, (int, float)) and isinstance(lead, (int, float)) \ + and lead > 0: + # Units of demand the lead time will consume, less everything we already hold or have + # bought. POSITIVE is a shortfall to buy; negative is surplus. Signed on purpose: a + # floor at zero would make the buy-list filter depend on the floor rather than on the + # measurement, and would flatten "covered by 2 units" into "covered by 400". + short = daily * lead - effective + # ⛔ AWAY FROM ZERO, NEVER `round()`. `cover_gap_d` is `int(round(...))` and the seed's + # own comment records why that makes it unsafe to SELECT on: a real gap of -0.4 days + # rounds to 0 and the SKU drops off a list it belongs on. A shortfall of 0.4 units is + # still a shortfall, so it must land on 1, not 0. `validate()` holds this on the + # boundary rather than trusting the expression. + row["cover_gap_units"] = int(_math.ceil(short) if short > 0 else _math.floor(short)) + row["cover_gap_d"] = int(round(effective / daily - lead)) + # ⭐ THE SIGNAL IS NOW A FUNCTION OF THE SAME NUMBER THE COLUMN SHOWS, so the words and + # the quantity on one row can no longer disagree — which they would have the moment + # the gap started counting inbound and the signal did not. + row["buy_now"] = "Buy now" if row["cover_gap_units"] > 0 else "OK" + else: + row["cover_gap_units"] = None + row["cover_gap_d"] = None + row["buy_now"] = None + rows.append(row) + + # `or 0.0` reads a BLANK as zero FOR SORTING ONLY — never for the cell. A never-sold SKU + # settles among the zero-revenue ones at the bottom, which is where it belongs; the stored + # value stays None so nothing downstream can mistake "we never saw a sale" for "we measured + # nothing sold". + rows.sort(key=lambda r: -(r["rev_ytd"] or 0.0)) + _assert_no_pid_collision(rows) + return rows + + +#: ⭐ THE BUY SIGNAL, AS THE FORMULA FIELD EVALUATES IT (owner, 2026-08-03). +#: +#: The owner's ruling was that "Buy signal" is not a preset field — it is a formula over two +#: columns the product table already carries, and the platform has a formula field type for it. +#: The formula, verbatim, is what `_seed_wave17.BUY_SIGNAL_FORMULA` creates and what the JSON +#: contract's `_product_removed_buy_now` note records: +#: +#: IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "") +#: +#: This function is a PORT of how `customer-grid/formulaEngine.ts` evaluates that tree, not a +#: restatement of the business rule — that is the whole point, because the two could drift and +#: `validate()` is where the drift must show. Three engine behaviours it reproduces exactly: +#: +#: · a `ref` to a missing/non-numeric cell is None (`case "ref"` returns null for anything +#: that is not a finite number or a string); +#: · `cmp` returns BLANK unless BOTH sides read as numbers — it never coerces a blank to 0, +#: which is the difference between this and a filter engine's `toNum(null) === 0`; +#: · `IF` with a non-boolean condition returns BLANK ("no truthiness"), so a blank comparison +#: propagates out as a blank cell rather than taking the false branch. +#: ⭐⭐ REPOINTED 2026-08-19 ONTO `cover_gap_units`, AND THE REASON IS THE OWNER'S OWN INSTRUCTION. +#: The old formula was `IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "")`. Once +#: the cover gap counts inbound stock, `{dos} < {lead_days}` is no longer the same question: it +#: would keep saying "Buy now" for a SKU whose replenishment is already on the water, which is the +#: shape of confusion the owner opened this work with. `cover_gap_units` already carries the whole +#: rule — lead time present and positive, velocity known, inbound counted, rounded away from zero +#: — so one reference replaces three and the words cannot disagree with the number beside them. +#: +#: ⚠ ONE ENGINE BEHAVIOUR CARRIES THE BLANK CASE, and it is why no `isNotEmpty` guard is needed: +#: `cmp` returns BLANK unless BOTH sides read as numbers, and `IF` with a non-boolean condition +#: returns BLANK. So a SKU with no lead time or no sell-through has `cover_gap_units = null`, the +#: comparison is blank, and the cell is blank — never the false branch, and never a coerced 0. +BUY_SIGNAL_FORMULA = 'IF({cover_gap_units} > 0, "Buy now", "OK")' + + +def _buy_signal_formula(row): + """Evaluate `BUY_SIGNAL_FORMULA` over one pool row -> 'Buy now' | 'OK' | '' (blank).""" + def num(v): + # The engine's `ref` + `asNumber`: booleans are not values here, and a non-finite + # number is null. `isinstance(True, int)` is True in Python, so bool is excluded first. + if isinstance(v, bool) or not isinstance(v, (int, float)): + return None + return v if v == v and v not in (float('inf'), float('-inf')) else None + + gap = num(row.get("cover_gap_units")) + if gap is None: # `{cover_gap_units} > 0` is blank -> IF(blank, …) is blank + return "" + return "Buy now" if gap > 0 else "OK" + + +def validate(team_id=None, t=None): + """Reconcile what SHIPS to an independent aggregate — the platform's own rule that a number + which does not tie to Odoo does not ship. + + ⛔⛔ THE POPULATION LEG IS NEW, AND IT IS HERE BECAUSE THIS FUNCTION USED TO BE SELF-SEALING + (wave 29, contract C8). It reconciled Σ per-SKU YTD revenue against `products._sku_rev` — the + same function `pool()` built its rows from. BOTH SIDES CAME FROM THE JOIN, so the oracle could + not see a missing row, and a grid holding 2,717 of 5,875 active products passed for a wave. + No gate anywhere asserted a pool row COUNT against an independent Odoo `search_count` either. + That is the leg below, and it is the control that would have caught it + ([[no-unverifiable-aggregates]]). + + ⚠ The revenue leg was NOT deleted with it — it answers a different question ("did the join + lose money?") and is still the right shape for that one. What changed is that it now has to + account for revenue belonging to codes NO ACTIVE PRODUCT CARRIES, because R12 keeps archived + products out of the grid; the total is decomposed rather than compared loosely, so a growing + "outside the grid" figure shows up as a number rather than as slack in a tolerance. + + Still deliberately NOT covered, and said rather than implied: the inventory columns are a + different module's reconciliation, and they are absent on a scoped pull anyway. + """ + t = t or P.today() + rows = pool(team_id=team_id, t=t) + + # ⭐⭐ THE POPULATION, AGAINST AN ORACLE THAT CANNOT SEE OUR JOIN. A bare Odoo count of active + # products, asked fresh — not a `len()` over anything this module or `products.catalogue()` + # built. NC: drop one row from `pool()` and this goes red; that is the whole point of it. + n_active = products.catalogue_count() + checks = [{ + "check": "the pool holds one row per ACTIVE product (R12) — row count == an INDEPENDENT " + "Odoo search_count('product.product', active=True)", + "ours": len(rows), "theirs": n_active, "ok": len(rows) == n_active, + }] + + yf, yt = P.ytd(t) + sku_rev = products._sku_rev(yf, yt, team_id) or {} + in_pool = {r["code"] for r in rows} + outside = {c: v for c, v in sku_rev.items() if c not in in_pool} + ours = round(sum(r["rev_ytd"] or 0.0 for r in rows), 2) + theirs = round(sum(v.get("rev", 0.0) for v in sku_rev.values()), 2) + dropped = round(sum(v.get("rev", 0.0) for v in outside.values()), 2) + checks.append({ + "check": "Σ per-SKU YTD revenue in the grid + Σ YTD revenue of codes no ACTIVE product " + "carries == modules.products._sku_rev over the same window", + "ours": round(ours + dropped, 2), "theirs": theirs, + "ok": abs((ours + dropped) - theirs) < 0.01, + "detail": {"in_the_grid": ours, "outside_the_grid": dropped, + "codes_outside": len(outside)}, + }) + # ⛔ AND THE EXCLUSION IS ASSERTED, NOT ASSUMED. "Everything I dropped was archived" is + # trivially true when checked against the dict that did the dropping — that is the + # self-sealing shape all over again. So it is asked of ODOO. A code here that a live ACTIVE + # product carries means `products.catalogue()` missed a row that has revenue, which is the + # 2,717 defect returning in a smaller costume. + # ⚠ `pid:N` keys are not `default_code`s and are skipped: an UNCODED active product is in the + # catalogue under its own `pid:N` key and therefore cannot be in `outside` at all. + coded_outside = sorted(c for c in outside if not str(c).startswith("pid:")) + still_active = (O.get_odoo().search_count( + 'product.product', [('active', '=', True), ('default_code', 'in', coded_outside)]) + if coded_outside else 0) + checks.append({ + "check": "every SKU with revenue but NO grid row is genuinely ARCHIVED (R12: archived " + "stay out) — asked of Odoo, never of the catalogue that did the dropping", + "ours": still_active, "theirs": 0, "ok": still_active == 0, + "detail": {"codes": coded_outside[:10], "n_codes": len(outside)}, + }) + # ── ⭐⭐ WAVE 30 W30-T34 — THE PRICELIST COLUMNS, AGAINST ORACLES THAT CANNOT SEE OUR JOIN ── + # + # Three legs, because the column can fail in three different ways and only one of them is a + # count. `products.pricelist_by_code` builds a `{code: {col: price}}` map by expanding each + # rule over the products it names, IN PYTHON — that expansion is the fragile part, so every + # leg below re-asks ODOO instead of re-reading the map. + price_report = _pricelist_by_code()[1] + _pl_rows = O.search_read('product.pricelist', [], ['id', 'name']) + _pl_id = {} + for _p in _pl_rows: + _pl_id.setdefault(str(_p.get('name') or '').strip(), _p['id']) + _today = P.today().isoformat() + + _targets_memo = {} + + def _rule_targets(list_name): + """(variant_ids, template_ids) a pricelist prices today — read FRESH from Odoo. + + Memoised for the LIFE OF THIS CALL only: legs 1 and 2 both need all three lists, and + without this `validate()` makes six identical round trips instead of three (measured: + ~48s of the run). Deliberately NOT an `lru_cache` — an oracle that survives the process + is an oracle reading yesterday's Odoo. + """ + if list_name in _targets_memo: + return _targets_memo[list_name] + var, tmpl = set(), set() + plid = _pl_id.get(list_name) + if plid is None: + return var, tmpl + for r in O.search_read( + 'product.pricelist.item', + [('pricelist_id', '=', plid), ('compute_price', '=', 'fixed'), + ('applied_on', 'in', ['0_product_variant', '1_product'])], + ['product_id', 'product_tmpl_id', 'applied_on', 'fixed_price', + 'date_start', 'date_end']): + ds, de = str(r.get('date_start') or '')[:10], str(r.get('date_end') or '')[:10] + if (ds and ds > _today) or (de and de < _today): + continue + fp = r.get('fixed_price') + if not isinstance(fp, (int, float)) or fp <= 0: + continue + if r.get('applied_on') == '0_product_variant' and r.get('product_id'): + var.add(O.m2o_id(r['product_id'])) + elif r.get('product_tmpl_id'): + tmpl.add(O.m2o_id(r['product_tmpl_id'])) + _targets_memo[list_name] = (var, tmpl) + return var, tmpl + + # LEG 1 — COVERAGE PER LIST. Ours: cells we filled. Theirs: a bare Odoo `search_count` of + # ACTIVE products a fresh read of that list's rules reaches. The rule set is shared (it IS + # the data) but the EXPANSION is not, and the expansion is what breaks. + for _col, _name in products.PRICELIST_COLUMNS: + _var, _tmpl = _rule_targets(_name) + _dom = [('active', '=', True), '|', ('id', 'in', sorted(_var)), + ('product_tmpl_id', 'in', sorted(_tmpl))] + theirs = O.get_odoo().search_count('product.product', _dom) if (_var or _tmpl) else 0 + ours = sum(1 for r in rows if isinstance(r.get(_col), (int, float))) + checks.append({ + "check": f"{_name} pricelist: SKUs priced in the grid == an INDEPENDENT Odoo count " + f"of ACTIVE products its date-valid fixed rules reach", + "ours": ours, "theirs": theirs, "ok": ours == theirs, + "detail": {"column": _col, "variant_rules": len(_var), "template_rules": len(_tmpl)}, + }) + + # LEG 2 — ⛔ THE TICKET'S OWN NEGATIVE CONTROL, AS A LEG. A SKU with no specific item must + # render BLANK. Asked of ODOO, never of the map that produced the blank — "everything I left + # empty was genuinely unpriced" is trivially true when checked against the dict that emptied + # it, which is the self-sealing shape the population leg above exists to end. + _unpriced = [r for r in rows + if not any(isinstance(r.get(c), (int, float)) + for c, _n in products.PRICELIST_COLUMNS)] + _codes = {r["code"] for r in _unpriced} + _stray = 0 + if _codes: + # Resolve those codes back to Odoo ids and ask whether ANY declared list prices them. + _ids, _tmpls = set(), set() + for _p in O.search_read('product.product', + [('active', '=', True), + ('default_code', 'in', sorted(c for c in _codes + if not c.startswith("pid:")))], + ['id', 'product_tmpl_id']): + _ids.add(_p['id']) + _tmpls.add(O.m2o_id(_p.get('product_tmpl_id'))) + for _col, _name in products.PRICELIST_COLUMNS: + _v, _t = _rule_targets(_name) + _stray += len((_v & _ids) | ({t for t in _tmpls if t in _t})) + checks.append({ + "check": "every SKU rendering a BLANK price is genuinely unpriced on all three declared " + "lists (W29-T52's NC: no fallback dressed as a price) — asked of Odoo", + "ours": _stray, "theirs": 0, "ok": _stray == 0, + "detail": {"blank_skus": len(_unpriced), "priced_skus": len(rows) - len(_unpriced)}, + }) + + # LEG 3 — ⭐ R6's SECOND SENTENCE, AS A NUMBER. *"If there is lag or it can't be done, you + # need to explicitly tell me why and recommend a fix."* Everything the reader cannot see is + # counted here rather than dropped. The ASSERTION is the one thing that must never be true — + # a price cell that is present and not a positive number, i.e. a "free" SKU — while the + # rest rides `detail` so it is reported without reddening an honest day's data. + _bad = [r["code"] for r in rows + for c, _n in products.PRICELIST_COLUMNS + if r.get(c) is not None and not (isinstance(r.get(c), (int, float)) and r[c] > 0)] + # ⛔ MEASURED 2026-08-12 AND NOT FIXABLE FROM THIS FENCE: `aios_grid.rows_from_pool` sends + # every non-text field through `_round(v)` = `round(v)` with no ndigits, i.e. to a whole + # dollar. 52.1% of fixed prices carry cents and the median relative error is 5.26%, so this + # column ties to the cent HERE and ships rounded. Reported, never silently enforced. + _cents = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS + if isinstance(r.get(c), (int, float)) and abs(r[c] - round(r[c])) > 1e-9) + _to_zero = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS + if isinstance(r.get(c), (int, float)) and round(r[c]) == 0) + checks.append({ + "check": "no price cell is present-but-not-a-positive-number (a 0 would read as FREE; " + "an unset rule must be blank), and what the reader cannot see is COUNTED", + "ours": len(_bad), "theirs": 0, "ok": not _bad, + "detail": {"bad_cells": _bad[:10], "reader_report": price_report, + "wire_rounding_loses_cents_on": _cents, + "wire_rounding_to_zero_dollars": _to_zero}, + }) + + # WAVE 17 R3 — the BUY SIGNAL must be a total, exact partition of the catalogue, and every + # member of it must be re-derivable from the two columns beside it. A signal somebody buys + # stock on cannot be "mostly right": the failure that matters is a row that says OK because + # an input was missing, so the blank leg is checked as hard as the other two. + if team_id is None: # consolidated only — the inputs exist only there + buy = [r for r in rows if r.get("buy_now") == "Buy now"] + ok_rows = [r for r in rows if r.get("buy_now") == "OK"] + blank = [r for r in rows if r.get("buy_now") is None] + # ⚠ RE-DERIVED FROM `cover_gap_units`, NOT FROM `dos < lead_days`, SINCE 2026-08-19. The + # signal counts inbound stock now; the old predicate does not, so leaving it here made the + # check disagree with the column on **796 rows** — every SKU with an open purchase order. + # It was the check that was stale, and it caught the definition move exactly as intended. + mis = sum(1 for r in buy if not (r["cover_gap_units"] > 0)) + mis += sum(1 for r in ok_rows if not (r["cover_gap_units"] <= 0)) + # A blank must be UNKNOWN — never a row we could have answered and quietly did not. + mis += sum(1 for r in blank if isinstance(r.get("cover_gap_units"), (int, float))) + checks.append({ + "check": "Buy signal partitions the catalogue (buy + ok + unknown == rows, none " + "misclassified)", + "ours": len(buy) + len(ok_rows) + len(blank) - mis, "theirs": len(rows), + "ok": mis == 0 and len(buy) + len(ok_rows) + len(blank) == len(rows), + "detail": {"buy_now": len(buy), "ok": len(ok_rows), "unknown": len(blank), + "misclassified": mis}, + }) + # ⭐ OWNER 2026-08-03 — "use the formula fields to come up to the same EXACT figures". + # + # This is that sentence, as a check. The retired preset column and the formula that + # replaces it must agree on every SKU, in all three states, or the replacement is not a + # replacement. `_buy_signal_formula` is a line-by-line port of the client formula + # engine's evaluation of + # + # IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "") + # + # including the part that is easy to get wrong: a comparison against a BLANK is blank, + # never a coerced 0. (The client engine's `cmp` returns null unless both sides read as + # numbers, and `IF` refuses a non-boolean condition — so a missing `dos` yields "" and + # not "Buy now". A filter engine would have said `0 < 30` and swept in every SKU that + # never sells through; the formula engine does not, and this check is what holds it.) + # + # ⚠ It compares SETS OF SKUs, not counts. Two different partitions can share a shape. + disagree = sorted(r["code"] for r in rows + if (r.get("buy_now") or "") != _buy_signal_formula(r)) + checks.append({ + "check": 'Buy signal as a FORMULA field == the retired preset column, per SKU ' + '(' + BUY_SIGNAL_FORMULA + ')', + "ours": len(rows) - len(disagree), "theirs": len(rows), + "ok": not disagree, + "detail": {"disagreeing_skus": disagree[:10], "n_disagree": len(disagree)}, + }) + # The other half of "the same figures": the SHARED Buy list view's conditions must select + # exactly the Buy-now SKUs. They are `discontinued neq Yes AND cover_gap_units gt 0` + # (_seed_wave17.views), so this reproduces exactly that conjunction. + # + # ⭐⭐ THE FOUR-LEAF CONJUNCTION COLLAPSED TO TWO ON 2026-08-19, and the guard the old + # leaves provided is now STRUCTURAL rather than spelled out. The two `isNotEmpty` leaves + # existed because the client filter engine's `toNum(null)` is **0**, so a bare + # `dos < lead_days` read a SKU with no days-of-supply as `0 < 30` = TRUE and put every + # never-selling product on the buy list. `cover_gap_units > 0` inverts that accident into + # a safety: a blank reads as 0, and `0 > 0` is FALSE, so an unknown SKU is EXCLUDED. The + # filter now fails CLOSED on exactly the rows the old one failed OPEN on. + # + # ⛔ AND THE ROUNDING TRAP IS THE REASON IT IS NOT `cover_gap_d < 0`. That column is + # `int(round(...))`, so a real gap of -0.4 days rounds to 0 and the SKU silently drops off + # a list it belongs on. `cover_gap_units` rounds AWAY from zero for that reason, and the + # boundary control below proves it on the rows where the two disagree. + view_rows = {r["code"] for r in rows + if (r.get("discontinued") or "") != "Yes" + and isinstance(r.get("cover_gap_units"), (int, float)) + and r["cover_gap_units"] > 0} + signal_rows = {r["code"] for r in buy if (r.get("discontinued") or "") != "Yes"} + checks.append({ + "check": "Buy list view conditions (discontinued neq Yes AND cover_gap_units gt 0) " + "select exactly the Buy-now SKUs that are not discontinued", + "ours": len(view_rows), "theirs": len(signal_rows), + "ok": view_rows == signal_rows, + "detail": {"only_in_view": sorted(view_rows - signal_rows)[:10], + "only_in_signal": sorted(signal_rows - view_rows)[:10]}, + }) + + # ⛔⛔ THE BOUNDARY CONTROL, and it is the single check that proves the collapse above did + # not silently SHRINK the list. It re-derives the rows where `cover_gap_d` rounds to 0 but + # the true gap is negative — precisely the SKUs the old comment warned a `cover_gap_d < 0` + # filter would lose — and asserts every one of them is still selected by the new predicate. + # A SKU short by 0.4 days of demand must produce `cover_gap_units >= 1`, not 0. + # ⚠ DISCONTINUED ROWS ARE OUT OF THIS POPULATION, and finding that out is why the control + # is written as a control. Its first run went RED naming SKU `15001`, which is a genuine + # sub-one-day shortfall AND carries Odoo's Discontinued tag: correctly absent from the buy + # list, for the other reason. Counting it as "dropped by rounding" would have made a + # working exclusion look like a rounding defect on every future run. + boundary = [r for r in rows + if r.get("cover_gap_d") == 0 + and (r.get("discontinued") or "") != "Yes" + and isinstance(r.get("cover_gap_units"), (int, float)) + and r["cover_gap_units"] > 0] + missed = sorted(r["code"] for r in boundary if r["code"] not in view_rows) + checks.append({ + "check": "Rounding boundary: every SKU whose cover gap rounds to 0 DAYS but is a real " + "shortfall in UNITS is still on the buy list", + "ours": len(boundary) - len(missed), "theirs": len(boundary), + "ok": not missed, + "detail": {"dropped_by_rounding": missed[:10], + # Reported, not asserted: these are the rows a `cover_gap_d < 0` filter + # would have lost. A 0 here does not make that filter correct. + "n_saved_by_rounding_away_from_zero": len(boundary)}, + }) + + # ⭐ RULE 8 — the Discontinued column against an INDEPENDENT Odoo aggregate. + # ⛔ IT RE-RESOLVES THE TAG BY NAME rather than reusing the id the column was built from. + # Sharing that binding would let the check and the column carry the same bug and agree + # about it ([[gate-and-nc-must-not-share-a-binding]]). + tag_id = products.discontinued_tag_id.__wrapped__() + ours_disc = len([r for r in rows if (r.get("discontinued") or "") == "Yes"]) + theirs_disc = O.get_odoo().search_count( + 'product.product', + [('active', '=', True), ('product_tag_ids', 'in', [tag_id])]) if tag_id else -1 + checks.append({ + "check": f"Discontinued SKUs == Odoo products carrying the {products.DISCONTINUED_TAG}" + f" tag (resolved by name, id {tag_id})", + "ours": ours_disc, "theirs": theirs_disc, + # Codes merge variants, so ours may be <= theirs; a tag that resolved to nothing is a + # hard red, because the column would read "nobody is discontinued" and look fine. + "ok": tag_id is not None and 0 < ours_disc <= theirs_disc, + "detail": {"tag_resolved": tag_id is not None, + "note": "ours counts SKU CODES, theirs counts product RECORDS; variants " + "sharing a code merge into one row, so ours <= theirs."}, + }) + + # ⭐ RULE 8 — inbound units against a SECOND, INDEPENDENTLY DERIVED source (open purchase + # order lines). ⛔ SHAPE AND DIRECTION, NOT EQUALITY: the two are in different units of + # measure on ~107 codes and asserting equality would go red forever on a correct + # difference. `products.incoming_from_po` documents the measurement. + po_units, po_report = products.incoming_from_po() + ours_inc = sum(float(r.get("incoming") or 0.0) for r in rows) + theirs_inc = sum(po_units.values()) + with_inbound = {r["code"] for r in rows if float(r.get("incoming") or 0.0) > 0} + po_codes = {c for c, v in po_units.items() if v > 0} + checks.append({ + "check": "Inbound units (Odoo incoming_qty) reconcile to open purchase order lines", + "ours": round(ours_inc, 1), "theirs": round(theirs_inc, 1), + # A truncated PO read makes the oracle itself untrustworthy, so it is a red. + "ok": (not po_report["truncated"]) and theirs_inc > 0 + and abs(ours_inc - theirs_inc) <= 0.10 * max(ours_inc, theirs_inc) + and len(with_inbound & po_codes) >= 0.85 * len(po_codes), + "detail": {"skus_with_inbound_ours": len(with_inbound), + "skus_with_inbound_po": len(po_codes), + "in_both": len(with_inbound & po_codes), + "po_lines": po_report, "uom_note": "purchase uom vs stock uom; the shipped " + "column is in the STOCK uom so it can be added to On hand."}, + }) + + # ⭐⭐ OWNER 2026-08-19 — `dos` COUNTS INBOUND, and this is the leg that proves it rather + # than trusting the expression. Two halves, because either alone is passable by accident: + # (a) EVERY row's dos re-derives from (on_hand + incoming) / (qty_ltm / 365), and + # (b) at least one row's dos is STRICTLY GREATER than the shelf-only figure would be. + # Without (b) the check stays green if `incoming` silently becomes 0 everywhere — the + # column would read exactly as it did before the ruling, and nothing would say so. + moved, wrong = 0, [] + for r in rows: + oh, inc, q = r.get("on_hand"), r.get("incoming") or 0.0, r.get("qty_ltm") + if oh is None or not isinstance(q, (int, float)) or q <= 0: + continue + want = round((float(oh) + float(inc)) / (float(q) / 365.0), 0) + if r.get("dos") != want: + wrong.append(r["code"]) + if inc > 0 and want > round(float(oh) / (float(q) / 365.0), 0): + moved += 1 + checks.append({ + "check": "Days of supply counts INBOUND units: every row re-derives from " + "(on_hand + incoming) / daily, and inbound demonstrably moves it", + "ours": moved, "theirs": len([r for r in rows if (r.get("incoming") or 0) > 0]), + "ok": not wrong and moved > 0, + "detail": {"rows_not_re_deriving": wrong[:10], "n_wrong": len(wrong), + "rows_where_inbound_raised_dos": moved, + "note": "a 0 in 'rows_where_inbound_raised_dos' means the column reads as " + "it did BEFORE the ruling, which is the silent-regression case."}, + }) + + # ⭐ RULE 8 — the FORWARD demand basis against a DIRECT Odoo read_group over the same + # reference window. ⛔ The oracle re-reads Odoo itself rather than re-calling + # `forward_demand_by_code`, which would reconcile the number with itself — the self-sealing + # shape this function already carries a scar for. + fwd_map, fwd_report = products.forward_demand_by_code(t=t, team_id=team_id) + _rf, _rt = fwd_report["reference_window"] + # ⚠ `include_excluded_partners=True` HERE TOO, or the oracle would measure a narrower + # universe than the column and go red on the Amazon units the owner asked to include. + direct = O.read_group('sale.order.line', + O.sale_line_domain(_rf, _rt, team_id, extra=products._NO_SVC, + all_channels=True), + ['product_uom_qty:sum'], [], lazy=False) + theirs_u = float((direct[0] or {}).get('product_uom_qty') or 0.0) if direct else 0.0 + # ⚠ A BRACKET, NOT AN EQUALITY, and the reason is in the number itself: the shipped total + # is the seasonal rows PLUS the LTM-fallback rows scaled onto the horizon, and the fallback + # rows are by definition SKUs that window never saw. Asserting equality would go red + # forever on a difference the design creates on purpose. + shipped = sum(float(r["demand_fwd"]) for r in rows + if isinstance(r.get("demand_fwd"), (int, float))) + checks.append({ + "check": f"Forward {products.FORWARD_MONTHS}-month demand basis: the shipped forecast " + f"ties to Odoo units over its own reference window {_rf} to {_rt}", + "ours": round(shipped, 0), "theirs": round(theirs_u, 0), + # The shipped total is seasonal rows PLUS scaled fallback rows, so it cannot equal the + # window total exactly; what must hold is that it is a real, bounded fraction of it and + # that the window itself returned units at all. + "ok": theirs_u > 0 and 0.5 * theirs_u <= shipped <= 1.5 * theirs_u, + "detail": {**fwd_report, + "skus_with_a_forecast": len([r for r in rows if r.get("demand_fwd")]), + "note": "shipped = seasonal rows + LTM-fallback rows scaled to the " + "horizon, so it brackets rather than equals the window total."}, + }) + checks.extend(validate_measures(t=t, team_id=team_id, + pool_codes={r["code"] for r in rows})) + checks.extend(validate_price_and_unit_cells(rows)) + checks.extend(validate_stock_measures(t=t)) + return checks + + +def validate_stock_measures(t=None, days=90, sample=4): + """⭐⭐ W37-T13 — `stock_in` / `stock_out` per SKU, against a DIRECT live Odoo `read_group`. + + ⛔ THE TWO-SIDED DOMAIN IS REPRODUCED ON THE LIVE SIDE, and getting it wrong there would hide + exactly the defect this validates. `location_id.usage` is a DOT-PATH FILTER, which Odoo + supports; a dot-path GROUPBY faults, which is why the direction is expressed as two separate + filtered reads rather than one grouped-by-usage read (`proto/P1-stock-moves.md`, gotcha 1). + + ⛔ AND THE DIRECTION SPLIT IS ASSERTED, NOT ASSUMED. A one-sided domain produces IN == OUT for + every internal transfer, so a run where the two columns agree everywhere is the signature of + the bug rather than of a quiet warehouse. The last leg requires a SKU where they genuinely + differ — the ticket's own `done-when` clause, and the only one a total cannot fake. + """ + from harness import datastore as DS + from harness import semantic as sem + + t = t or P.today() + checks = [] + offer = sem.entity_measures("odoo_products") + keys = [m["key"] for m in offer if m["key"].startswith("stock_")] + if not keys: + why = [r for r in sem.entity_measure_refusals("odoo_products") + if str(r.get("key", "")).startswith("stock_")] + return [{"check": "the product catalogue offers Stock moved in / out (W37-T13)", + "ours": 0, "theirs": 3, "ok": False, + # ⭐ The refusal carries its own cause — reported, not inferred from an absence. + "detail": {"refusals": why or "no stock binding declared"}}] + checks.append({"check": "the product measure catalogue offers the stock-movement keys " + "(W37-T13)", "ours": sorted(keys), "theirs": 3, + "ok": {"stock_in", "stock_out"} <= set(keys)}) + + con = DS.ro_cursor() + try: + newest = con.execute("SELECT max(date) FROM stock_move").fetchone() + finally: + con.close() + d_to = t - _dt.timedelta(days=2) + if newest and newest[0]: + try: + d_to = min(d_to, _dt.date.fromisoformat(str(newest[0])[:10]) - _dt.timedelta(days=1)) + except ValueError: + pass + d_from = d_to - _dt.timedelta(days=days) + DF, DT = d_from.isoformat(), d_to.isoformat() + ours = sem.entity_measure_values("odoo_products", ["stock_in", "stock_out"], + date_from=DF, date_to=DT, offer=offer) + + o = O.get_odoo() + base = [("state", "=", "done"), + ("date", ">=", f"{DF} 00:00:00"), ("date", "<=", f"{DT} 23:59:59")] + IN = base + [("location_dest_id.usage", "=", "internal"), + ("location_id.usage", "!=", "internal")] + OUT = base + [("location_id.usage", "=", "internal"), + ("location_dest_id.usage", "!=", "internal")] + live = {} + for dom, side in ((IN, "stock_in"), (OUT, "stock_out")): + for r in o.read_group("stock.move", dom, ["product_id", "quantity_done:sum"], + ["product_id"], lazy=False): + if not r.get("product_id"): + continue + live.setdefault(r["product_id"][0], {})[side] = r["quantity_done"] + code_of = _codes_of_odoo_products(o, list(live)) + by_code = {} + for pid_, v in live.items(): + c = code_of.get(pid_, f"pid:{pid_}") + d = by_code.setdefault(c, {"stock_in": 0.0, "stock_out": 0.0}) + for k in ("stock_in", "stock_out"): + d[k] += v.get(k, 0.0) + + for side in ("stock_in", "stock_out"): + a = round(sum(c.get(side, 0) for c in ours.values()), 2) + b = round(sum(c.get(side, 0) for c in by_code.values()), 2) + checks.append({ + "check": f"{side}: the mirror's per-SKU total vs a DIRECT Odoo read_group under the " + f"SAME two-sided location domain", + "ours": a, "theirs": b, "gap": round(a - b, 2), + "ok": bool(b) and abs(a - b) <= 0.02 * b, + "detail": {"window": [DF, DT], "skus_ours": len(ours), "skus_odoo": len(by_code)}}) + + # ⛔ THE NAMED SKU, and it is chosen for DIFFERING — see the docstring. + diff = sorted(((abs((c.get("stock_in") or 0) - (c.get("stock_out") or 0)), k) + for k, c in ours.items()), reverse=True)[:sample] + named = [] + for _d, k in diff: + named.append({"sku": k, + "ours": {s: round(ours[k].get(s, 0), 2) for s in ("stock_in", "stock_out")}, + "odoo": {s: round((by_code.get(k) or {}).get(s, 0), 2) + for s in ("stock_in", "stock_out")}}) + off = [n for n in named + if any(abs(n["ours"][s] - n["odoo"][s]) > max(0.01, 0.02 * (n["odoo"][s] or 1)) + for s in ("stock_in", "stock_out"))] + checks.append({ + "check": f"each NAMED SKU's in/out ties to Odoo ({len(named)} SKUs, picked for the " + f"largest in-vs-out difference)", + "ours": len(off), "theirs": 0, "ok": not off, + "detail": {"named": named[:3], "mismatched": off[:2]}}) + genuinely_split = [n for n in named if n["ours"]["stock_in"] != n["ours"]["stock_out"]] + checks.append({ + "check": "⛔ the DIRECTION SPLIT is real: at least one SKU where IN and OUT genuinely " + "differ. A one-sided domain makes them equal for every internal transfer, so " + "all-equal is the SIGNATURE OF THE BUG, not a quiet warehouse", + "ours": len(genuinely_split), "theirs": ">=1", "ok": bool(genuinely_split), + "detail": {"example": genuinely_split[0] if genuinely_split else None}}) + return checks + + +def validate_price_and_unit_cells(rows, sample=6): + """⭐⭐ W37-T14 / T15 — the `Tier prices` and `Units` cells, against a FRESH Odoo read. + + ⛔ THE ORACLE IS ASKED PER SKU, not in bulk, and deliberately so: the builders group a + bulk read in Python, so re-running the same bulk read would re-run the same grouping and + could only ever agree with itself. Asking Odoo for ONE SKU's price rules is a different + question shape and can actually disagree ([[no-unverifiable-aggregates]]). + + ⚠ WHAT IS NOT PROVEN HERE, said rather than implied: that a PERSON sees the cell. These are + `json` columns on the product grid and the render is the client's; the data half is what a + module `validate()` can reach. + """ + checks = [] + priced = [r for r in rows if r.get("tier_prices")] + united = [r for r in rows if r.get("units")] + checks.append({ + "check": "the product grid serves a Tier-prices cell (W37-T14) and a Units cell (T15); " + "a DECLARED column that is never filled is the defect these replace", + "ours": {"with_tier_prices": len(priced), "with_units": len(united), "rows": len(rows)}, + "theirs": ">0 each", + # ⚠ Units are legitimately sparse (19.6% measured), so the floor is existence, not a rate. + "ok": bool(priced) and bool(united), + "detail": {"multi_price_skus": sum(1 for r in priced + if len(json.loads(r["tier_prices"])) > 1)}, + }) + if not priced: + return checks + o = O.get_odoo() + pls = {p["id"]: str(p.get("name") or "").strip() + for p in O.search_read("product.pricelist", [], ["id", "name"])} + today = P.today().isoformat() + # Prefer SKUs that carry MORE THAN ONE price — the ticket's own subject. + cand = sorted(priced, key=lambda r: -len(json.loads(r["tier_prices"])))[:sample] + bad = [] + for r in cand: + mine = sorted((t["pricelist"], round(float(t["unit_price"]), 2)) + for t in json.loads(r["tier_prices"])) + pid = r.get("product_id") + tmpl = None + if pid: + rec = o.search_read("product.product", [("id", "=", pid)], ["product_tmpl_id"]) + tmpl = O.m2o_id(rec[0].get("product_tmpl_id")) if rec else None + dom = [("compute_price", "=", "fixed"), + "|", ("date_start", "=", False), ("date_start", "<=", today), + "|", ("date_end", "=", False), ("date_end", ">=", today), + ("fixed_price", ">", 0), + "|", ("product_id", "=", pid), ("product_tmpl_id", "=", tmpl)] + live = o.search_read("product.pricelist.item", dom, + ["pricelist_id", "fixed_price", "min_quantity", "applied_on"]) + best = {} + for it in live: + nm = pls.get(O.m2o_id(it.get("pricelist_id")), "?") + q = it.get("min_quantity") or 0.0 + if nm not in best or q < best[nm][0]: + best[nm] = (q, round(it.get("fixed_price") or 0.0, 2)) + theirs = sorted((nm, v) for nm, (q, v) in best.items()) + # ⚠ A code carried by TWO active products legitimately holds MORE entries than a single + # product's rules (D-309 / `2112-12`), so ours is a SUPERSET, never an equality. + if not set(theirs) <= set(mine): + bad.append({"sku": r.get("code"), "ours": mine, "odoo": theirs}) + checks.append({ + "check": f"each sampled SKU's Tier prices contain every live Odoo price for it " + f"({len(cand)} SKUs, chosen for having the MOST prices)", + "ours": len(bad), "theirs": 0, "ok": not bad, + "detail": {"mismatches": bad[:3], + "sampled": [r.get("code") for r in cand]}, + }) + return checks + + +def _codes_of_odoo_products(o, ids): + """`{odoo product id: the identity key the GRID uses}` — `default_code`, or `pid:`. + + ⚠ BATCHED, 500 at a time. One call per id is ~1,700 XML-RPC round trips on this window and + turns a 5-second reconciliation into a coffee break. + ⚠ `active in [True, False]`: a re-SKUed line points at the ARCHIVED record and the grid + merges it under the surviving code, so an active-only read would key it `pid:` and + manufacture a mismatch this check would then report as a defect. + """ + ids = sorted({i for i in ids if i}) + out = {} + for i in range(0, len(ids), 500): + for p in o.search_read('product.product', + [('id', 'in', ids[i:i + 500]), ('active', 'in', [True, False])], + ['default_code']): + out[p['id']] = p.get('default_code') or f"pid:{p['id']}" + return {i: out.get(i, f"pid:{i}") for i in ids} + + +def validate_measures(t=None, team_id=None, days=90, pool_codes=None): + """⭐⭐ W37-T10 — THE MINTED LOOKBACK MEASURES, against a DIRECT Odoo `read_group`. + + Standing rule 8: a number that does not tie to Odoo does not ship. These columns are minted + from the tenant MIRROR (`semantic.entity_measure_values`), so the oracle has to be the live + ERP and nothing derived from the mirror — otherwise both sides come from the same place and + the check cannot fail, which is the self-sealing shape `validate()`'s own header records + costing a wave. + + ⛔⛔ THE MIRROR IS BEHIND LIVE, ALWAYS, AND THAT IS NOT A DEFECT — so a bare equality here + would be RED every day and would teach everyone to ignore it. The reconciliation is therefore + two-legged, and the second leg is the one that carries the meaning: + + leg 1 totals agree within the lag, and the lag is REPORTED as a number, not a tolerance; + leg 2 ⭐ EVERY line-level difference traces to a line ODOO WROTE AFTER THE MIRROR'S OWN + WATERMARK. This is what makes the check falsifiable: a join bug produces differences + on lines the mirror holds perfectly, and leg 2 goes red on the first one. + + ⚠ Do NOT "fix" leg 2 by filtering the live side on `write_date <= watermark` and comparing + totals — MEASURED 2026-08-19, that is a far worse instrument: confirming an order touches its + lines' `write_date` without changing a value, so the filter drops thousands of lines the + mirror holds correctly and the gap grows from $425 to $140,030. + """ + from harness import datastore as DS + from harness import semantic as sem + + t = t or P.today() + checks = [] + try: + if not DS.ready(): + return [{"check": "product lookback measures reconcile to Odoo", + "ours": "no mirror", "theirs": "-", "ok": False, + "detail": "the tenant store is not readable, so the measures are UNPROVEN, " + "and an unproven aggregate is exactly what standing rule 8 bars"}] + except Exception as e: # noqa: BLE001 + return [{"check": "product lookback measures reconcile to Odoo", + "ours": f"{type(e).__name__}", "theirs": "-", "ok": False, "detail": str(e)[:200]}] + + # ⛔ THE WINDOW END COMES FROM THE MIRROR, NOT FROM `today`, and this was measured the wrong + # way round first. A window ending today reaches past what the mirror has ever seen: orders + # placed since the last sync exist live and NOWHERE in the store, so the totals leg reported + # a 2.69% "lag" that was really "the last three days do not exist here yet". Anchoring on the + # mirror's own newest order makes the comparison one about EDITS to a shared period — which + # is the only difference that could indicate a join bug. + con = DS.ro_cursor() + try: + _newest = con.execute("SELECT max(date_order) FROM sale_order").fetchone() + finally: + con.close() + d_to = t - _dt.timedelta(days=2) + if _newest and _newest[0]: + _n = str(_newest[0])[:10] + try: + # one day INSIDE the mirror's newest order: the final day may be half-synced. + d_to = min(d_to, _dt.date.fromisoformat(_n) - _dt.timedelta(days=1)) + except ValueError: + pass + d_from = d_to - _dt.timedelta(days=days) + DF, DT = d_from.isoformat(), d_to.isoformat() + + offer = sem.entity_measures("odoo_products") + checks.append({ + "check": "the product measure OFFER is non-empty and every key it names resolves in the " + "semantic model (owner item 4 / R1)", + "ours": len(offer), "theirs": ">0", "ok": len(offer) > 0, + "detail": {"keys": [m["key"] for m in offer], + # ⭐ The REPORTING half of standing rule 1: a declared key that dropped out + # says why, rather than being quietly absent from a list nobody diffs. + "refused": sem.entity_measure_refusals("odoo_products")}, + }) + if not offer: + return checks + missing_family = [m["key"] for m in offer if m.get("empty") not in ("zero", "blank")] + checks.append({ + "check": "every offered measure declares an EMPTY-WINDOW family (C1: additive->0, " + "ratio->blank), because 72% of this catalogue has no group in a 90-day window", + "ours": len(missing_family), "theirs": 0, "ok": not missing_family, + "detail": {"undeclared": missing_family}, + }) + + store = sem.entity_measure_values("odoo_products", ["revenue", "units", "margin"], + date_from=DF, date_to=DT, exclude_services=False, + offer=offer) + o = O.get_odoo() + # ⛔ ASKED OF ODOO DIRECTLY, grouped by Odoo's OWN product id — deliberately NOT by the SKU + # code the mirror joins on, so the oracle cannot inherit our join key. + g = o.read_group('sale.order.line', O.sale_line_domain(DF, DT), + ['product_id', 'price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'], + ['product_id'], lazy=False) + live_tot = {"revenue": round(sum(r['price_subtotal'] for r in g), 2), + "units": round(sum(r['product_uom_qty'] for r in g), 2), + "margin": round(sum(r['margin'] for r in g), 2)} + ours_tot = {k: round(sum(v.get(k, 0) for v in store.values()), 2) + for k in ("revenue", "units", "margin")} + + # leg 2 — the falsifiable one. Every discrepant LINE must post-date the mirror's watermark. + con = DS.ro_cursor() + try: + wm = con.execute("SELECT cursor_wd FROM _sync_state WHERE entity = 'sale_order_line'" + ).fetchone() + wm = wm[0] if wm else None + rows = con.execute( + "SELECT l.id, l.price_subtotal FROM sale_order_line l " + "JOIN sale_order o ON o.id = l.order_id " + "WHERE o.state IN ('sale','done') AND o.team_id IN (5,6) " + " AND l.product_id IS NOT NULL " + " AND l.order_partner_id NOT IN " + " (SELECT id FROM res_partner WHERE name LIKE 'GIFTWARE%') " + " AND CAST(o.date_order AS TIMESTAMP) >= ? AND CAST(o.date_order AS TIMESTAMP) <= ?", + [f"{DF} 00:00:00", f"{DT} 23:59:59"]).fetchall() + finally: + con.close() + mine = {r[0]: (r[1] or 0.0) for r in rows} + theirs = {r['id']: (r['price_subtotal'] or 0.0) for r in + o.search_read('sale.order.line', O.sale_line_domain(DF, DT), + ['id', 'price_subtotal', 'write_date'])} + wd = {r['id']: str(r['write_date']) for r in + o.search_read('sale.order.line', O.sale_line_domain(DF, DT), ['id', 'write_date'])} + discrepant = [i for i, v in theirs.items() + if i not in mine or abs(mine[i] - v) >= 0.005] + unexplained = [i for i in discrepant if not wm or wd.get(i, '') <= str(wm)] + checks.append({ + "check": "every per-SKU measure difference vs live Odoo traces to a line Odoo wrote " + "AFTER the mirror's watermark, because a join bug would differ on a mirrored line", + "ours": len(unexplained), "theirs": 0, "ok": not unexplained, + "detail": {"lines_compared": len(theirs), "discrepant": len(discrepant), + "explained_by_mirror_lag": len(discrepant) - len(unexplained), + "watermark": str(wm), "window": [DF, DT], + "unexplained_line_ids": unexplained[:10]}, + }) + # ⛔⛔ THE LEG THAT CATCHES A WRONG JOIN KEY, and neither leg above can. Both of those sum the + # same LINES whatever dim they were grouped by, so swapping `dim: product_code` for + # `dim: product` (contract C1's trap, an Odoo id where the grid carries a SKU code) leaves + # them both green while every cell on the screen goes blank. This one asks: does the answer + # arrive under a key a GRID ROW ACTUALLY HAS, and is the value right FOR THAT SKU? + code_of = _codes_of_odoo_products(o, [r['product_id'][0] for r in g if r.get('product_id')]) + by_code = {} + for r in g: + if not r.get('product_id'): + continue + c = code_of.get(r['product_id'][0], f"pid:{r['product_id'][0]}") + by_code[c] = by_code.get(c, 0.0) + r['price_subtotal'] + # ⚠ The pool is the CALLER'S when it has one (it has already paid for it), and read fresh + # otherwise — `validate_measures` is runnable on its own, and a leg that silently skips when + # called directly is a leg nobody runs ([[gate-can-report-green-on-nothing]]). + pool_codes = set(pool_codes) if pool_codes is not None else { + r["code"] for r in pool(team_id=team_id, t=t)} + keyed_to_a_row = [c for c in store if c in pool_codes] + top = sorted(store.items(), key=lambda kv: -(kv[1].get("revenue") or 0))[:10] + spot = [{"sku": c, + "ours": round(v.get("revenue") or 0.0, 2), + "odoo": round(by_code.get(c, 0.0), 2)} for c, v in top] + # A named SKU may legitimately differ by a line Odoo edited after the watermark; the + # assertion is that MOST of the top ten tie exactly and NONE is off by an order of magnitude, + # which is what a mis-keyed join looks like (0.00 against a five-figure number). + exact = sum(1 for s in spot if abs(s["ours"] - s["odoo"]) < 0.005) + checks.append({ + "check": "the measure answer is KEYED TO THE GRID'S OWN IDENTITY (C1's join-key trap): " + "every group key is a SKU code a pool row carries, and the top-10 SKUs' revenue " + "ties to Odoo for THAT SKU", + "ours": {"keys_matching_a_pool_row": len(keyed_to_a_row), "of": len(store), + "top10_exact": exact}, + "theirs": {"keys_matching_a_pool_row": len(store), "of": len(store), "top10_exact": 10}, + # ⚠ Not `== len(store)`: a code whose ONLY product record is archived legitimately has + # revenue and no grid row (R12 keeps archived out), which `validate()`'s own + # "outside the grid" leg already reconciles. A mis-keyed join lands at ~0, not at 99%. + "ok": len(store) > 0 and len(keyed_to_a_row) / len(store) > 0.95 and exact >= 8, + "detail": {"spot_checks": spot, + "keys_with_no_pool_row": sorted(set(store) - pool_codes)[:10]}, + }) + checks.append({ + "check": "product lookback totals vs a DIRECT Odoo read_group, where the residual is mirror " + "lag and is REPORTED as a figure, never absorbed into a tolerance", + "ours": ours_tot, "theirs": live_tot, + # The assertion is on leg 2; this leg is red only if the lag is implausibly large, which + # is the shape that means "the mirror stopped" rather than "the mirror is a day behind". + "ok": abs(ours_tot["revenue"] - live_tot["revenue"]) <= max( + 0.01, live_tot["revenue"] * 0.02), + "detail": {"revenue_lag": round(ours_tot["revenue"] - live_tot["revenue"], 2), + "revenue_lag_pct": round( + (ours_tot["revenue"] - live_tot["revenue"]) / live_tot["revenue"] * 100, 4) + if live_tot["revenue"] else None, + "skus_in_grid_answer": len(store)}, + }) + return checks