File size: 21,499 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
bf8519f
 
e1b3e71
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91de770
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
"""routes_products.py β€” the PRODUCT table's read seam (wave 15 item 9/10, contract C-TOPIC).

The proof that C-TOPIC's bet is real: **a second table object is a pool module plus a route, not
a second component tree**. Everything structural here is borrowed rather than re-implemented β€”
the permission wall (`core.perm_scope`), the row builder (`aios_grid.rows_from_pool`), the
scope-keyed stale-while-refresh cache (`scope_cache`), the error shape, the module gate.

Landed nav-less in wave 15 ON PURPOSE (no registry row, so no user could navigate to a
half-built surface while the server half hardened); wave 16 shipped the `product_data`
registry row, the client `topic` prop and the write path, so the surface has been REACHABLE
from the nav since β€” this header stayed stale until 2026-08-04 (the wave-18 debt sweep) and
cost readers a false "you can't get there from here".

Wave 16 (C-TOPIC's second half): the WRITE PATH exists now, and it is exactly what the wave-15
header demanded before one could β€” its OWN table workspace bucket
(`modules.product_data.TABLE_OPS`, store key 'product_table_workspace') and its own event
validation (the events route builds the ctx over the PRODUCT field contract + product pids +
the product ops). β›” The separate bucket is load-bearing, not tidy: product pids are CRC32
hashes and customer pids are Odoo partner ids β€” one shared overlay bucket and a hash collision
silently writes a product note onto somebody's customer.
"""
import time

from fastapi import Body, Depends

from fastapi import APIRouter

import scope_cache
from deps import Session, err, module_gate

router = APIRouter(prefix="/api/v1")

#: The registry key this surface WILL carry. The gate is live now even though the nav is not, so
#: the day the registry row appears the wall is already the one that was tested.
MODULE = "product_data"

_CACHE_TTL = 900


def _pool_for(rt, team_id):
    """The cached SKU pool for one scope. Same stale-while-refresh discipline as the customer
    pool: only a scope's FIRST-ever build blocks, and the cache key is the SCOPE, never the user
    β€” the customer route learned that the hard way (a per-user payload cached on a scope key
    served one user's private columns to another)."""
    import modules.product_data as pd

    # ⚠ Signature is (cache, key, ttl, build) β€” the wave-15 version passed `ttl=` as a keyword
    # after the build lambda and DIED on every call ("multiple values for 'ttl'"). Latent until
    # wave 16 because nothing drove this route end-to-end: verify_perm_scope's section H stubs
    # the pool a layer below. verify_api's W16 section now exercises the real call.
    key = ("product_pool", team_id)

    # ⭐ D-10 (wave 24) β€” THE PRODUCT POOL HONOURS THE CONNECTOR PAUSE. It did not, and that was
    # a shipped defect on a live surface: the CUSTOMER pool got this guard at DEBT-2 and its
    # sibling β€” written from the same template, four files away β€” never did. So pausing Odoo
    # froze the Customer grid and the Product grid kept reading live, with the Settings toggle
    # reporting success. Sibling caches diverge exactly this way and nothing greps for
    # "the other one".
    #
    # ⚠ NO SNAPSHOT LEG, and that is the honest difference from the customer path rather than an
    # omission: `save_pool_snapshots` persists the `odoo_pool` scopes only β€” there is no product
    # snapshot bucket to serve. So this serves the in-process copy AT ANY AGE, and answers 503
    # naming the pause when there is none. Minting a product snapshot here would be a new
    # persistence format at a wave tail; refusing with a sentence is the answer the user can act
    # on, and it is the same 503 the customer path already gives for an unsnapshotted scope.
    # ⭐ WAVE 29 / R12 β€” THE POOL IS THE CATALOGUE NOW (5,875 active products, not the 2,717 that
    # happened to sell in a revenue window). Two consequences that land HERE rather than in
    # `product_data`:
    #   Β· the 503 below now also covers a TRUNCATED catalogue read. `modules.product_data.
    #     _catalogue_by_code` RAISES instead of degrading, precisely so a short pull arrives as a
    #     refusal a user can act on rather than as a plausible smaller grid nobody questions;
    #     `pool_unavailable` names the cause in its message.
    #   Β· the first build for a scope got slower (MEASURED 2026-08-11: ~44 s consolidated, of
    #     which the catalogue read is ~3 s; a BU-scoped build is ~9 s). Only a scope's FIRST-ever
    #     build blocks β€” the stale-while-refresh discipline above is what keeps that off the
    #     request path afterwards.
    import routes_keychain
    if routes_keychain.odoo_paused(rt):
        hit = rt.pool_cache.get(key)
        if hit:
            return hit[1]
        raise err(503, "connector_paused",
                  "this data source is paused and nothing has been read since β€” "
                  "an admin can resume it under Settings β†’ Connectors")

    return scope_cache.get(rt.pool_cache, key, _CACHE_TTL,
                           lambda: pd.pool(team_id=team_id))


