Catalog and products (Vendor)
Executive summary
Section titled “Executive summary”Vendor catalog spans products plus categories, units, attributes, tags, and suppliers subroutes. Access is gated by module product (and related rules) per vendor-module-access.ts.
Goals and non-goals
Section titled “Goals and non-goals”| Goals | Non-goals |
|---|---|
| Map all catalog URLs to screens | Parity with every admin catalog field |
| Enable merchandising within RBAC | Replace PIM or external MDM |
Current platform behavior
Section titled “Current platform behavior”- Routes listed in
app-router.tsx(see table); create/edit flows need write where enforced. - Store context from selector applies to scoped APIs.
- Product list (
GET /vendor/products) is paginated (page,limit; max 500 per request). Large catalogs (e.g. thousands of SKUs) stay fast because the API returns one page at a time and uses a lighter count query (fewer joins when search/stock filters do not need them). Passinclude_sales=falseon pickers that do not show the sales column to skiporder_detailsaggregation. - SaaS data model: tenancy is
vendor_id→stores→items.store_id(plus{prefix}item_storesfor multi-store SKUs). Run migration1783650000000-VendorCatalogListQueryIndexeson each environment so MySQL can index that path:stores.vendor_id,itemscomposites (store_id+admin_only/status/category_id/brand_id/barcode/sku_code), pivots (item_tag,item_types,item_suppliers,lot_items,lots.supplier_id),{prefix}stocks.item_id,{prefix}item_variations, andorder_details.item_idfor per-page sales rollup. - List rows include boolean
recommended(legacyitems.recommended) andis_recommend(items.is_recommend). Inline switches PATCHPATCH /vendor/products/:idwithrecommended,is_recommend, and/orstatus(0draft /1active /2archived). The combined body fieldis_recommended(when used) still sets both columns together, matching the admin catalog DTO.
Sequence: happy path
Section titled “Sequence: happy path”- Pick store (if applicable) → open Products list.
- Create → fill → save → detail.
- Maintain categories/units as needed from subroutes.
Security and operations
Section titled “Security and operations”- Pricing/cost fields may be sensitive—restrict screenshots and exports per policy.
Test scenarios (UAT / QA)
Section titled “Test scenarios (UAT / QA)”| ID | Scenario | Expected |
|---|---|---|
| VC1 | Employee with product | Catalog screens open |
| VC2 | Employee without product | Blocked by ProtectedRoute |
| VC3 | Create product | Persists for selected store scope |
Routes (app-router.tsx)
Section titled “Routes (app-router.tsx)”| Route | Screen |
|---|---|
/dashboard/products | Product list |
/dashboard/products/create | Create product |
/dashboard/products/:id | Product detail |
/dashboard/products/:id/edit | Edit product |
/dashboard/products/categories | Categories |
/dashboard/products/units | Units |
/dashboard/products/taxes | Store tax profiles (store_taxes) |
/dashboard/products/attributes | Attributes |
/dashboard/products/tags | Tags |
/dashboard/products/suppliers | Suppliers list |
/dashboard/products/suppliers/:id | Supplier detail |
/dashboard/products/addons | Placeholder — shared PlaceholderPage (“Addons”) until implemented |
/dashboard/products/price-lists | Price lists index |
/dashboard/products/price-lists/:id | Price list detail and per-SKU prices |
On product detail, sections are grouped in a left rail on large screens (Catalog, Pricing, Inventory, Sales); smaller screens keep a horizontal tab strip. Query ?tab= still selects a section (omit for Overview). Tabs Selling price history, Purchase cost history, and Sold on orders map to the three tracking layers below — see Price & cost tracking.
Access (RBAC)
Section titled “Access (RBAC)”Path prefixes under /dashboard/products map to module product in vendor-module-access.ts (ROUTE_RULES). Create/edit URLs require write-level permission for product (owners always pass). See Vendor panel overview for the full matrix.
Shared concepts with admin catalog
Section titled “Shared concepts with admin catalog”Vendor product screens reuse the same dashboard/table/form patterns and many shared components from @indochina/shared (forms, panels, catalog JSON blocks where applicable). Workflows align with Admin catalog and products where both panels manage the same catalog concepts (items, categories, suppliers, etc.); vendor scope is store/vendor-facing rather than zone-wide admin operations.
Grocery & POS catalog (create / edit)
Section titled “Grocery & POS catalog (create / edit)”On the product Info panel, the Grocery & POS catalog block configures checkout behavior:
- Scan quantity mode (
item_type) —NORMAL,WEIGHT,PER_UNIT,PRICE_EMBEDDED, orQTY_EMBEDDED. Drives how POS interprets barcodes (e.g. weight-embedded EAN-13) and how Generate barcode on variants builds shelf labels. Leave blank for default NORMAL. On the POS workspace → Sale screen,PER_UNITitems open a quantity dialog (manual entry or USB scale via Web Serial in Chrome/Edge) before the line is added to the cart. - Clover inventory import — On the Clover Items sheet: when Price Type is per unit (case-insensitive), import sets
item_typetoPER_UNIT. When Price unit has a value (e.g.LB,1 lb, or bare1for per-pound), import maps it to the product unit only if the item does not already have a unit; matching is case-insensitive and treats pound variants (LB,Lb,1 lb,1) as the same unit so one Lb row is reused per store during the import batch (missing units are created once). When the workbook includes a Tax Rates or Taxes sheet, each row is upserted intostore_taxes(Name→ profile name; Tax Rate → percent — Clover decimal fractions such as0.0915become9.15%; Tax Amount → flatamountrate). On Items, the Tax Rates column links the product to that profile viaitems.store_tax_id(variant rows inherit the tax name from the preceding group header row when their cell is empty). - PLU — Optional produce lookup code; required for embedded-weight / price-embedded barcode layouts when those modes are selected.
- Auto-add to purchase when low stock (
auto_add_purchase_on_low_stock) — When enabled, the SKU is included in purchase-lot Add low-stock items (GET /vendor/products/low-stock?auto_add_purchase_only=1) and in the background job that maintains a per-store draft lot (note=auto_low_stock_purchase). See Inventory for cron details. Separate from Retail (POS catalog channel). - Retail / Wholesale catalog channels, No sales tax, and SNAP / EBT eligible — same block; saved with the product payload.
Store tax profiles and per-SKU tax
Section titled “Store tax profiles and per-SKU tax”/dashboard/products/taxes— CRUD forstore_taxes: reusable rate profiles scoped to vendor and optionally a store (store_idnull = vendor-wide). API:GET/POST/PATCH/DELETE /vendor/store-taxes.- Product link —
items.store_tax_id(prefixed column) points at an active profile. On the product Commission tab: pick a store tax profile, or leave empty and set custom tax (tax+tax_typeon the item). - Checkout resolution — If the item is not tax-exempt, POS uses the linked profile when active; otherwise custom tax on the SKU; otherwise no line tax. Effective tax is what clients see on catalog/POS payloads (
tax/tax_type); raw custom values are also exposed ascustom_tax_*when a profile is linked.
Variant sell unit, pack size, and base SKU
Section titled “Variant sell unit, pack size, and base SKU”When editing a product on create or edit (/dashboard/products/create, /dashboard/products/:id/edit):
- Product-level unit — Still set via the main form
unit_id; it is the default sell unit for all variations when a row does not override it. - Per-variation Sell unit — In the variations table: same as product (inherit) or choose another unit from the store’s unit list.
- Units / pack — Optional multiplier for the SKU (e.g. 24 for a case of 24 cans). Used for reporting and optional base-SKU linkage; stock is not auto-converted between pack and base on the catalog row today (warehouse truth lives in
stocks;items.stockis a synced aggregate — see Inventory stock model). - Base SKU — Shown only when there are multiple variations. After Units / pack is a positive number, you can pick another variation as the logical “base” row; the API stores this as
base_variation_idand returnsbase_variant(variation key) for editing.
Technical reference (migration, sync, JSON keys, POS): Catalog — variant sell unit and pack.
Price & cost tracking (Phases 1–3 — shipped)
Section titled “Price & cost tracking (Phases 1–3 — shipped)”Product detail (/dashboard/products/:id) exposes three separate histories. Each answers a different business question. They must not be merged into one table or rebuilt by scanning orders on every page load.
What each layer means
Section titled “What each layer means”| Layer | Business question | Source of truth | Written when | Vendor tab |
|---|---|---|---|---|
| 1 — Catalog retail | What list price did we set on the product? | items_selling_price_history | items.price changes (vendor/admin product update) | Selling price history |
| 2 — Purchase cost | What did we pay suppliers per receipt? | item_cost_price_events | Purchase lot → Delivered | Purchase cost history |
| 3 — Realized sell | What did customers actually pay on orders? | order_details (+ orders filters) | Read-only at query time (no event table yet) | Sold on orders |
Snapshot vs history: stocks.avg_purchase_price is only the current weighted average after stock moves — not a timeline. Use layer 2 for cost over time.
Architecture overview
Section titled “Architecture overview”flowchart TB
subgraph writes["Event writers (append-only)"]
W1["PATCH product\n(vendor + admin)"]
W2["Lot status → Delivered\n(inventory)"]
end
subgraph tables["History tables"]
T1[("items_selling_price_history")]
T2[("item_cost_price_events")]
end
subgraph reads["Read paths"]
R1["GET …/selling-price-history"]
R2["GET …/cost-price-history"]
R3["GET …/realized-selling-prices"]
end
subgraph orders["Orders (existing)"]
OD[("order_details")]
O[("orders")]
end
W1 -->|"recordItemSellingPriceHistoryIfChanged"| T1
W2 -->|"recordItemCostPriceEventsFromDeliveredLot"| T2
T1 --> R1
T2 --> R2
OD --> R3
O --> R3
UI["Product detail tabs"] --> R1
UI --> R2
UI --> R3
Product detail — tab flow
Section titled “Product detail — tab flow”flowchart LR PD["/dashboard/products/:id"] PD --> O["Overview"] PD --> SPH["Selling price history"] PD --> CPH["Purchase cost history"] PD --> RSP["Sold on orders"] PD --> CAT["Catalog / stock / orders / lots…"]
Phase 1 — Catalog selling price history
Section titled “Phase 1 — Catalog selling price history”When a vendor or admin updates the catalog retail price (items.price, including when variations recalculate the header price), the API appends a row to items_selling_price_history (old_price, new_price, profit_percent, changed_by, created_at).
Writers: VendorProductsService.update and AdminProductsService.update → recordItemSellingPriceHistoryIfChanged (item-selling-price-history.util.ts).
| Surface | Behavior |
|---|---|
| Vendor detail | Tab Selling price history |
| API | GET /vendor/products/:id/selling-price-history?page=&limit= |
| Scope | Store-scoped product access; paginated newest first |
This tracks catalog list price only — not checkout overrides, price lists, or order line prices.
sequenceDiagram actor User as Vendor or admin participant API as Product PATCH API participant Item as items participant Hist as items_selling_price_history participant Tab as Selling price history tab User->>API: PATCH product (new price) API->>Item: UPDATE price API->>Hist: INSERT if price changed User->>Tab: Open tab Tab->>API: GET selling-price-history API->>Hist: Paginated SELECT by item_id API-->>Tab: old/new price, actor, date
Phase 2 — Purchase cost & supplier (from lots)
Section titled “Phase 2 — Purchase cost & supplier (from lots)”When a purchase lot is marked delivered, each lot_items line appends a row to item_cost_price_events. Existing delivered lots are backfilled on migration 1783520000000-ItemCostPriceEvents.
Writer: VendorInventoryService.updateLotStatus → recordItemCostPriceEventsFromDeliveredLot (item-cost-price-event.util.ts).
| Surface | Behavior |
|---|---|
| Vendor detail | Tab Purchase cost history (optional supplier_id filter) |
| API | GET /vendor/products/:id/cost-price-history?page=&limit=&supplier_id= |
| Dedup | Unique on (lot_item_id, source) — redelivery does not duplicate |
stocks.avg_purchase_price remains the current snapshot only.
sequenceDiagram
actor User as Vendor inventory
participant Inv as updateLotStatus
participant Lot as lots + lot_items
participant Stock as stocks
participant Ev as item_cost_price_events
participant Tab as Purchase cost history tab
User->>Inv: Deliver lot (status 1)
Inv->>Lot: pending → delivered
loop Each lot_items line
Inv->>Stock: Update qty + avg_purchase_price
Inv->>Ev: INSERT cost event
end
User->>Tab: Open tab
Tab->>API: GET cost-price-history
API-->>Tab: unit_cost, supplier, lot #, date
See Inventory (Vendor) for lots and deliver workflow.
Phase 3 — Realized selling price (from orders)
Section titled “Phase 3 — Realized selling price (from orders)”Paginated read of paid, delivered/completed order_details for the product. Line economics use computeOrderDetailLineCommerce (same as order detail API).
Not catalog price history — use Phase 1 for that. No new history table in v1.
| Surface | Behavior |
|---|---|
| Vendor detail | Tab Sold on orders (links to /dashboard/orders/:id) |
| API | GET /vendor/products/:id/realized-selling-prices?page=&limit= |
| Future | Optional nightly aggregate if query volume grows |
sequenceDiagram actor User as Vendor participant Tab as Sold on orders tab participant API as listRealizedSellingPrices participant OD as order_details participant O as orders User->>Tab: Open tab Tab->>API: GET realized-selling-prices API->>OD: JOIN orders, filter item + store + paid + completed API-->>Tab: unit_price, unit_net_price, line_net, order link
API quick reference
Section titled “API quick reference”| Endpoint | Query params | Response highlights |
|---|---|---|
GET …/selling-price-history | page, limit | old_price, new_price, profit_percent, changed_by_name |
GET …/cost-price-history | page, limit, supplier_id? | unit_cost, quantity, supplier_name, lot_id, event_at |
GET …/realized-selling-prices | page, limit | unit_price, unit_net_price, line_net_total, order_id, sold_at |
Register these routes before GET /vendor/products/:id in vendor-products.controller.ts.
Code map (update this doc when code changes)
Section titled “Code map (update this doc when code changes)”| Concern | Backend | Frontend |
|---|---|---|
| Catalog price write | item-selling-price-history.util.ts, vendor-products.service.ts, admin-products.service.ts | ProductSellingPriceHistoryTab.tsx, vendor-products.api.ts |
| Lot cost write | item-cost-price-event.util.ts, vendor-inventory.service.ts | ProductCostPriceHistoryTab.tsx |
| Order line read | vendor-products.service.ts → listRealizedSellingPrices | ProductRealizedSellingPricesTab.tsx |
| Schema | item-cost-price-event.entity.ts, migration 1783520000000 | en.ts / vi.ts under products.detail.* |
Design rules (do / don’t)
Section titled “Design rules (do / don’t)”| Do | Don’t |
|---|---|
| Append events at the business moment (price edit, lot delivered) | Scan all order_details on every product view for catalog history |
| Keep three tabs and three APIs | One ambiguous “price history” mixing catalog, cost, and orders |
Use avg_purchase_price for current stock UI | Treat it as historical cost timeline |
| Paginate all history endpoints | Load unbounded rows in the UI |
Test scenarios (price tracking)
Section titled “Test scenarios (price tracking)”| ID | Scenario | Expected |
|---|---|---|
| P1 | Change product retail price on edit | New row in Selling price history |
| P2 | Save same price again | No duplicate history row |
| P3 | Deliver purchase lot | Rows in Purchase cost history with supplier |
| P4 | Filter cost history by supplier_id | Only that supplier |
| P5 | Paid + completed order line | Row in Sold on orders with net unit |
| P6 | Pending/unpaid order | No row in Sold on orders |
Deployment checklist
Section titled “Deployment checklist”- Run migration
1783520000000-ItemCostPriceEvents. - Smoke-test all three tabs on one product (price change, delivered lot, completed order).
- Verify history
GETroutes are not shadowed byGET :id.
Related
Section titled “Related”- Price lists (Vendor) — named lists,
GET/PATCH :id/items, andvariant_keyfor JSON variants. - Catalog — variant sell unit and pack — data model and API notes.
- Inventory stock model (ADR) — SSOT,
items.stockcache vs virtual field, phases. - Inventory (Vendor) — stock, warehouses, lots.
- Vendor panel overview — navigation.