| { | |
| "_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).", | |
| "fields": [ | |
| { | |
| "key": "customer", | |
| "label": "Customer", | |
| "type": "text", | |
| "source": "odoo", | |
| "pinned": true, | |
| "default": true, | |
| "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months." | |
| }, | |
| { | |
| "key": "odoo_status", | |
| "label": "Odoo record", | |
| "type": "status", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Whether this customer still exists in Odoo. Archived means deleted there." | |
| }, | |
| { | |
| "key": "agent", | |
| "label": "Agent", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "The sales agent who owns this account." | |
| }, | |
| { | |
| "key": "dba", | |
| "label": "DBA", | |
| "type": "select", | |
| "source": "odoo", | |
| "default": false, | |
| "options": [ | |
| "Fisch", | |
| "Royal", | |
| "Both" | |
| ], | |
| "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA." | |
| }, | |
| { | |
| "key": "salesperson", | |
| "label": "Salesperson", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Who keyed in most of this customer's orders — not the Agent, who owns the account." | |
| }, | |
| { | |
| "key": "street", | |
| "label": "Street", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address." | |
| }, | |
| { | |
| "key": "street2", | |
| "label": "Street 2", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Second address line (suite, unit, floor) on the customer's Odoo address." | |
| }, | |
| { | |
| "key": "city", | |
| "label": "City", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "City on the customer's Odoo address." | |
| }, | |
| { | |
| "key": "state", | |
| "label": "State", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "State or province on the customer's Odoo address." | |
| }, | |
| { | |
| "key": "country", | |
| "label": "Country", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Country on the customer's Odoo address." | |
| }, | |
| { | |
| "key": "zip", | |
| "label": "ZIP", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Postal code on the customer's Odoo address." | |
| }, | |
| { | |
| "key": "customer_since", | |
| "label": "Customer since", | |
| "type": "date", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "When this customer was first set up in Odoo." | |
| }, | |
| { | |
| "key": "tags", | |
| "label": "Tags", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Odoo labels on this customer, comma-separated." | |
| }, | |
| { | |
| "key": "pricelist", | |
| "label": "Price list", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "The price list this customer buys on." | |
| }, | |
| { | |
| "key": "payment_terms", | |
| "label": "Payment terms", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Payment terms on this customer's account — Net 30, for example." | |
| }, | |
| { | |
| "key": "last_order", | |
| "label": "Last order", | |
| "type": "date", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "Date of the most recent confirmed order." | |
| }, | |
| { | |
| "key": "overdue_days", | |
| "label": "Overdue days", | |
| "type": "int", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "How many days late this customer is running against their own usual ordering rhythm." | |
| }, | |
| { | |
| "_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.", | |
| "key": "est_missed", | |
| "label": "Est. missed $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": true, | |
| "agg": "sum", | |
| "filterable": false, | |
| "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed." | |
| }, | |
| { | |
| "_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.", | |
| "key": "ar_open", | |
| "label": "AR current $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)." | |
| }, | |
| { | |
| "key": "ar_overdue", | |
| "label": "AR overdue $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Invoiced money past due — same basis as the Collections page." | |
| }, | |
| { | |
| "_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.", | |
| "key": "ar_outstanding", | |
| "label": "AR outstanding $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Total invoiced money owed right now: AR current $ plus AR overdue $." | |
| }, | |
| { | |
| "key": "ar_exposure", | |
| "label": "Credit exposure $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced." | |
| }, | |
| { | |
| "key": "ar_aged_1_30", | |
| "label": "1-30 days $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $." | |
| }, | |
| { | |
| "key": "ar_aged_31_60", | |
| "label": "31-60 days $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $." | |
| }, | |
| { | |
| "key": "ar_aged_61_90", | |
| "label": "61-90 days $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $." | |
| }, | |
| { | |
| "key": "ar_aged_90_plus", | |
| "label": "90+ days $", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $." | |
| }, | |
| { | |
| "key": "days_to_pay", | |
| "label": "Days to pay", | |
| "type": "int", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet." | |
| }, | |
| { | |
| "key": "top_category", | |
| "label": "Top category", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "The category this customer spent the most on in the last 12 months." | |
| }, | |
| { | |
| "key": "top_category_pct", | |
| "label": "Top category %", | |
| "type": "pct", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Share of last-12-months spend that went to the top category." | |
| }, | |
| { | |
| "key": "sku_count", | |
| "label": "SKUs bought", | |
| "type": "int", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Distinct products bought in the last 12 months." | |
| }, | |
| { | |
| "key": "top_sku", | |
| "label": "Top SKU", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "The product this customer spent the most on in the last 12 months." | |
| }, | |
| { | |
| "key": "days_since", | |
| "label": "Days since order", | |
| "type": "int", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Days since the last confirmed order." | |
| }, | |
| { | |
| "key": "typical_gap_days", | |
| "label": "Typical gap days", | |
| "type": "int", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Days this customer usually goes between orders, from their own history." | |
| }, | |
| { | |
| "key": "notes", | |
| "label": "Notes", | |
| "type": "text", | |
| "source": "overlay", | |
| "default": false, | |
| "description": "Your notes on this customer. Saved in this app only, visible only to you." | |
| } | |
| ], | |
| "_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.", | |
| "_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).", | |
| "product_data": { | |
| "identity": "pid", | |
| "business_key": "code", | |
| "fields": [ | |
| { | |
| "key": "code", | |
| "label": "SKU", | |
| "type": "text", | |
| "source": "odoo", | |
| "pinned": true, | |
| "default": true, | |
| "description": "The SKU code — the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer." | |
| }, | |
| { | |
| "key": "product", | |
| "label": "Product", | |
| "type": "text", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "Product name as it appears in Odoo." | |
| }, | |
| { | |
| "key": "category", | |
| "label": "Category", | |
| "type": "select", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "Product category; '(uncategorized)' when Odoo carries none." | |
| }, | |
| { | |
| "key": "supplier", | |
| "label": "Supplier", | |
| "type": "text", | |
| "source": "overlay", | |
| "default": true, | |
| "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.", | |
| "shared": true | |
| }, | |
| { | |
| "key": "origin_country", | |
| "label": "Country", | |
| "type": "text", | |
| "source": "overlay", | |
| "default": false, | |
| "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.", | |
| "shared": true | |
| }, | |
| { | |
| "key": "lead_days", | |
| "label": "Lead time (days)", | |
| "type": "int", | |
| "source": "overlay", | |
| "default": true, | |
| "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.", | |
| "shared": true | |
| }, | |
| { | |
| "key": "first_cost", | |
| "label": "First cost", | |
| "type": "currency", | |
| "source": "overlay", | |
| "default": false, | |
| "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.", | |
| "shared": true | |
| }, | |
| { | |
| "key": "price_fisch", | |
| "label": "Fisch price", | |
| "type": "currency", | |
| "source": "odoo", | |
| "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere." | |
| }, | |
| { | |
| "key": "price_royal_1", | |
| "label": "Royal 1 price", | |
| "type": "currency", | |
| "source": "odoo", | |
| "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere." | |
| }, | |
| { | |
| "key": "price_royal_2", | |
| "label": "Royal 2 price", | |
| "type": "currency", | |
| "source": "odoo", | |
| "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere." | |
| }, | |
| { | |
| "key": "rev_ytd", | |
| "label": "Revenue YTD", | |
| "type": "currency", | |
| "source": "odoo", | |
| "default": true, | |
| "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is." | |
| }, | |
| { | |
| "key": "rev_ly", | |
| "label": "Revenue LY", | |
| "type": "currency", | |
| "source": "odoo", | |
| "description": "Same period last year — seasonal wholesale compares like for like." | |
| }, | |
| { | |
| "key": "yoy_pct", | |
| "label": "YoY %", | |
| "type": "pct", | |
| "source": "odoo", | |
| "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)." | |
| }, | |
| { | |
| "key": "qty_ytd", | |
| "label": "Units YTD", | |
| "type": "int", | |
| "source": "odoo", | |
| "description": "Units sold year to date." | |
| }, | |
| { | |
| "key": "orders_ytd", | |
| "label": "Orders YTD", | |
| "type": "int", | |
| "source": "odoo", | |
| "description": "Distinct orders containing this SKU, year to date." | |
| }, | |
| { | |
| "key": "on_hand", | |
| "label": "On hand", | |
| "type": "int", | |
| "source": "odoo", | |
| "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." | |
| }, | |
| { | |
| "key": "unit_cost", | |
| "label": "Unit cost", | |
| "type": "currency", | |
| "source": "odoo", | |
| "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller." | |
| }, | |
| { | |
| "key": "inv_value", | |
| "label": "Stock value", | |
| "type": "currency", | |
| "source": "odoo", | |
| "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller." | |
| }, | |
| { | |
| "key": "qty_ltm", | |
| "label": "Units LTM", | |
| "type": "int", | |
| "source": "odoo", | |
| "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller." | |
| }, | |
| { | |
| "key": "dos", | |
| "label": "Days of supply", | |
| "type": "int", | |
| "source": "odoo", | |
| "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller." | |
| }, | |
| { | |
| "key": "cover_gap_d", | |
| "label": "Cover gap (days)", | |
| "type": "int", | |
| "source": "odoo", | |
| "default": false, | |
| "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands." | |
| }, | |
| { | |
| "key": "stock_bucket", | |
| "label": "Stock status", | |
| "type": "select", | |
| "source": "odoo", | |
| "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller." | |
| } | |
| ] | |
| } | |
| } | |