2026-05-10 on prod. Adds the cafe-modifier pattern Narong asked for on the 2026-05-07 call (ice/hot, sugar level). Three new tables back the relational model (cafe.product_attributes + product_attribute_values + product_attribute_assignments); chosen variants snapshot to cafe.order_lines.variants jsonb. Both Starter and Pro tiers. New Modifiers tab in Settings, per-product Attributes section in product edit form, POS variant picker on add-to-cart, lineKey-based cart dedup so “Latte hot” and “Latte iced” stay on separate cart lines. Pro Odoo sync appends variant labels to pos.order.line.customer_note (real product.attribute Odoo modeling deferred per scope).
cafe.order_lines.variants jsonb populated, switch to Orders tab → Paid filter → click order → variant subtitle in detail panel. Cleanup runs in finally{} (force-close session, deactivate cashier, drop test attribute).
order_lines.variants jsonb. lib/db/product_attributes.ts with bulk listAssignmentsForProducts (db.transaction-wrapped to dodge Hyperdrive cache).is_required + default_value_id.VariantPicker modal opens on add-to-cart for products with attributes. Defaults pre-fill, required attributes block Add. Cart-line dedup key = productId + sortedValueIds via computeLineKey helper. Variants render in cart, drafts (park/resume preserves), receipt, Orders detail.cafe.order_lines.variants persisted at order time. Sync worker maps variants → customer_note on the Odoo pos.order.line ("Iced", or "Iced, No Sugar" for multi-attribute).1606716 (backend) — feat(backend R10): cafe.product_attributes + values + assignments
tables + cafe.order_lines.variants jsonb
migrations/20260510100000_cafe_product_variants.ts
migrate.js (idempotent registry entry)
144fa81 (cafe) — feat(cafe R10): product variants — Modifiers admin + POS variant
picker + lineKey-based cart dedup + variants jsonb on order_lines
+ Pro Odoo customer_note labels (both Starter+Pro)
32 files changed, 1963 insertions(+), 72 deletions(-)
+ lib/db/product_attributes.ts, lib/actions/product_attributes.ts
+ lib/pos/attribute-pairs.ts (server-side helper)
+ app/(authed)/settings/modifiers/{page.tsx, modifiers-client.tsx}
+ app/(authed)/pos/_components/{variant-picker.tsx, line-key.ts}
+ integrations into ProductForm, RegisterShell, Cart, CartLineRow,
pro-handlers, starter-handlers, draft_orders, odoo_sync, queries,
native-receipt, khmer-receipt, orders POST + Odoo sync route, mounts,
lockable-shell, starter-lockable-shell, orders-view detail rendering
e509918 (cafe) — fix(cafe R10): translate cafe.products.id → odoo_product_id
when building POS productAttributePairs
Pro+cafe-master uses ShellProduct.id = String(odoo_product_id), but
cafe.product_attribute_assignments FKs to cafe.products.id (UUID).
buildShellAttributePairs was keying output by cafe UUID → mounts'
attributesByProductId.get(p.id) lookup with the int-stringified id
never matched → picker never opened on prod. Surfaced in the first
Gate 2 run (10/16 with the picker step timing out). Fix: helper now
takes a `Map` translation map; Pro+cafe-master
callers build it from MirrorProduct.cafe_product_id (new field on the
DAO result). Starter passes identity since ShellProduct.id IS
cafe.products.id there.
5 files changed, 53 insertions(+), 20 deletions(-)
| # | Step | Status |
|---|---|---|
| 1 | Schema: 3 attribute tables + order_lines.variants jsonb | pass |
| 2 | Pre-flight: drop prior “Temperature R10” + close stray sessions | pass |
| 3 | SSO login as get-coffee owner (narongix) | pass |
| 4 | Modifiers admin: create attribute “Temperature R10” | pass |
| 5 | Add value “Hot” ($0) | pass |
| 6 | Add value “Iced” (+$0.50) | pass |
| 7 | Assign attribute to “Bread” via product edit form | pass |
| 8 | Create test cashier → capture PIN | pass |
| 9 | Open /cafe/pos/register/7 popup | pass |
| 10 | PIN unlock + open shift $0 | pass |
| 11 | Click “Bread” tile → variant picker opens with both chips | pass |
| 12 | Click Iced chip → picker total reflects +$0.50 extra | pass |
| 13 | Add to cart → cart line shows variant subtitle | pass |
| 14 | Pay cash → success modal | pass |
| 15 | DB: cafe.order_lines.variants jsonb populated for the new line | pass |
| 16 | Orders tab → Paid filter → variant subtitle in detail | pass |
| Suite | Result | Notes |
|---|---|---|
| test-r10-prod.mjs (this ship) | 16/16 | Full Modifiers admin + POS picker + DB persistence flow on get-coffee config 7 |
| test-phase1-prod.mjs | 11/11 | SSO routing + subscription page |
| test-phase2-sso-outdoor-prod.mjs | 6/6 | SSO bridge across products |
| test-phase2-cafe-multishop-prod.mjs | 6/6 | Demo creds, parallel-safe |
| test-m1-prod.mjs | 10/10 | Shop scoping |
| test-r7-prod.mjs | 14/14 | Dashboard + manager-live drawer |
| test-r8-prod.mjs | 4/4 | Auth/security trio (single-session, per-tenant scope) |
| test-r9-prod.mjs | 16/16 | Orders view + per-line refund |
83/83 effective across the R10 ship + the full regression sweep. Sequential execution per the post-R8.2 sid rule (parallel runs sharing the narongix cred would mutually kick).
Schema:
cafe.product_attributes — tenant-scoped templates (name, name_kh, sort_order)
cafe.product_attribute_values — options under each attribute, with price_extra_usd
cafe.product_attribute_assignments — (product_id, attribute_id) PK + is_required + default_value_id
cafe.order_lines.variants — JSONB snapshot of chosen values at order time
Cart-line dedup:
computeLineKey(productId, variants) returns either productId (no variants) or
`${productId}:${sortedValueIds.join(",")}`. Sort is order-independent so the same
selection in any array order produces the same key. RegisterShell uses lineKey
for adjustQty / removeLine / setLineDiscount and as the React key.
Picker UX:
VariantPicker reads product.attributes (from buildShellAttributePairs server-side).
Each attribute renders as a row of selectable chips. Required attributes block
the Add button until chosen. Defaults pre-fill from assignment.default_value_id.
Cancel discards the click; Add computes unitPriceUsd = base + sum(extras), pushes
to cart with the lineKey + variants[] snapshot.
Persistence:
cafe.order_lines.variants jsonb populated on createNativeOrder (orders.ts).
Drafts (cafe.draft_orders.payload jsonb) carry variants in payload.lines[].variants
so park/resume preserves the choices.
Pro Odoo sync:
Worker reads cafe.order_lines.variants, joins valueName labels with ", ",
passes as CreateOrderLine.variantNote → pos.order.line.customer_note in Odoo.
Skips real product.attribute modeling (heavy schema + Narong hasn't asked
for variant-level reporting on the Odoo side yet — defer until requested).
Tier scope:
Both Starter (uuid product ids) and Pro+cafe-master (Odoo-int shell ids).
buildShellAttributePairs takes a Map<cafeProductId, shellId> translation
so the page-level wiring decouples assignment lookup (cafe UUIDs) from the
ShellProduct id used by tiles + the picker's productAttributePairs map.
1. Pro+cafe-master uses two distinct product ids on the same row. cafe.products.id (UUID, real PK) is what assignments + product edit URLs key off. ShellProduct.id (= String(cafe.products.odoo_product_id)) is what the POS register tiles use, since the legacy Pro path (Odoo direct) hands int ids straight through to the shell. The first Gate 2 run failed at 10/16 because buildShellAttributePairs keyed output by the UUID but the mount looked up by the Odoo int. Fixed by introducing a translation map at the helper boundary + threading cafe_product_id through MirrorProduct. 2. Bundle-all gate cycles surface integration bugs the per-phase runs would have caught earlier. R10.1–R10.4 typechecked clean and the local DAO probe passed in isolation, but the cross-component assumption that ShellProduct.id matched cafe.products.id only failed on real Pro+cafe-master data. Future ARCs that bundle should still run a brief end-to-end smoke against prod-shaped data before declaring local "Gate 1 done." 3. Hyperdrive cache wraps weren't needed for the picker reads (we set them up in listAssignmentsForProducts proactively). Confirmed — the first variant-on-product test resolved cleanly without read staleness. Good that the pattern is now reflexive after R9's two repro cases.
loading…