def scoped_pool(session: Session):
    """`(pids, team_id, rows_src, fields_base)` β€” THE PRODUCT WALL, on its own.

    Extracted from `product_assembly` (wave 19, item 12) so a caller that needs only "which
    products may this session touch" β€” record comments, say β€” asks the SAME question in the same
    order rather than re-deriving it: pool scope from the permanent filter, rows, THEN the row
    wall, THEN the pids. Re-deriving it is how a second wall drifts from the first, and the
    walls are the whole point of this route.

    ⚠ The caller is responsible for the GRANT (`session.require(MODULE)`); this is the row half.
    """
    import core.perm_scope as perm_scope

    team_id, _agent = perm_scope.derive_pool_scope(session.user, MODULE)
    try:
        rows_src = _pool_for(session.runtime, team_id)
    except Exception as e:
        raise err(503, "pool_unavailable",
                  f"the product catalogue could not be built β€” {str(e)[:160]}")
    fields_base = pd_fields(consolidated=team_id is None)
    # The SAME wall the customer assembly applies, in the same order: rows first (before pids
    # are taken, so an out-of-filter row never enters allowed_pids), then the field closure,
    # then the values stripped from the rows as well as the field list.
    rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, fields_base)
    pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
    return pids, team_id, rows_src, fields_base


