Skip to content

ADR-0006. Inventory cho COMBO — bung thành SaleOrderItem con tại lúc thêm vào giỏ

TrườngGiá trị
StatusAccepted
Date2026-05-14
Decidersinventory-team, sale-team, PM
Supersedes

Bối cảnh

ProductBundler link 1 PV "lead" tới N PV "related", phân loại theo COMBO / ADDON / FBT. Trước thay đổi này, bán 1 COMBO PV (vd. "Burger Combo" = 1 Burger + 2 Drinks) không trừ kho gì — các component vật lý bị thất thoát.

Mô hình discriminator

Một COMBO được nhận biết qua ProductVariant.type === '301_COMBO' — một giá trị của enum ProductVariantTypes có sẵn, đặt cạnh KIT:

ProductVariantTypesÝ nghĩaNổ ra qua
KITcó BOM, nổ ra lúc bánMaterialRecipe → Materials
COMBOcó bundle, nổ ra lúc bánProductBundler(type=COMBO) → ProductVariants

COMBO không nằm trong STOCKABLE_SET, nên combo variant là ảo — không có InventoryItem riêng, không được CDC handler seed. Đây là quyết định structural per-variant tại lúc tạo (cùng nhóm change-guard với KITSTORABLE).

Tại sao không dùng Category.type? Phiên bản trước phân biệt combo qua Category.type === COMBO. Field đó mutable và fan-out tới N variants — đổi type của 1 category, hoặc move Product giữa các category, sẽ âm thầm lật variant giữa physical/virtual, gây thất thoát kho. Industry reference xác nhận structural type thuộc về variant/product, gần-bất-biến, không thuộc mutable category (commercetools làm Product Type immutable; Odoo guard việc đổi product-type khi đã có stock move). Discriminator giờ nằm ở ProductVariant.type; Category.type thành nhãn FE-grouping thuần, không còn load-bearing cho inventory.

Tại sao ADDON / FBT không cần marker ProductVariant.type

Chỉ COMBO lead là ảo. ADDON và FBT chỉ liên quan variant vật lý ở cả 2 đầu — addon "extra cheese" là 1 variant STORABLE thường, có stock riêng. Cái làm nó thành addon/FBT thuần là link ProductBundler, không phải bản chất variant. Nên ProductBundler vẫn là 1 bảng cho cả 3 loại quan hệ; chỉ COMBO cần thêm marker variant-type.

Các approach đã xem xét

v1 — Bung tại cart-addv2 — Bung chỉ ở inventory layer
ReferencecommercetoolsToast / Medusa Inventory Kits
Sale layerCó nhánh combo trong _addProductItem; lead + N row conSale layer không đổi; combo PV = 1 row
Bill / receiptThấy componentChỉ hiện "Burger Combo"
KDSĐọc trực tiếp childrenCần bung riêng
ReportingAggregate per-component tự nhiênCần bung tại report time
Refund per-componentCó row thậtCần reverse-expansion
Drift riskKhông (row = sự thật)Reserve vs deduct có thể lệch nếu sửa bundler giữa chừng

Quyết định

v1 — bung tại cart-add. Single source of truth nằm ở SaleOrderItem rows, mọi downstream consumer (KDS, báo cáo, refund) đọc trực tiếp không cần bung riêng.

Cụ thể:

  1. CDC handler tự skip InventoryItem seedCOMBO không nằm trong STOCKABLE_SET, nên guard if (!ProductVariantTypes.isStockable(type)) return; có sẵn trong handleProductVariantCDC đã loại nó. Không cần category lookup.
  2. Sale cart-add branch theo ProductVariantTypes.isCombo(variant.type) trong _addProductItem. Combo gọi _addComboProductItem:
    • Reject re-add cùng 1 combo (COMBO_ALREADY_IN_ORDER).
    • Gọi BundleExpansionService.extractComboItems (packages/core/src/services/inventory/bundle-expansion.service.ts) walk ProductBundler ra leaf variants. Service nhận biết nested combo bằng cách kiểm tra type của chính variant — không cần category lookup.
    • Reserve mỗi leaf song song qua StockReservationService.applyReservationDelta (path per-PV cũ).
    • Insert lead SaleOrderItem (combo PV; full giá; leadItemId=null).
    • Insert N child rows (leadItemId=lead.id; unitPrice=0; metadata.combo.bundlerRowIds để audit).
  3. Combo atomic theo POV người dùng. Sửa/xoá trực tiếp row có leadItemId != null bị reject với COMBO_CHILD_EDIT_FORBIDDEN. Sửa lead cascade: scale qty lead → scale qty từng child + per-child applyReservationDelta.
  4. Order split reject group chứa combo lead mà thiếu children (hoặc ngược lại) với COMBO_SPLIT_NOT_ATOMIC. Order merge tự kế thừa nhóm combo vì merge cả order.
  5. Nested combo (combo of combo) hỗ trợ recursive. extractComboItems đệ quy với MAX_BUNDLE_DEPTH=5 + cycle guard (COMBO_DEPTH_EXCEEDED, COMBO_CYCLE_DETECTED).

Hệ quả

Ưu

  • KDS, báo cáo, refund hoạt động không cần thêm expansion logic.
  • Không có drift reserve-vs-deduct: cart-add đã commit explosion thành rows; deduct chỉ đọc.
  • ADDON / FBT không cần đổi inventory — PV của chúng đã vật lý.
  • Discriminator là structural type per-variant — đổi Category.type hoặc move Product giữa category không còn ảnh hưởng inventory.

Nhược

  • Nhiều row hơn per combo (1 + N).
  • Edit semantics lead-driven; override per-child ("không cay") cần ticket tương lai.
  • Change-guard cho ProductVariant.type (COMBO ↔ STORABLE) là follow-up — cùng nhóm concern hẹp BANA đã có với KITSTORABLE.

Tham chiếu nghiên cứu

Files

  • packages/core/src/models/schemas/public/product-variant/constants.tsProductVariantTypes.COMBO + isCombo()
  • packages/core/src/services/inventory/bundle-expansion.service.tsextractComboItems; đệ quy dựa trên variant.type
  • packages/core/src/models/schemas/sale/sale-item/constants.ts — mở rộng TSaleOrderItemMetadata thêm combo (optional)
  • packages/core/src/migrations/drizzle/public/0008_combo_variant_type_backfill.sql — backfill ProductVariant.type từ Category.type
  • packages/sale/src/services/sale.service.ts:_addComboProductItem — branch theo isCombo(variant.type)
  • packages/sale/src/services/sale-order-item.service.ts:update — cascade lead-driven + guard sửa child
  • packages/sale/src/services/order-split.service.ts:_assertCombosAtomicAcrossGroups
  • packages/sale/src/errors/sale.errors.ts — 6 lỗi COMBO_*
  • packages/inventory/src/services/inventory-worker.service.ts:handleProductVariantCDC — COMBO tự skip qua guard isStockable()

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.