Bỏ qua để đến nội dung

Catalog and products (Vendor)

Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.

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.


GoalsNon-goals
Map all catalog URLs to screensParity with every admin catalog field
Enable merchandising within RBACReplace PIM or external MDM

  • 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). Pass include_sales=false on pickers that do not show the sales column to skip order_details aggregation.
  • SaaS data model: tenancy is vendor_idstoresitems.store_id (plus {prefix}item_stores for multi-store SKUs). Run migration 1783650000000-VendorCatalogListQueryIndexes on each environment so MySQL can index that path: stores.vendor_id, items composites (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, and order_details.item_id for per-page sales rollup.
  • List rows include boolean recommended (legacy items.recommended) and is_recommend (items.is_recommend). Inline switches PATCH PATCH /vendor/products/:id with recommended, is_recommend, and/or status (0 draft / 1 active / 2 archived). The combined body field is_recommended (when used) still sets both columns together, matching the admin catalog DTO.

  1. Pick store (if applicable) → open Products list.
  2. Create → fill → save → detail.
  3. Maintain categories/units as needed from subroutes.

  • Pricing/cost fields may be sensitive—restrict screenshots and exports per policy.

IDScenarioExpected
VC1Employee with productCatalog screens open
VC2Employee without productBlocked by ProtectedRoute
VC3Create productPersists for selected store scope

RouteScreen
/dashboard/productsProduct list
/dashboard/products/createCreate product
/dashboard/products/:idProduct detail
/dashboard/products/:id/editEdit product
/dashboard/products/categoriesCategories
/dashboard/products/unitsUnits
/dashboard/products/taxesStore tax profiles (store_taxes)
/dashboard/products/attributesAttributes
/dashboard/products/tagsTags
/dashboard/products/suppliersSuppliers list
/dashboard/products/suppliers/:idSupplier detail
/dashboard/products/addonsPlaceholder — shared PlaceholderPage (“Addons”) until implemented
/dashboard/products/price-listsPrice lists index
/dashboard/products/price-lists/:idPrice 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.

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.

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.

On the product Info panel, the Grocery & POS catalog block configures checkout behavior:

  • Scan quantity mode (item_type) — NORMAL, WEIGHT, PER_UNIT, PRICE_EMBEDDED, or QTY_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_UNIT items 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_type to PER_UNIT. When Price unit has a value (e.g. LB, 1 lb, or bare 1 for 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 into store_taxes (Name → profile name; Tax Rate → percent — Clover decimal fractions such as 0.0915 become 9.15%; Tax Amount → flat amount rate). On Items, the Tax Rates column links the product to that profile via items.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.
  • /dashboard/products/taxes — CRUD for store_taxes: reusable rate profiles scoped to vendor and optionally a store (store_id null = vendor-wide). API: GET/POST/PATCH/DELETE /vendor/store-taxes.
  • Product linkitems.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_type on 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 as custom_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.stock is 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_id and returns base_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.

LayerBusiness questionSource of truthWritten whenVendor tab
1 — Catalog retailWhat list price did we set on the product?items_selling_price_historyitems.price changes (vendor/admin product update)Selling price history
2 — Purchase costWhat did we pay suppliers per receipt?item_cost_price_eventsPurchase lot → DeliveredPurchase cost history
3 — Realized sellWhat 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.

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
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…"]

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.updaterecordItemSellingPriceHistoryIfChanged (item-selling-price-history.util.ts).

SurfaceBehavior
Vendor detailTab Selling price history
APIGET /vendor/products/:id/selling-price-history?page=&limit=
ScopeStore-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.updateLotStatusrecordItemCostPriceEventsFromDeliveredLot (item-cost-price-event.util.ts).

SurfaceBehavior
Vendor detailTab Purchase cost history (optional supplier_id filter)
APIGET /vendor/products/:id/cost-price-history?page=&limit=&supplier_id=
DedupUnique 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.

SurfaceBehavior
Vendor detailTab Sold on orders (links to /dashboard/orders/:id)
APIGET /vendor/products/:id/realized-selling-prices?page=&limit=
FutureOptional 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

EndpointQuery paramsResponse highlights
GET …/selling-price-historypage, limitold_price, new_price, profit_percent, changed_by_name
GET …/cost-price-historypage, limit, supplier_id?unit_cost, quantity, supplier_name, lot_id, event_at
GET …/realized-selling-pricespage, limitunit_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)”
ConcernBackendFrontend
Catalog price writeitem-selling-price-history.util.ts, vendor-products.service.ts, admin-products.service.tsProductSellingPriceHistoryTab.tsx, vendor-products.api.ts
Lot cost writeitem-cost-price-event.util.ts, vendor-inventory.service.tsProductCostPriceHistoryTab.tsx
Order line readvendor-products.service.tslistRealizedSellingPricesProductRealizedSellingPricesTab.tsx
Schemaitem-cost-price-event.entity.ts, migration 1783520000000en.ts / vi.ts under products.detail.*

DoDon’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 APIsOne ambiguous “price history” mixing catalog, cost, and orders
Use avg_purchase_price for current stock UITreat it as historical cost timeline
Paginate all history endpointsLoad unbounded rows in the UI

IDScenarioExpected
P1Change product retail price on editNew row in Selling price history
P2Save same price againNo duplicate history row
P3Deliver purchase lotRows in Purchase cost history with supplier
P4Filter cost history by supplier_idOnly that supplier
P5Paid + completed order lineRow in Sold on orders with net unit
P6Pending/unpaid orderNo row in Sold on orders

  1. Run migration 1783520000000-ItemCostPriceEvents.
  2. Smoke-test all three tabs on one product (price change, delivered lot, completed order).
  3. Verify history GET routes are not shadowed by GET :id.