fsanyoto commited on
Commit
26e4b28
·
verified ·
1 Parent(s): e796e5e

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "398e917",
3
  "releases": [
4
  {
5
  "version": "v25",
 
1
  {
2
+ "current": "cb05235",
3
  "releases": [
4
  {
5
  "version": "v25",
VERSION CHANGED
@@ -1 +1 @@
1
- 398e917
 
1
+ cb05235
api/routes_grid.py CHANGED
@@ -726,6 +726,111 @@ def grid_calendar_metrics(body: dict = Body(default=None), scope: str = "custome
726
  return out
727
 
728
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  @router.post("/grid/events")
730
  def grid_events_route(body: dict = Body(default=None),
731
  session: Session = Depends(require_session)):
@@ -822,13 +927,30 @@ def grid_events_route(body: dict = Body(default=None),
822
  try:
823
  for one in events:
824
  eid = str(one.get("id") or "") if isinstance(one, dict) else ""
 
 
 
 
 
 
825
  rerender = grid_events.handle_one(one, ctx)
826
- results.append({"id": eid, "rerender": bool(rerender)})
 
 
 
 
 
 
827
  except grid_events.StoreUnavailable:
828
  raise err(503, "store_unavailable",
829
  "the tenant store is unavailable — none of your changes were saved")
830
 
831
  out = {"results": results, "rerender": any(r["rerender"] for r in results)}
 
 
 
 
 
832
  if ctx.out.doc is not None:
833
  # ⭐ C4 / W30-T27 — `docPayload` IS THE NAME THE CLIENT ALREADY DECLARES. `types.ts` has
834
  # carried `docPayload?: {pid, docId, name, mime, data_b64}` since C5, and `Documents.tsx`
 
726
  return out
727
 
728
 
729
+ #: ⭐⭐ THE BULK CELL DOOR'S OWN CEILING, and it is REPORTED rather than silently enforced. The
730
+ #: standing no-cap rule governs data read from a connected source; this is a WRITE of team-typed
731
+ #: values, so a bound is right — but R6's second sentence still binds, which is why exceeding it
732
+ #: is a 400 naming the number and the count, never a truncation.
733
+ #: Sized against the real job with headroom: the 2027 catalog is 1,397 rows.
734
+ MAX_BULK_ROWS = 10_000
735
+
736
+
737
+ @router.post("/grid/bulk-cells")
738
+ def bulk_cells(body: dict = Body(default=None),
739
+ session: Session = Depends(require_session)):
740
+ """`{scopeKey, rows: {"<pid>": {key: value}}}` → shared cells on many rows, ONE transaction.
741
+
742
+ ⭐⭐ WHY THIS EXISTS AT ALL, because "we already have `/grid/events`" is the obvious objection.
743
+ That door is capped at `_MAX_EVENTS = 24` (it is sized for the client's resend window), so a
744
+ 1,397-row import is ~59 sequential POSTs against ONE JSON document — and this repo has a
745
+ MEASURED scar for exactly that: 18 writes against one document under the store's coalescing
746
+ single-flight landed **zero** while answering 200 eighteen times. Raising `_MAX_EVENTS` would
747
+ have widened the browser's own resend window to fix a script's problem. One door, one
748
+ transaction, is the honest shape.
749
+
750
+ ⛔ TENANT-WIDE KEYS ONLY, AND THAT IS THE POINT RATHER THAN A LIMITATION. It writes through
751
+ `shared_overlay`, which every account in the tenant reads. A per-user bulk write would be a
752
+ contradiction: nobody imports 1,397 rows of the team's work so that one account can see it —
753
+ that is precisely the failure owner ruling R6 was made to avoid. A key that is not declared
754
+ `shared: true` in the canonical contract is REFUSED BY NAME.
755
+
756
+ ⛔ ADMIN ONLY. This mutates data every account in the tenant reads, in bulk, in one call.
757
+ D-172 already records that the shared stratum has an asymmetric wall (anyone may create a
758
+ tenant-wide column, only the creator or an admin may delete one); a new door does not get to
759
+ inherit the loose half of an asymmetry somebody already flagged.
760
+ """
761
+ import core.shared_overlay as shared_overlay
762
+ import modules.product_data as pd
763
+ import core.perm_scope as perm_scope
764
+ from routes_products import MODULE as PRODUCT_MODULE, scoped_pool
765
+
766
+ scope = _scope_or_400((body or {}).get("scopeKey"))
767
+ if scope != "product":
768
+ # Fail-closed with the reason: the shared stratum is a PRODUCT-topic mechanism today.
769
+ raise err(400, "scope_not_bulk_writable",
770
+ f"bulk cell writes are available on the product database; '{scope}' has no "
771
+ f"tenant-wide cell stratum")
772
+ session.require(PRODUCT_MODULE)
773
+ if not session.admin:
774
+ raise err(403, "admin_only",
775
+ "a bulk write changes values every account in this workspace reads")
776
+
777
+ rows = (body or {}).get("rows")
778
+ if not isinstance(rows, dict):
779
+ raise err(400, "bad_rows", "expected {rows: {\"<pid>\": {field: value}}}")
780
+ if len(rows) > MAX_BULK_ROWS:
781
+ raise err(400, "too_many_rows",
782
+ f"at most {MAX_BULK_ROWS} rows per request; this one carried {len(rows)}")
783
+
784
+ pids, _team, _src, fields_base = scoped_pool(session)
785
+ allowed = {int(p) for p in pids}
786
+ shared_keys = set(pd.SHARED_KEYS())
787
+ hidden = perm_scope.hidden_keys(session.user, PRODUCT_MODULE, fields_base)
788
+
789
+ clean, unknown_pid, refused_keys = {}, [], {}
790
+ for raw_pid, values in rows.items():
791
+ try:
792
+ pid = int(raw_pid)
793
+ except (TypeError, ValueError):
794
+ unknown_pid.append(str(raw_pid)[:40])
795
+ continue
796
+ # ⚠ THE POOL IS THE WALL. `scoped_pool` is the same predicate the read door uses, so a
797
+ # caller cannot write a row they could not see — including a row in another BU.
798
+ if pid not in allowed:
799
+ unknown_pid.append(str(raw_pid)[:40])
800
+ continue
801
+ keep = {}
802
+ for key, value in dict(values or {}).items():
803
+ key = str(key)
804
+ if key not in shared_keys:
805
+ refused_keys.setdefault("not_shared", set()).add(key)
806
+ continue
807
+ if key in hidden:
808
+ refused_keys.setdefault("hidden_by_permissions", set()).add(key)
809
+ continue
810
+ if not isinstance(value, (str, int, float)) or isinstance(value, bool):
811
+ refused_keys.setdefault("unsupported_value", set()).add(key)
812
+ continue
813
+ keep[key] = value
814
+ if keep:
815
+ clean[pid] = keep
816
+
817
+ written = shared_overlay.put_rows(pd.TABLE_KEY, clean, st=pd.TABLE_OPS.st) if clean else {}
818
+ out = {
819
+ "rows_written": len(written),
820
+ "cells_written": sum(len(v) for v in written.values()),
821
+ "rows_requested": len(rows),
822
+ }
823
+ # ⭐ R6's second sentence: what did NOT land, and why. A bulk door that reports only its
824
+ # successes is how 701 unmatched SKUs disappear quietly.
825
+ if unknown_pid:
826
+ out["rows_not_in_your_pool"] = {"count": len(unknown_pid),
827
+ "sample": sorted(unknown_pid)[:10]}
828
+ if refused_keys:
829
+ out["refused_fields"] = {reason: sorted(keys)
830
+ for reason, keys in refused_keys.items()}
831
+ return out
832
+
833
+
834
  @router.post("/grid/events")
835
  def grid_events_route(body: dict = Body(default=None),
836
  session: Session = Depends(require_session)):
 
927
  try:
928
  for one in events:
929
  eid = str(one.get("id") or "") if isinstance(one, dict) else ""
930
+ # ⭐⭐ D-291 — WHICH event was refused, not merely THAT something was. The handler
931
+ # appends to a shared list, so the refusals belonging to THIS event are exactly the
932
+ # ones that appeared across THIS call. Reading the list once after the loop would
933
+ # answer "the batch was refused" and leave the caller to guess which member, which is
934
+ # the same class of unfalsifiable answer the channel exists to end.
935
+ _before = len(ctx.out.refusals)
936
  rerender = grid_events.handle_one(one, ctx)
937
+ row = {"id": eid, "rerender": bool(rerender)}
938
+ mine = ctx.out.refusals[_before:]
939
+ if mine:
940
+ # `refused` is the SHAPE a caller branches on; the first reason is the one that
941
+ # stopped this write (a handler returns at its first refusal).
942
+ row["refused"] = mine[0]
943
+ results.append(row)
944
  except grid_events.StoreUnavailable:
945
  raise err(503, "store_unavailable",
946
  "the tenant store is unavailable — none of your changes were saved")
947
 
948
  out = {"results": results, "rerender": any(r["rerender"] for r in results)}
949
+ if ctx.out.refusals:
950
+ # ⚠ ALSO AT THE TOP LEVEL, because a batch that was wholly refused must not read as a
951
+ # batch that wholly landed. A caller that only checks the envelope still learns something
952
+ # is wrong, and a caller that walks `results` learns exactly which member.
953
+ out["refusals"] = list(ctx.out.refusals)
954
  if ctx.out.doc is not None:
955
  # ⭐ C4 / W30-T27 — `docPayload` IS THE NAME THE CLIENT ALREADY DECLARES. `types.ts` has
956
  # carried `docPayload?: {pid, docId, name, mime, data_b64}` since C5, and `Documents.tsx`
platform/aios_grid_fields.json CHANGED
@@ -1,474 +1,534 @@
1
- {
2
- "_comment": "CANONICAL field contract for the AIOS Airtable-style grid — the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description — every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else — no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` — not `is empty` — finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED — LTM's replacement is a creatable Sales measure column (the demo column IS Sales · the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED — every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
- "fields": [
4
- {
5
- "key": "customer",
6
- "label": "Customer",
7
- "type": "text",
8
- "source": "odoo",
9
- "pinned": true,
10
- "default": true,
11
- "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
- },
13
- {
14
- "key": "partner_id",
15
- "label": "Odoo ID",
16
- "type": "int",
17
- "source": "odoo",
18
- "derived": true,
19
- "default": false,
20
- "description": "The Odoo res.partner id — the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
21
- },
22
- {
23
- "key": "odoo_status",
24
- "label": "Odoo record",
25
- "type": "status",
26
- "source": "odoo",
27
- "default": false,
28
- "options": ["Active", "Archived"],
29
- "description": "Whether this customer still exists in Odoo. Archived means deleted there."
30
- },
31
- {
32
- "key": "agent",
33
- "label": "Agent",
34
- "type": "text",
35
- "source": "odoo",
36
- "default": true,
37
- "description": "The sales agent who owns this account."
38
- },
39
- {
40
- "key": "dba",
41
- "label": "DBA",
42
- "type": "select",
43
- "source": "odoo",
44
- "default": false,
45
- "options": [
46
- "Fisch",
47
- "Royal",
48
- "Both"
49
- ],
50
- "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
51
- },
52
- {
53
- "key": "salesperson",
54
- "label": "Salesperson",
55
- "type": "text",
56
- "source": "odoo",
57
- "default": false,
58
- "description": "Who keyed in most of this customer's orders — not the Agent, who owns the account."
59
- },
60
- {
61
- "key": "street",
62
- "label": "Street",
63
- "type": "text",
64
- "source": "odoo",
65
- "default": false,
66
- "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
67
- },
68
- {
69
- "key": "street2",
70
- "label": "Street 2",
71
- "type": "text",
72
- "source": "odoo",
73
- "default": false,
74
- "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
75
- },
76
- {
77
- "key": "city",
78
- "label": "City",
79
- "type": "text",
80
- "source": "odoo",
81
- "default": true,
82
- "description": "City on the customer's Odoo address."
83
- },
84
- {
85
- "key": "state",
86
- "label": "State",
87
- "type": "text",
88
- "source": "odoo",
89
- "default": true,
90
- "description": "State or province on the customer's Odoo address."
91
- },
92
- {
93
- "key": "country",
94
- "label": "Country",
95
- "type": "text",
96
- "source": "odoo",
97
- "default": false,
98
- "description": "Country on the customer's Odoo address."
99
- },
100
- {
101
- "key": "zip",
102
- "label": "ZIP",
103
- "type": "text",
104
- "source": "odoo",
105
- "default": false,
106
- "description": "Postal code on the customer's Odoo address."
107
- },
108
- {
109
- "key": "customer_since",
110
- "label": "Customer since",
111
- "type": "date",
112
- "source": "odoo",
113
- "default": false,
114
- "description": "When this customer was first set up in Odoo."
115
- },
116
- {
117
- "key": "tags",
118
- "label": "Tags",
119
- "type": "text",
120
- "source": "odoo",
121
- "default": false,
122
- "description": "Odoo labels on this customer, comma-separated."
123
- },
124
- {
125
- "key": "pricelist",
126
- "label": "Price list",
127
- "type": "text",
128
- "source": "odoo",
129
- "default": false,
130
- "description": "The price list this customer buys on."
131
- },
132
- {
133
- "key": "payment_terms",
134
- "label": "Payment terms",
135
- "type": "text",
136
- "source": "odoo",
137
- "default": false,
138
- "description": "Payment terms on this customer's account — Net 30, for example."
139
- },
140
- {
141
- "key": "last_order",
142
- "label": "Last order",
143
- "type": "date",
144
- "source": "odoo",
145
- "default": true,
146
- "description": "Date of the most recent confirmed order."
147
- },
148
- {
149
- "key": "overdue_days",
150
- "label": "Overdue days",
151
- "type": "int",
152
- "source": "odoo",
153
- "default": true,
154
- "description": "How many days late this customer is running against their own usual ordering rhythm."
155
- },
156
- {
157
- "_note": "filterable:false DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule — see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
158
- "key": "est_missed",
159
- "label": "Est. missed $",
160
- "type": "currency",
161
- "source": "odoo",
162
- "default": true,
163
- "agg": "sum",
164
- "filterable": false,
165
- "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
166
- },
167
- {
168
- "_note": "wave 21 R1 KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary — 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
169
- "key": "ar_open",
170
- "label": "AR current $",
171
- "type": "currency",
172
- "source": "odoo",
173
- "default": false,
174
- "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
175
- },
176
- {
177
- "key": "ar_overdue",
178
- "label": "AR overdue $",
179
- "type": "currency",
180
- "source": "odoo",
181
- "default": false,
182
- "description": "Invoiced money past due — same basis as the Collections page."
183
- },
184
- {
185
- "_note": "wave 21 R1the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie — no second oracle.",
186
- "key": "ar_outstanding",
187
- "label": "AR outstanding $",
188
- "type": "currency",
189
- "source": "odoo",
190
- "default": false,
191
- "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
192
- },
193
- {
194
- "key": "ar_exposure",
195
- "label": "Credit exposure $",
196
- "type": "currency",
197
- "source": "odoo",
198
- "default": false,
199
- "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
200
- },
201
- {
202
- "key": "ar_aged_1_30",
203
- "label": "1-30 days $",
204
- "type": "currency",
205
- "source": "odoo",
206
- "default": false,
207
- "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
208
- },
209
- {
210
- "key": "ar_aged_31_60",
211
- "label": "31-60 days $",
212
- "type": "currency",
213
- "source": "odoo",
214
- "default": false,
215
- "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
216
- },
217
- {
218
- "key": "ar_aged_61_90",
219
- "label": "61-90 days $",
220
- "type": "currency",
221
- "source": "odoo",
222
- "default": false,
223
- "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
224
- },
225
- {
226
- "key": "ar_aged_90_plus",
227
- "label": "90+ days $",
228
- "type": "currency",
229
- "source": "odoo",
230
- "default": false,
231
- "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
232
- },
233
- {
234
- "key": "days_to_pay",
235
- "label": "Days to pay",
236
- "type": "int",
237
- "source": "odoo",
238
- "default": false,
239
- "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
240
- },
241
- {
242
- "key": "top_category",
243
- "label": "Top category",
244
- "type": "text",
245
- "source": "odoo",
246
- "default": false,
247
- "description": "The category this customer spent the most on in the last 12 months."
248
- },
249
- {
250
- "key": "top_category_pct",
251
- "label": "Top category %",
252
- "type": "pct",
253
- "source": "odoo",
254
- "default": false,
255
- "description": "Share of last-12-months spend that went to the top category."
256
- },
257
- {
258
- "key": "sku_count",
259
- "label": "SKUs bought",
260
- "type": "int",
261
- "source": "odoo",
262
- "default": false,
263
- "description": "Distinct products bought in the last 12 months."
264
- },
265
- {
266
- "key": "top_sku",
267
- "label": "Top SKU",
268
- "type": "text",
269
- "source": "odoo",
270
- "default": false,
271
- "description": "The product this customer spent the most on in the last 12 months."
272
- },
273
- {
274
- "key": "days_since",
275
- "label": "Days since order",
276
- "type": "int",
277
- "source": "odoo",
278
- "default": false,
279
- "description": "Days since the last confirmed order."
280
- },
281
- {
282
- "key": "typical_gap_days",
283
- "label": "Typical gap days",
284
- "type": "int",
285
- "source": "odoo",
286
- "default": false,
287
- "description": "Days this customer usually goes between orders, from their own history."
288
- },
289
- {
290
- "key": "notes",
291
- "label": "Notes",
292
- "type": "text",
293
- "source": "overlay",
294
- "default": false,
295
- "description": "Your notes on this customer. Saved in this app only, visible only to you."
296
- }
297
- ],
298
- "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
299
- "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
300
- "product_data": {
301
- "identity": "pid",
302
- "business_key": "code",
303
- "fields": [
304
- {
305
- "key": "code",
306
- "label": "SKU",
307
- "type": "text",
308
- "source": "odoo",
309
- "pinned": true,
310
- "default": true,
311
- "description": "The SKU code — the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
312
- },
313
- {
314
- "key": "product",
315
- "label": "Product",
316
- "type": "text",
317
- "source": "odoo",
318
- "default": true,
319
- "description": "Product name as it appears in Odoo."
320
- },
321
- {
322
- "key": "category",
323
- "label": "Category",
324
- "type": "select",
325
- "source": "odoo",
326
- "default": true,
327
- "description": "Product category; '(uncategorized)' when Odoo carries none."
328
- },
329
- {
330
- "key": "supplier",
331
- "label": "Supplier",
332
- "type": "text",
333
- "source": "overlay",
334
- "default": true,
335
- "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
336
- "shared": true
337
- },
338
- {
339
- "key": "origin_country",
340
- "label": "Country",
341
- "type": "text",
342
- "source": "overlay",
343
- "default": false,
344
- "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
345
- "shared": true
346
- },
347
- {
348
- "key": "lead_days",
349
- "label": "Lead time (days)",
350
- "type": "int",
351
- "source": "overlay",
352
- "default": true,
353
- "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
354
- "shared": true
355
- },
356
- {
357
- "key": "first_cost",
358
- "label": "First cost",
359
- "type": "currency",
360
- "source": "overlay",
361
- "default": false,
362
- "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
363
- "shared": true
364
- },
365
- {
366
- "key": "price_fisch",
367
- "label": "Fisch price",
368
- "type": "currency",
369
- "source": "odoo",
370
- "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
371
- },
372
- {
373
- "key": "price_royal_1",
374
- "label": "Royal 1 price",
375
- "type": "currency",
376
- "source": "odoo",
377
- "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
378
- },
379
- {
380
- "key": "price_royal_2",
381
- "label": "Royal 2 price",
382
- "type": "currency",
383
- "source": "odoo",
384
- "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
385
- },
386
- {
387
- "key": "rev_ytd",
388
- "label": "Revenue YTD",
389
- "type": "currency",
390
- "source": "odoo",
391
- "default": true,
392
- "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
393
- },
394
- {
395
- "key": "rev_ly",
396
- "label": "Revenue LY",
397
- "type": "currency",
398
- "source": "odoo",
399
- "description": "Same period last year — seasonal wholesale compares like for like."
400
- },
401
- {
402
- "key": "yoy_pct",
403
- "label": "YoY %",
404
- "type": "pct",
405
- "source": "odoo",
406
- "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
407
- },
408
- {
409
- "key": "qty_ytd",
410
- "label": "Units YTD",
411
- "type": "int",
412
- "source": "odoo",
413
- "description": "Units sold year to date."
414
- },
415
- {
416
- "key": "orders_ytd",
417
- "label": "Orders YTD",
418
- "type": "int",
419
- "source": "odoo",
420
- "description": "Distinct orders containing this SKU, year to date."
421
- },
422
- {
423
- "key": "on_hand",
424
- "label": "On hand",
425
- "type": "int",
426
- "source": "odoo",
427
- "description": "Units in stock. CONSOLIDATED — one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
428
- },
429
- {
430
- "key": "unit_cost",
431
- "label": "Unit cost",
432
- "type": "currency",
433
- "source": "odoo",
434
- "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
435
- },
436
- {
437
- "key": "inv_value",
438
- "label": "Stock value",
439
- "type": "currency",
440
- "source": "odoo",
441
- "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
442
- },
443
- {
444
- "key": "qty_ltm",
445
- "label": "Units LTM",
446
- "type": "int",
447
- "source": "odoo",
448
- "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
449
- },
450
- {
451
- "key": "dos",
452
- "label": "Days of supply",
453
- "type": "int",
454
- "source": "odoo",
455
- "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
456
- },
457
- {
458
- "key": "cover_gap_d",
459
- "label": "Cover gap (days)",
460
- "type": "int",
461
- "source": "odoo",
462
- "default": false,
463
- "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
464
- },
465
- {
466
- "key": "stock_bucket",
467
- "label": "Stock status",
468
- "type": "select",
469
- "source": "odoo",
470
- "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
471
- }
472
- ]
473
- }
474
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "CANONICAL field contract for the AIOS Airtable-style grid — the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description — every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else — no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` — not `is empty` — finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED — LTM's replacement is a creatable Sales measure column (the demo column IS Sales · the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED — every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
+ "fields": [
4
+ {
5
+ "key": "customer",
6
+ "label": "Customer",
7
+ "type": "text",
8
+ "source": "odoo",
9
+ "pinned": true,
10
+ "default": true,
11
+ "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
+ },
13
+ {
14
+ "key": "partner_id",
15
+ "label": "Odoo ID",
16
+ "type": "int",
17
+ "source": "odoo",
18
+ "derived": true,
19
+ "default": false,
20
+ "description": "The Odoo res.partner id — the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
21
+ },
22
+ {
23
+ "key": "odoo_status",
24
+ "label": "Odoo record",
25
+ "type": "status",
26
+ "source": "odoo",
27
+ "default": false,
28
+ "options": [
29
+ "Active",
30
+ "Archived"
31
+ ],
32
+ "description": "Whether this customer still exists in Odoo. Archived means deleted there."
33
+ },
34
+ {
35
+ "key": "agent",
36
+ "label": "Agent",
37
+ "type": "text",
38
+ "source": "odoo",
39
+ "default": true,
40
+ "description": "The sales agent who owns this account."
41
+ },
42
+ {
43
+ "key": "dba",
44
+ "label": "DBA",
45
+ "type": "select",
46
+ "source": "odoo",
47
+ "default": false,
48
+ "options": [
49
+ "Fisch",
50
+ "Royal",
51
+ "Both"
52
+ ],
53
+ "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
54
+ },
55
+ {
56
+ "key": "salesperson",
57
+ "label": "Salesperson",
58
+ "type": "text",
59
+ "source": "odoo",
60
+ "default": false,
61
+ "description": "Who keyed in most of this customer's orders — not the Agent, who owns the account."
62
+ },
63
+ {
64
+ "key": "street",
65
+ "label": "Street",
66
+ "type": "text",
67
+ "source": "odoo",
68
+ "default": false,
69
+ "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
70
+ },
71
+ {
72
+ "key": "street2",
73
+ "label": "Street 2",
74
+ "type": "text",
75
+ "source": "odoo",
76
+ "default": false,
77
+ "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
78
+ },
79
+ {
80
+ "key": "city",
81
+ "label": "City",
82
+ "type": "text",
83
+ "source": "odoo",
84
+ "default": true,
85
+ "description": "City on the customer's Odoo address."
86
+ },
87
+ {
88
+ "key": "state",
89
+ "label": "State",
90
+ "type": "text",
91
+ "source": "odoo",
92
+ "default": true,
93
+ "description": "State or province on the customer's Odoo address."
94
+ },
95
+ {
96
+ "key": "country",
97
+ "label": "Country",
98
+ "type": "text",
99
+ "source": "odoo",
100
+ "default": false,
101
+ "description": "Country on the customer's Odoo address."
102
+ },
103
+ {
104
+ "key": "zip",
105
+ "label": "ZIP",
106
+ "type": "text",
107
+ "source": "odoo",
108
+ "default": false,
109
+ "description": "Postal code on the customer's Odoo address."
110
+ },
111
+ {
112
+ "key": "customer_since",
113
+ "label": "Customer since",
114
+ "type": "date",
115
+ "source": "odoo",
116
+ "default": false,
117
+ "description": "When this customer was first set up in Odoo."
118
+ },
119
+ {
120
+ "key": "tags",
121
+ "label": "Tags",
122
+ "type": "text",
123
+ "source": "odoo",
124
+ "default": false,
125
+ "description": "Odoo labels on this customer, comma-separated."
126
+ },
127
+ {
128
+ "key": "pricelist",
129
+ "label": "Price list",
130
+ "type": "text",
131
+ "source": "odoo",
132
+ "default": false,
133
+ "description": "The price list this customer buys on."
134
+ },
135
+ {
136
+ "key": "payment_terms",
137
+ "label": "Payment terms",
138
+ "type": "text",
139
+ "source": "odoo",
140
+ "default": false,
141
+ "description": "Payment terms on this customer's account — Net 30, for example."
142
+ },
143
+ {
144
+ "key": "last_order",
145
+ "label": "Last order",
146
+ "type": "date",
147
+ "source": "odoo",
148
+ "default": true,
149
+ "description": "Date of the most recent confirmed order."
150
+ },
151
+ {
152
+ "key": "overdue_days",
153
+ "label": "Overdue days",
154
+ "type": "int",
155
+ "source": "odoo",
156
+ "default": true,
157
+ "description": "How many days late this customer is running against their own usual ordering rhythm."
158
+ },
159
+ {
160
+ "_note": "filterable:false — DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule — see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
161
+ "key": "est_missed",
162
+ "label": "Est. missed $",
163
+ "type": "currency",
164
+ "source": "odoo",
165
+ "default": true,
166
+ "agg": "sum",
167
+ "filterable": false,
168
+ "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
169
+ },
170
+ {
171
+ "_note": "wave 21 R1 — KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary — 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
172
+ "key": "ar_open",
173
+ "label": "AR current $",
174
+ "type": "currency",
175
+ "source": "odoo",
176
+ "default": false,
177
+ "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
178
+ },
179
+ {
180
+ "key": "ar_overdue",
181
+ "label": "AR overdue $",
182
+ "type": "currency",
183
+ "source": "odoo",
184
+ "default": false,
185
+ "description": "Invoiced money past due — same basis as the Collections page."
186
+ },
187
+ {
188
+ "_note": "wave 21 R1 — the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie — no second oracle.",
189
+ "key": "ar_outstanding",
190
+ "label": "AR outstanding $",
191
+ "type": "currency",
192
+ "source": "odoo",
193
+ "default": false,
194
+ "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
195
+ },
196
+ {
197
+ "key": "ar_exposure",
198
+ "label": "Credit exposure $",
199
+ "type": "currency",
200
+ "source": "odoo",
201
+ "default": false,
202
+ "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
203
+ },
204
+ {
205
+ "key": "ar_aged_1_30",
206
+ "label": "1-30 days $",
207
+ "type": "currency",
208
+ "source": "odoo",
209
+ "default": false,
210
+ "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
211
+ },
212
+ {
213
+ "key": "ar_aged_31_60",
214
+ "label": "31-60 days $",
215
+ "type": "currency",
216
+ "source": "odoo",
217
+ "default": false,
218
+ "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
219
+ },
220
+ {
221
+ "key": "ar_aged_61_90",
222
+ "label": "61-90 days $",
223
+ "type": "currency",
224
+ "source": "odoo",
225
+ "default": false,
226
+ "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
227
+ },
228
+ {
229
+ "key": "ar_aged_90_plus",
230
+ "label": "90+ days $",
231
+ "type": "currency",
232
+ "source": "odoo",
233
+ "default": false,
234
+ "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
235
+ },
236
+ {
237
+ "key": "days_to_pay",
238
+ "label": "Days to pay",
239
+ "type": "int",
240
+ "source": "odoo",
241
+ "default": false,
242
+ "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
243
+ },
244
+ {
245
+ "key": "top_category",
246
+ "label": "Top category",
247
+ "type": "text",
248
+ "source": "odoo",
249
+ "default": false,
250
+ "description": "The category this customer spent the most on in the last 12 months."
251
+ },
252
+ {
253
+ "key": "top_category_pct",
254
+ "label": "Top category %",
255
+ "type": "pct",
256
+ "source": "odoo",
257
+ "default": false,
258
+ "description": "Share of last-12-months spend that went to the top category."
259
+ },
260
+ {
261
+ "key": "sku_count",
262
+ "label": "SKUs bought",
263
+ "type": "int",
264
+ "source": "odoo",
265
+ "default": false,
266
+ "description": "Distinct products bought in the last 12 months."
267
+ },
268
+ {
269
+ "key": "top_sku",
270
+ "label": "Top SKU",
271
+ "type": "text",
272
+ "source": "odoo",
273
+ "default": false,
274
+ "description": "The product this customer spent the most on in the last 12 months."
275
+ },
276
+ {
277
+ "key": "days_since",
278
+ "label": "Days since order",
279
+ "type": "int",
280
+ "source": "odoo",
281
+ "default": false,
282
+ "description": "Days since the last confirmed order."
283
+ },
284
+ {
285
+ "key": "typical_gap_days",
286
+ "label": "Typical gap days",
287
+ "type": "int",
288
+ "source": "odoo",
289
+ "default": false,
290
+ "description": "Days this customer usually goes between orders, from their own history."
291
+ },
292
+ {
293
+ "key": "notes",
294
+ "label": "Notes",
295
+ "type": "text",
296
+ "source": "overlay",
297
+ "default": false,
298
+ "description": "Your notes on this customer. Saved in this app only, visible only to you."
299
+ }
300
+ ],
301
+ "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
302
+ "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
303
+ "product_data": {
304
+ "identity": "pid",
305
+ "business_key": "code",
306
+ "fields": [
307
+ {
308
+ "key": "code",
309
+ "label": "SKU",
310
+ "type": "text",
311
+ "source": "odoo",
312
+ "pinned": true,
313
+ "default": true,
314
+ "description": "The SKU code — the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
315
+ },
316
+ {
317
+ "key": "product",
318
+ "label": "Product",
319
+ "type": "text",
320
+ "source": "odoo",
321
+ "default": true,
322
+ "description": "Product name as it appears in Odoo."
323
+ },
324
+ {
325
+ "key": "category",
326
+ "label": "Category",
327
+ "type": "select",
328
+ "source": "odoo",
329
+ "default": true,
330
+ "description": "Product category; '(uncategorized)' when Odoo carries none."
331
+ },
332
+ {
333
+ "key": "supplier",
334
+ "label": "Supplier",
335
+ "type": "text",
336
+ "source": "overlay",
337
+ "default": true,
338
+ "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
339
+ "shared": true
340
+ },
341
+ {
342
+ "key": "origin_country",
343
+ "label": "Country",
344
+ "type": "text",
345
+ "source": "overlay",
346
+ "default": false,
347
+ "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
348
+ "shared": true
349
+ },
350
+ {
351
+ "key": "lead_days",
352
+ "label": "Lead time (days)",
353
+ "type": "int",
354
+ "source": "overlay",
355
+ "default": true,
356
+ "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
357
+ "shared": true
358
+ },
359
+ {
360
+ "key": "first_cost",
361
+ "label": "First cost",
362
+ "type": "currency",
363
+ "source": "overlay",
364
+ "default": false,
365
+ "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
366
+ "shared": true
367
+ },
368
+ {
369
+ "key": "price_fisch",
370
+ "label": "Fisch price",
371
+ "type": "currency",
372
+ "source": "odoo",
373
+ "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
374
+ },
375
+ {
376
+ "key": "price_royal_1",
377
+ "label": "Royal 1 price",
378
+ "type": "currency",
379
+ "source": "odoo",
380
+ "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
381
+ },
382
+ {
383
+ "key": "price_royal_2",
384
+ "label": "Royal 2 price",
385
+ "type": "currency",
386
+ "source": "odoo",
387
+ "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
388
+ },
389
+ {
390
+ "key": "rev_ytd",
391
+ "label": "Revenue YTD",
392
+ "type": "currency",
393
+ "source": "odoo",
394
+ "default": true,
395
+ "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
396
+ },
397
+ {
398
+ "key": "rev_ly",
399
+ "label": "Revenue LY",
400
+ "type": "currency",
401
+ "source": "odoo",
402
+ "description": "Same period last year — seasonal wholesale compares like for like."
403
+ },
404
+ {
405
+ "key": "yoy_pct",
406
+ "label": "YoY %",
407
+ "type": "pct",
408
+ "source": "odoo",
409
+ "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
410
+ },
411
+ {
412
+ "key": "qty_ytd",
413
+ "label": "Units YTD",
414
+ "type": "int",
415
+ "source": "odoo",
416
+ "description": "Units sold year to date."
417
+ },
418
+ {
419
+ "key": "orders_ytd",
420
+ "label": "Orders YTD",
421
+ "type": "int",
422
+ "source": "odoo",
423
+ "description": "Distinct orders containing this SKU, year to date."
424
+ },
425
+ {
426
+ "key": "on_hand",
427
+ "label": "On hand",
428
+ "type": "int",
429
+ "source": "odoo",
430
+ "description": "Units in stock. CONSOLIDATED — one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
431
+ },
432
+ {
433
+ "key": "unit_cost",
434
+ "label": "Unit cost",
435
+ "type": "currency",
436
+ "source": "odoo",
437
+ "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
438
+ },
439
+ {
440
+ "key": "inv_value",
441
+ "label": "Stock value",
442
+ "type": "currency",
443
+ "source": "odoo",
444
+ "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
445
+ },
446
+ {
447
+ "key": "qty_ltm",
448
+ "label": "Units LTM",
449
+ "type": "int",
450
+ "source": "odoo",
451
+ "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
452
+ },
453
+ {
454
+ "key": "dos",
455
+ "label": "Days of supply",
456
+ "type": "int",
457
+ "source": "odoo",
458
+ "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
459
+ },
460
+ {
461
+ "key": "cover_gap_d",
462
+ "label": "Cover gap (days)",
463
+ "type": "int",
464
+ "source": "odoo",
465
+ "default": false,
466
+ "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
467
+ },
468
+ {
469
+ "key": "stock_bucket",
470
+ "label": "Stock status",
471
+ "type": "select",
472
+ "source": "odoo",
473
+ "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
474
+ },
475
+ {
476
+ "key": "needs_pricing",
477
+ "label": "Needs pricing",
478
+ "type": "select",
479
+ "source": "overlay",
480
+ "default": false,
481
+ "options": [
482
+ "Yes"
483
+ ],
484
+ "shared": true,
485
+ "description": "Team-maintained. A SKU carries “Yes” when it appears on the NEEDS PRICING tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace — the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
486
+ },
487
+ {
488
+ "key": "march_pricelist",
489
+ "label": "March pricelist",
490
+ "type": "select",
491
+ "source": "overlay",
492
+ "default": false,
493
+ "options": [
494
+ "Yes"
495
+ ],
496
+ "shared": true,
497
+ "description": "Team-maintained. A SKU carries “Yes” when it appears on the March Pricelist tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace — the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
498
+ },
499
+ {
500
+ "key": "price_changes",
501
+ "label": "Price changes",
502
+ "type": "select",
503
+ "source": "overlay",
504
+ "default": false,
505
+ "options": [
506
+ "Yes"
507
+ ],
508
+ "shared": true,
509
+ "description": "Team-maintained. A SKU carries “Yes” when it appears on the Price Changes tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace — the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
510
+ },
511
+ {
512
+ "key": "closeouts",
513
+ "label": "Closeouts",
514
+ "type": "select",
515
+ "source": "overlay",
516
+ "default": false,
517
+ "options": [
518
+ "Yes"
519
+ ],
520
+ "shared": true,
521
+ "description": "Team-maintained. A SKU carries “Yes” when it appears on the Closeouts tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace — the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
522
+ },
523
+ {
524
+ "key": "notes",
525
+ "label": "Notes",
526
+ "type": "text",
527
+ "source": "overlay",
528
+ "default": false,
529
+ "shared": true,
530
+ "description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
531
+ }
532
+ ]
533
+ }
534
+ }
platform/core/grid_events.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/core/shared_overlay.py CHANGED
@@ -230,6 +230,44 @@ def put_cells(table_key, pid, values, st=None):
230
  return clean
231
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def clear_row(table_key, pid, st=None):
234
  """Forget every shared cell on one row. True when there was something to forget."""
235
  row_id = _pid(pid)
 
230
  return clean
231
 
232
 
233
+ def put_rows(table_key, rows, st=None):
234
+ """Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
235
+
236
+ ⭐⭐ THE WHOLE POINT IS THE *ONE*, AND IT IS NOT AN OPTIMISATION. `put_cells` is one row per
237
+ store write, so a 1,397-row catalog import is 1,397 download-modify-upload cycles against one
238
+ JSON document. This repo has a MEASURED scar for that shape: **18 writes against one document
239
+ under the store's coalescing single-flight landed ZERO while answering 200 eighteen times.**
240
+ Batching is what makes a bulk import land at all, not what makes it fast.
241
+
242
+ ⚠ It deliberately takes the WHOLE SET rather than accepting a stream: a caller that loops over
243
+ this function has simply rebuilt `put_cells` with extra steps, and the failure it reintroduces
244
+ is silent. If the set does not fit in memory it does not fit in this store either — that is a
245
+ signal to change substrate, not to chunk.
246
+
247
+ ⛔ NO CAP HERE, ON PURPOSE. The bound belongs at the DOOR, where a caller identity and a
248
+ reportable refusal exist (`routes_grid.bulk_cells`). A silent ceiling in a store primitive is
249
+ exactly the shape the standing no-cap rule forbids.
250
+ """
251
+ clean = {}
252
+ for pid, values in dict(rows or {}).items():
253
+ row_id = _pid(pid)
254
+ cells_for_row = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
255
+ if cells_for_row:
256
+ clean.setdefault(row_id, {}).update(cells_for_row)
257
+ if not clean:
258
+ return {}
259
+
260
+ def _patch(data):
261
+ for row_id, values in clean.items():
262
+ data['cells'].setdefault(row_id, {}).update(values)
263
+
264
+ # `flush='sync'` because a bulk import must be durable when the call returns: the caller is a
265
+ # script that will report "1,397 rows written" and exit, and an async flush would make that
266
+ # sentence a prediction rather than a fact.
267
+ _write(table_key, _patch, st, flush='sync')
268
+ return clean
269
+
270
+
271
  def clear_row(table_key, pid, st=None):
272
  """Forget every shared cell on one row. True when there was something to forget."""
273
  row_id = _pid(pid)