Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/routes_grid.py +123 -1
- platform/aios_grid_fields.json +534 -474
- platform/core/grid_events.py +0 -0
- platform/core/shared_overlay.py +38 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "cb05235",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 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 |
-
"
|
| 377 |
-
"
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
"
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
"
|
| 384 |
-
"
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
"
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
"
|
| 391 |
-
"
|
| 392 |
-
"
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
"
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
"
|
| 399 |
-
"
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
"
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
"
|
| 406 |
-
"
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
"
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
"
|
| 413 |
-
"
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
"
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
"
|
| 420 |
-
"
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
"
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
"
|
| 427 |
-
"
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
"
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
"
|
| 434 |
-
"
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
"
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
"
|
| 441 |
-
"
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
"
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
"
|
| 448 |
-
"
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
"
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
"
|
| 455 |
-
"
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
"
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
"
|
| 462 |
-
"
|
| 463 |
-
"
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
"
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
"
|
| 470 |
-
"
|
| 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)
|