Skip to content

ADR-0006. Tồn kho cho COMBO - bung thành SaleOrderItem con khi thêm vào giỏ

TrườngGiá trị
Trạng tháiAccepted
Ngày2026-05-14
Người quyết địnhPhat 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ĩaBung ra qua
KITcó BOM, bung ra lúc bánMaterialRecipe → Materials
COMBOcó bundle, bung ra lúc bánProductBundler(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 KITSTORABLE).

Tại sao không dùng Category.type? Một phiên bản trước phân biệt combo qua Category.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.type trở 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ếucommercetoolsToast / Medusa Inventory Kits
Tầng SaleThêm nhánh combo trong _addProductItem; lead + N dòng SaleOrderItem conTầng Sale không đổi; combo PV là một dòng
Hóa đơn / biên laiThấy được thành phầnChỉ hiện dòng "Burger Combo"
KDSCác thành phần con đi qua các đường đọc sẵn cóCần bung riêng
Báo cáoTổng hợp theo thành phần một cách tự nhiênCần bung lúc làm báo cáo
Hoàn tiền theo thành phầnCó dòng thậtCần bung ngược
Rủi ro lệchKhô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ể:

  1. CDC handler tự bỏ qua việc seed InventoryItem - COMBO không nằm trong STOCKABLE_SET, nên điều kiện chặn if (!ProductVariantTypes.isStockable(type)) return; có sẵn trong handleProductVariantCDC đã loại nó ra. Không cần tra cứu category.
  2. 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ệt ProductBundler và tạo ra các biến thể lá. Service nhận biết combo lồng nhau bằng cách kiểm tra type củ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 SaleOrderItem con (leadItemId=lead.id; unitPrice=0; metadata.combo.bundlerRowIds để kiểm toán).
  3. 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ó leadItemId khác null sẽ bị từ chối với COMBO_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ọi applyReservationDelta cho từng dòng con.
  4. 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.
  5. Hỗ trợ combo lồng nhau (combo trong combo). extractComboItems đệ quy với MAX_BUNDLE_DEPTH=5 và 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.type hay 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ới KITSTORABLE.

Cơ sở nghiên cứ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ên variant.type
  • packages/core/src/models/schemas/sale/sale-item/constants.ts - mở rộng TSaleOrderItemMetadata với trường kiểm toán combo tùy chọn
  • 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 - rẽ nhánh theo isCombo(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 con
  • 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ự động bị bỏ qua bởi điều kiện chặn isStockable()

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