def product_assembly(session: Session, scope: str = "product", storage_key: str = "",
                     consume_corrections: bool = True):
    """The product topic's mirror of `routes_customers.grid_assembly` β€” SAME g-dict keys, so
    the /workspace and events routes consume either interchangeably.

    One deliberate absence, a topic fact rather than a gap:
      * `measures`/`measure_sets` are EMPTY β€” `core.measure_resolve` is CUSTOMER-grain (the
        C-TOPIC v1 descope, booked in the wave doc); the events ctx therefore refuses measure
        creates on this surface, which is the correct fail-closed shape.

    ⭐ WAVE 19 / R9 β€” `lists` IS NO LONGER EMPTY. Wave 16 passed `with_cohorts=False` because
    there was one customer-keyed cohort bucket and product pids are CRC32 hashes of SKU codes;
    intersecting the two id spaces would have printed a plausible, meaningless member count. The
    owner's ruling is that a cohort belongs to its database, so `modules.cohort` grew a bucket
    per topic and the product surface reads `product_cohorts` β€” ids from this pool, resolved
    against this pool. `derived` carries their membership cells (the Cohorts column) for the same
    reason it does on the customer surface; the measure half of that channel stays empty.
    """
    import aios_grid
    import core.perm_scope as perm_scope
    import modules.product_data as pd
    from core import grid_events

    pids, team_id, rows_src, fields_base = scoped_pool(session)

    ctx = grid_events.EventCtx(
        uname=session.uname, allowed_pids=pids, fields=[],
        hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base),
        admin=session.admin, fallback_ws=None, seen_ids={},
        scope_key="product", table=pd.TABLE_OPS)
    ws = grid_events.table_workspace(ctx, allowed_pids=pids,
                                     consume_corrections=consume_corrections)
    # ⭐⭐ W30-T36 β€” THE TENANT-WIDE STRATUM, MERGED OVER THE PER-USER ONE, IN THE ASSEMBLY SO
    # EVERY CONSUMER SEES ONE TRUTH. `routes_grid`'s /workspace route serves
    # `workspace["overlays"] = g["ws"].get("overlays")` verbatim, so doing it here means that door
    # carries shared values with no change in a file this lane does not own.
    #
    # ⚠ SAFE TO MUTATE, and that was checked rather than assumed: `table_workspace` reads through
    # `store.get`, which deep-copies, so `ws` is a detached copy and nothing writes it back. A
    # shared value can therefore never leak INTO the per-user bucket by way of this merge.
    # ⚠ SHARED WINS per key β€” these are contract columns, so the tenant-wide value is the answer
    # and any per-user leftover under the same key is stale by construction.
    _shared = pd.shared_cells(pids)
    if _shared:
        _ov = dict(ws.get("overlays") or {})
        for _pid, _cells in _shared.items():
            _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
        ws["overlays"] = _ov
    workspace, fields, views, lists = aios_grid.workspace_wire(
        ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
        fields_base=fields_base)

    hidden = perm_scope.hidden_keys(session.user, MODULE, fields)
    if hidden:
        fields = [f for f in fields if f.get("key") not in hidden]
        rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]

    return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
            "fields": fields, "views": views, "lists": lists,
            # R9: the Cohorts column's cells, built from THIS topic's lists. Same read-only
            # `derived` channel the customer assembly uses β€” the measure half stays empty.
            "derived": aios_grid.cohort_cells(lists),
            "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
            "team_id": team_id}


