Bulk-create products + variants via Narong's canonical XLSX template. Source:
Notion test-run doc item #12 "Product Import"; spec is the drawio
flow diagram + nix_pos_import_template.xlsx + the Figma 3-stage UI.
Client-side parse via SheetJS keeps bytes off the Worker; per-row transactions so
one bad row doesn't roll back the rest.
xlsx parses the file client-side. Auto-maps each column header to a NIX field. "Import Template for Products" download served from /public/import-templates/.migrations/20260527110000_u9_product_template_track_inventory.ts + migrate.js entry — ADD COLUMN IF NOT EXISTS track_inventory BOOLEAN NOT NULL DEFAULT false. Idempotent. No backfill (default applies).lib/db/schema.ts — cafeProductTemplate.trackInventory: boolean("track_inventory").notNull().default(false).lib/db/product_import.ts — importRows(tenantId, rows) + importProductRow(...) walk Narong's drawio flow. Each row in its own transaction so a bad row doesn't roll back its predecessors. Find-or-create category → find-or-create template (lock track_inventory/nameKh/categoryId/isHidden at template-create) → resolve attribute + value pairs (find-or-create both) → ensure product_template_attribute_line + value_rel rows exist → check combo existence → create variant + combo rows OR skip. For single-attribute rows: also patch attribute_value.priceExtraUsd = row.sales_price − template.sales_price (per scope).lib/import/xlsx_normalize.ts — pure-fn header auto-mapping + per-row field validation (booleans, decimals, "Attribute: Value" parsing). Detects within-file duplicate SKUs. Required: product_name_en + sales_price.lib/actions/product_import.ts — validateImportAction (Test — no DB writes) + runProductImportAction (Import). Same gate as /cafe/products CRUD: Starter or Pro+cafe-master.app/(authed)/products/import/page.tsx — server wrapper, force-dynamic, perm + tier gates.app/(authed)/products/import/import-client.tsx — 3-stage UI. Dynamic import("xlsx") on file pick so the page chunk stays small. Outside-click / Escape close on the dropdown. data-testids on every interactive element.public/import-templates/nix_pos_import_template.xlsx — Narong's canonical template shipped as a public asset (admin downloads the blank).app/(authed)/products/products-list-client.tsx — "+ Product" button replaced by NewMenu dropdown with 4 items: New record (active — opens existing single-product modal) · Import records (active — routes to /products/import) · Export All (disabled — "Coming soon") · Spreadsheet (disabled — "Coming soon").| ✓ | Migration .ts: ADD COLUMN IF NOT EXISTS track_inventory BOOLEAN NOT NULL DEFAULT false + DROP COLUMN on down() |
| ✓ | migrate.js registry: U9 entry slots after U8 |
| ✓ | schema.ts: cafeProductTemplate.trackInventory declared with expected shape |
| ✓ | Postgres: cafe.product_template.track_inventory exists, default false, NOT NULL |
| ✓ | Public template shipped: /public/import-templates/nix_pos_import_template.xlsx with 9 expected headers (verified via SheetJS read) |
| ✓ | xlsx_normalize.ts: NIX_FIELDS exports 9 expected slugs |
| ✓ | autoMapHeader + normalizeSheet: required-field rejection + Café Latte × 3 parse (sales_price normalized "1.25"/"1.75"/"2.00"; product_values parsed to {attribute, value} pairs) |
| ✓ | DAO round-trip on local lumiere: feed Café Latte × 3 → 1 template + 3 variants + 1 category + 1 attribute + 3 values; re-run same import → variantsSkipped = 3 |
| ✓ | products-list-client: NewMenu replaces "+ Product"; 6 expected testids present; routes Import records to /products/import; "Coming soon" labels on Export + Spreadsheet |
| ✓ | import-client: 3-stage state machine + 18 expected testids covering all interactive surfaces |
| ✓ | actions: validateImportAction + runProductImportAction both exported via "use server" with requireImportSession gate |
Typecheck: npx tsc --noEmit on nix-cafe → 0 errors. Local migration applied via knex.
track_inventory UI toggle on the product detail page — v1 just stores the import value; toggle comes later.attribute_value.priceExtraUsd only patched for single-attribute rows (canonical case). Multi-attribute rows store the variant price on product_product.price_usd as authoritative; per-value extras stay 0 (admin can edit via U6 popover).node migrate.js against prod Supabase from local. Verify cafe.product_template.track_inventory column exists across get-coffee + demo + lumiere.test-u9-prod.mjs on lumiere: SSO → /cafe/products → click "+ New ▾" → Import records → upload nix_pos_import_template.xlsx → Test → Import → assert summary card values → DB verify 1 template + 3 variants + 1 category + 1 attribute + 3 values. Re-upload → skipped = 3.