ADR-0006. Inventory cho COMBO — bung thành SaleOrderItem con tại lúc thêm vào giỏ
| Trường | Giá trị |
|---|---|
| Status | Accepted |
| Date | 2026-05-14 |
| Deciders | inventory-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ĩa | Nổ ra qua |
|---|---|---|
KIT | có BOM, nổ ra lúc bán | MaterialRecipe → Materials |
COMBO | có bundle, nổ ra lúc bán | ProductBundler(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 KIT ↔ STORABLE).
Tại sao không dùng
Category.type? Phiên bản trước phân biệt combo quaCategory.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.typethà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-add | v2 — Bung chỉ ở inventory layer | |
|---|---|---|
| Reference | commercetools | Toast / Medusa Inventory Kits |
| Sale layer | Có nhánh combo trong _addProductItem; lead + N row con | Sale layer không đổi; combo PV = 1 row |
| Bill / receipt | Thấy component | Chỉ hiện "Burger Combo" |
| KDS | Đọc trực tiếp children | Cần bung riêng |
| Reporting | Aggregate per-component tự nhiên | Cần bung tại report time |
| Refund per-component | Có row thật | Cần reverse-expansion |
| Drift risk | Khô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ể:
- CDC handler tự skip InventoryItem seed —
COMBOkhông nằm trongSTOCKABLE_SET, nên guardif (!ProductVariantTypes.isStockable(type)) return;có sẵn tronghandleProductVariantCDCđã loại nó. Không cần category lookup. - 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) walkProductBundlerra leaf variants. Service nhận biết nested combo bằng cách kiểm tratypecủ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).
- Reject re-add cùng 1 combo (
- Combo atomic theo POV người dùng. Sửa/xoá trực tiếp row có
leadItemId != nullbị reject vớiCOMBO_CHILD_EDIT_FORBIDDEN. Sửa lead cascade: scale qty lead → scale qty từng child + per-childapplyReservationDelta. - 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. - Nested combo (combo of combo) hỗ trợ recursive.
extractComboItemsđệ quy vớiMAX_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.typehoặ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ớiKIT↔STORABLE.
Tham chiếu nghiên cứu
- commercetools — Managing static product bundles
- Medusa — Inventory Kits
- Toast — Stock Depletion
- Modifier Handling — Why "Add Bacon" Should Update Food Cost
Files
packages/core/src/models/schemas/public/product-variant/constants.ts—ProductVariantTypes.COMBO+isCombo()packages/core/src/services/inventory/bundle-expansion.service.ts—extractComboItems; đệ quy dựa trênvariant.typepackages/core/src/models/schemas/sale/sale-item/constants.ts— mở rộngTSaleOrderItemMetadatathêmcombo(optional)packages/core/src/migrations/drizzle/public/0008_combo_variant_type_backfill.sql— backfillProductVariant.typetừCategory.typecũpackages/sale/src/services/sale.service.ts:_addComboProductItem— branch theoisCombo(variant.type)packages/sale/src/services/sale-order-item.service.ts:update— cascade lead-driven + guard sửa childpackages/sale/src/services/order-split.service.ts:_assertCombosAtomicAcrossGroupspackages/sale/src/errors/sale.errors.ts— 6 lỗi COMBO_*packages/inventory/src/services/inventory-worker.service.ts:handleProductVariantCDC— COMBO tự skip qua guardisStockable()