@router.get("/products")
def products(session: Session = Depends(module_gate(MODULE))):
    """The product table for this session's scope β€” the /customers envelope byte-for-byte
    (`{fields, rows, today, pulled_at}`) plus two additive keys (`identity`, `scope`).

    The BU scope is DERIVED FROM THE PERMANENT FILTER (`perm_scope.derive_pool_scope`), exactly
    as the customer route derives it β€” and for the same reason, which is worth restating because
    it is the wave's central lesson: `team_id` shapes the revenue VALUES on each row, so a BU
    enforced as a post-filter yields a correct row list carrying both units' numbers.
    """
    import aios_grid

    g = product_assembly(session)
    rows = aios_grid.rows_from_pool(
        g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
    rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
    return {"fields": g["fields"], "rows": rows,
            "today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
            "identity": {"pid": "pid", "businessKey": "code"},
            "scope": {"team_id": g["team_id"], "consolidated": g["team_id"] is None}}


@router.patch("/products/{pid}")
def patch_product(pid: int, body: dict = Body(default=None),
                  session: Session = Depends(module_gate(MODULE))):
    """Write the product table's EDITABLE overlay stratum β€” the customers PATCH, on the product
    topic's ctx. Routed through `core.grid_events.handle_one` so the per-key `permissions.edit`
    wall, the pid wall and the truncation rules stay ONE implementation; the ctx's `table` ops
    aim the write at the PRODUCT bucket."""
    import modules.product_data as pd
    from core import grid_events

    updates = dict(body or {})
    if not updates:
        raise err(400, "empty_patch", "no fields to update")
    g = product_assembly(session, consume_corrections=False)
    if pid not in g["pids"]:
        # 403, not 404 β€” the code may exist; it is simply not in this session's catalogue.
        raise err(403, "out_of_scope", "that product is not in your catalogue")
    import core.perm_scope as perm_scope

    ctx = grid_events.EventCtx(
        uname=session.uname, allowed_pids=g["pids"], fields=g["fields"],
        admin=session.admin, fallback_ws=None, seen_ids={},
        hidden_keys=perm_scope.hidden_keys(
            session.user, MODULE, pd_fields(consolidated=g["team_id"] is None)),
        scope_key="product", table=pd.TABLE_OPS)
    try:
        grid_events.handle_one(
            {"id": f"patch:product:{pid}:{time.time_ns()}", "type": "overlay_patch",
             "pid": pid, "updates": updates}, ctx)
    except grid_events.StoreUnavailable:
        raise err(503, "store_unavailable",
                  "the tenant store is unavailable β€” your change was not saved")

    # What actually landed, read back from the PRODUCT bucket rather than echoed from the
    # request: a refused key or a truncated value must not be reported as accepted.
    # ⚠ W30-T36 β€” AND FROM THE SHARED BUCKET TOO. The four shared columns are written to
    # `<TABLE_KEY>__shared` by `_ProductTableStore.patch_overlay`, so a read-back that consulted
    # only the per-user stratum would find nothing and report every accepted shared write as
    # `refused` β€” a correct write reported as a failure, which is the read-path-cannot-witness-
    # the-write-path shape ([[read-path-cannot-witness-write-path]]).
    stored = (grid_events.table_workspace(ctx, allowed_pids=None)
              .get("overlays") or {}).get(str(pid)) or {}
    stored = {**stored, **((pd.shared_cells([pid]).get(str(pid))) or {})}
    accepted = {k: stored.get(k) for k in updates if k in stored}
    refused = sorted(k for k in updates
                     if k not in accepted or stored.get(k) != str(updates[k]))
    out = {"pid": pid, "updates": accepted}
    if refused:
        out["refused"] = refused
    return out


#: ⭐ WAVE 19 R7 β€” the RI Product table's built-in picture column. `source: 'overlay'` (editable,
#: so a user can upload a different shot for one SKU) with its DEFAULT supplied per render from
#: the row's `code` β€” see `_seed_image`. A stamped overlay value would have been the other way to
#: do it and it is the wrong one twice over: it is a 1,142-row migration that has to be right
#: once, and it FREEZES the code into the cell, so a re-coded SKU would keep pointing at the old
#: master with nothing saying why.
PRODUCT_IMAGE_KEY = "image"


def _seed_shared(rows, rows_src, fields):
    """W30-T36 β€” the supplier master as a per-render DEFAULT for the four SHARED columns.

    Exactly `_seed_image`'s shape and for the same reasons: no migration, and nothing frozen. An
    untouched grid renders byte-identically to the read-only version it replaces β€” including
    `supplier: "(none)"`, which the pool has always stamped β€” and **empty means "no override"**,
    so clearing a cell restores the mastersheet value rather than blanking the column.

    β›”β›” GATED PER KEY ON THE SERVED FIELD LIST, AND HERE THAT IS A PERMISSION WALL RATHER THAN
    TIDINESS. `_seed_image`'s docstring predicted this failure in writing β€” *"An unconditional seed
    would put the key straight back onto every row AFTER that strip… the value here is the visible
    `code`, so nothing new escapes today; the contract is the point, and a future non-code default
    would escape."* **`first_cost` IS that future non-code default**: a money number, and
    `product_assembly` strips a hidden field from BOTH wires precisely so a restricted reader
    cannot read it off the row payload. An ungated seed would re-attach it after the strip.
    (Belt and braces, deliberately: `perm_scope.strip_row` has already removed the key from
    `rows_src` too, so the pool row cannot supply it either.)

    ⚠ JOINED ON `pid`, never on position: `rows_from_pool` iterates the pool, but nothing in the
    contract promises the two lists stay index-aligned, and an off-by-one here would put one SKU's
    supplier on another SKU's row β€” a wrong answer that looks entirely plausible.
    """
    import modules.product_data as pd

    served = {f.get("key") for f in (fields or ())}
    keys = [k for k in pd.SHARED_KEYS() if k in served]
    if not keys:
        return rows
    by_pid = {r.get("pid"): r for r in (rows_src or ())}
    for row in rows:
        src = by_pid.get(row.get("pid")) or {}
        for key in keys:
            # A non-empty stored value WINS; only an empty cell is seeded. Blank is therefore
            # "no override" rather than "blank", which is the documented meaning `_seed_image`
            # already gives this column family and the only one that keeps the master reachable
            # after a mistaken edit.
            if row.get(key) in (None, ""):
                row[key] = src.get(key)
    return rows


def _seed_image(rows, fields):
    """R7's "auto-seeded from SKU `code`", as a per-render DEFAULT rather than stored data.

    Royal's 1,142 masters are named for `default_code`, so an untouched product row already names
    its own picture; this is what makes them appear with nothing uploaded. A user's own value is
    NON-EMPTY and therefore wins β€” the fallback only fills a cell nobody has set.

    ⚠ Clearing the cell restores the SKU's own picture rather than blanking it, and that is the
    documented meaning of empty on this column ("no override"). A product with no master on file
    still shows an empty frame, because the reference resolves to a 404 β€” the honest outcome, and
    the record modal names the failing reference in words.

    β›” GATED ON THE SERVED FIELD LIST, and that is C-PERM, not tidiness. `product_assembly` strips
    a hidden field from BOTH wires β€” the field list and the row payload β€” because narrowing only
    the first leaves the value sitting in the second where anything can read it. An unconditional
    seed would put the key straight back onto every row AFTER that strip, re-creating exactly the
    shape the rule forbids. (The value here is the visible `code`, so nothing new escapes today;
    the contract is the point, and a future non-code default would escape.)

    ⚠ STATED CONSEQUENCE: hiding `code` blanks this column, because the reference IS the code.
    That coupling is inherent to seeding from a business key, not a bug β€” and it fails in the safe
    direction (an empty frame, never another row's picture).
    """
    if not any(f.get("key") == PRODUCT_IMAGE_KEY for f in fields or ()):
        return rows
    for row in rows:
        if not row.get(PRODUCT_IMAGE_KEY):
            row[PRODUCT_IMAGE_KEY] = row.get("code") or ""
    return rows


def pd_fields(consolidated=True):
    """The product field contract, minus `product_data.CONSOLIDATED_ONLY` when the caller is scoped.

    ⭐ OWNER RULING 2026-08-11 EMPTIED THAT TUPLE, so today both callers receive the SAME columns
    and this narrowing is a no-op that is kept, not deleted. Previously a BU-scoped caller lost the
    whole inventory block, on the grounds that company-wide stock must not sit beside BU-shaped
    revenue. The owner's answer β€” *"just scope any inventory with Sales from Fisch, leave the
    rest"* β€” splits the block instead: the physical columns stay unscoped (there is one warehouse),
    the sales-derived ones are recomputed from the unit's own LTM units. The scope difference now
    lives entirely in the VALUES, which is where a reader can see it.

    The filter stays because the mechanism is still the right one for the next column that
    genuinely cannot be BU-shaped; `verify_perm_scope` asserts the tuple is empty so nothing here
    is silently filtering on a list that grew back.
    """
    import json
    from pathlib import Path

    import aios_grid

    doc = json.loads((Path(aios_grid.__file__).resolve().parent /
                      "aios_grid_fields.json").read_text(encoding="utf-8"))
    fields = list((doc.get("product_data") or {}).get("fields") or [])
    # R7: the Image column is BUILT IN on this table, injected here rather than added to
    # `aios_grid_fields.json`. That file is the ODOO-SOURCED contract β€” every key in it is a
    # column `modules.product_data.pool()` reads off a SKU β€” and this one is neither read from
    # Odoo nor written to it. Injected after the JSON so the canonical file stays the answer to
    # "what does Odoo give us", which is the question `verify_fields_contract` referees.
    fields = fields + [{
        "key": PRODUCT_IMAGE_KEY, "label": "Image", "type": "image", "source": "overlay",
        "note": "The product's picture. Empty shows the SKU's own master image; upload one "
                "from the record panel to override it.",
    }]
    if consolidated:
        return fields
    import modules.product_data as pd
    return [f for f in fields if f.get("key") not in pd.CONSOLIDATED_ONLY]