ADR-0006. Tồn kho cho COMBO - bung thành SaleOrderItem con khi thêm vào giỏ
| Trường | Giá trị |
|---|---|
| Trạng thái | Accepted |
| Ngày | 2026-05-14 |
| Người quyết định | Phat Nguyen |
| Thay thế | - |
Bối cảnh
ProductBundler liên kết một ProductVariant "lead" tới N biến thể "related" thành phần, phân loại là COMBO / ADDON / FBT. Trước thay đổi này, bán một COMBO PV (ví dụ "Burger Combo" = 1 Burger + 2 Drinks) không trừ kho gì cả - các thành phần vật lý của nó bị thất thoát.
Mô hình phân biệt loại
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 | Bung ra qua |
|---|---|---|
KIT | có BOM, bung ra lúc bán | MaterialRecipe → Materials |
COMBO | có bundle, bung ra lúc bán | ProductBundler(type=COMBO) → ProductVariants |
COMBO không nằm trong STOCKABLE_SET, nên một combo variant là ảo - nó không có InventoryItem riêng và không được CDC handler seed. Đây là một quyết định cấu trúc theo từng variant, tại thời điểm tạo (cùng nhóm điều kiện chặn thay đổi với KIT ↔ STORABLE).
Tại sao không dùng
Category.type? Một phiên bản trước phân biệt combo quaCategory.type === COMBO. Trường đó có thể thay đổi và lan tới N biến thể - đổi type của một category, hoặc chuyển một Product giữa các category, sẽ âm thầm lật biến thể giữa vật lý và ảo, gây thất thoát kho. Các tham chiếu trong ngành xác nhận rằng kiểu cấu trúc phải nằm ở variant/product, gần như bất biến, không nằm ở category vốn có thể thay đổi (commercetools làm Product Type bất biến; Odoo chặn việc đổi product-type khi đã có phát sinh xuất nhập kho). Bộ phân biệt loại giờ nằm ởProductVariant.type;Category.typetrở thành nhãn nhóm thuần cho giao diện, không còn mang vai trò quyết định cho tồn kho.
Tại sao ADDON / FBT không cần marker ProductVariant.type
Chỉ COMBO lead là ảo. ADDON và FBT chỉ liên quan tới biến thể vật lý ở cả hai phía - addon "extra cheese" là một biến thể STORABLE thông thường, có tồn kho riêng. Cái khiến nó thành addon/FBT thuần túy là liên kết ProductBundler, không phải bản chất của biến thể. Vì vậy ProductBundler vẫn là một bảng duy nhất cho cả ba loại quan hệ; chỉ COMBO mới cần thêm marker kiểu biến thể.
Các phương án đã xem xét
| v1 - Bung khi thêm vào giỏ | v2 - Chỉ bung ở tầng inventory | |
|---|---|---|
| Tham chiếu | commercetools | Toast / Medusa Inventory Kits |
| Tầng Sale | Thêm nhánh combo trong _addProductItem; lead + N dòng SaleOrderItem con | Tầng Sale không đổi; combo PV là một dòng |
| Hóa đơn / biên lai | Thấy được thành phần | Chỉ hiện dòng "Burger Combo" |
| KDS | Các thành phần con đi qua các đường đọc sẵn có | Cần bung riêng |
| Báo cáo | Tổng hợp theo thành phần một cách tự nhiên | Cần bung lúc làm báo cáo |
| Hoàn tiền theo thành phần | Có dòng thật | Cần bung ngược |
| Rủi ro lệch | Không (dòng chính là sự thật) | Reserve và deduct có thể lệch nếu bundler bị sửa giữa chừng đơn hàng |
Quyết định
v1 - bung khi thêm vào giỏ. Một nguồn sự thật duy nhất nằm trong các dòng SaleOrderItem được mọi consumer phía sau (KDS, báo cáo, hoàn tiền) đọc trực tiếp mà không cần logic bung riêng cho từng consumer.
Cụ thể:
- CDC handler tự bỏ qua việc seed InventoryItem -
COMBOkhông nằm trongSTOCKABLE_SET, nên điều kiện chặnif (!ProductVariantTypes.isStockable(type)) return;có sẵn tronghandleProductVariantCDCđã loại nó ra. Không cần tra cứu category. - Khi thêm vào giỏ, Sale rẽ nhánh theo
ProductVariantTypes.isCombo(variant.type)trong_addProductItem. Combo gọi_addComboProductItem, hàm này:- Từ chối thêm lại cùng một combo (
COMBO_ALREADY_IN_ORDER). - Gọi
BundleExpansionService.extractComboItems(packages/core/src/services/inventory/bundle-expansion.service.ts) để duyệtProductBundlervà tạo ra các biến thể lá. Service nhận biết combo lồng nhau bằng cách kiểm tratypecủa chính mỗi biến thể liên quan - không cần tra cứu category. - Đặt giữ (reserve) từng lá song song qua
StockReservationService.applyReservationDelta(đường xử lý theo từng PV có sẵn). - Chèn dòng lead
SaleOrderItem(combo PV; giá đầy đủ;leadItemId=null). - Chèn N dòng
SaleOrderItemcon (leadItemId=lead.id;unitPrice=0;metadata.combo.bundlerRowIdsđể kiểm toán).
- Từ chối thêm lại cùng một combo (
- Combo là một khối liền mạch theo góc nhìn người dùng. Sửa/xóa trực tiếp một dòng có
leadItemIdkhác null sẽ bị từ chối vớiCOMBO_CHILD_EDIT_FORBIDDEN. Sửa lead lan xuống: tăng số lượng lead sẽ tăng số lượng từng dòng con theo tỉ lệ, kèm theo lời gọiapplyReservationDeltacho từng dòng con. - Tách đơn từ chối bất kỳ nhóm nào chứa một combo lead mà thiếu toàn bộ dòng con của nó (hoặc ngược lại) với
COMBO_SPLIT_NOT_ATOMIC. Gộp đơn tự kế thừa cách nhóm combo vì nó di chuyển nguyên cả đơn. - Hỗ trợ combo lồng nhau (combo trong combo).
extractComboItemsđệ quy vớiMAX_BUNDLE_DEPTH=5và một bộ chặn vòng lặp (COMBO_DEPTH_EXCEEDED,COMBO_CYCLE_DETECTED).
Hệ quả
Ưu điểm
- KDS, báo cáo, hoàn tiền đều hoạt động mà không cần logic bung bổ sung.
- Không lệch giữa reserve và deduct: thêm vào giỏ đã chốt việc bung thành các dòng, deduct chỉ việc đọc lại.
- ADDON / FBT không cần thay đổi gì về tồn kho - các PV của chúng vốn đã là vật lý.
- Bộ phân biệt loại là kiểu cấu trúc theo từng biến thể - đổi
Category.typehay chuyển một Product giữa các category không còn ảnh hưởng tới hành vi tồn kho.
Nhược điểm
- Nhiều dòng hơn cho mỗi lần bán combo (1 + N).
- Ngữ nghĩa chỉnh sửa do lead điều khiển; ghi đè theo từng dòng con ("không dưa chua") cần một ticket trong tương lai.
- Điều kiện chặn thay đổi cho
ProductVariant.type(COMBO ↔ STORABLE) là việc làm tiếp theo - cùng mối quan tâm hẹp mà BANA đã có vớiKIT↔STORABLE.
Cơ sở nghiên cứu
- commercetools - Managing static product bundles - bundle Line Item + N component Line Item,
InventoryMode=Nonetrên bundle. - Medusa - Inventory Kits - liên kết nhiều item ở cấp biến thể (gần với v2).
- Toast - Stock Depletion - trừ kho theo recipe lúc bán (kiểu v2).
- Modifier Handling: Why "Add Bacon" Should Update Food Cost - lý do ánh xạ modifier ↔ nguyên liệu.
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ộngTSaleOrderItemMetadatavới trường kiểm toáncombotùy chọnpackages/core/src/migrations/drizzle/public/0008_combo_variant_type_backfill.sql- backfillProductVariant.typetừCategory.typecũpackages/sale/src/services/sale.service.ts:_addComboProductItem- rẽ nhánh theoisCombo(variant.type)packages/sale/src/services/sale-order-item.service.ts:update- lan truyền do lead điều khiển + chặn sửa dòng conpackages/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ự động bị bỏ qua bởi điều kiện chặnisStockable()