Material & BOM
1. Tổng quan
| Thuộc tính | Giá trị |
|---|---|
| ID | FEAT-INV-MAT |
| Status | Stable (recipes Beta — chỉ type KIT; MANUFACTURED Beta) |
| Owner | inventory-team |
| Phụ thuộc | InventoryItem, InventoryStock, Category, UnitOfMeasure, KitchenTicketItem (sale) |
Material là một loại principal riêng biệt với ProductVariant. Material được tracking trong inventory nhưng không bán trực tiếp — chúng tham gia vào stock thông qua MaterialRecipe (BOM). Khi một ProductVariant có recipe đang active, các material thành phần được reserve lúc sale-payment và tiêu thụ khi kitchen ticket đạt READY.
2. Mô hình Entity
Trường Material
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
merchantId | text | ✓ | Owner |
identifier | text | ✓ | Tự động, tiền tố MAT |
slug | text | ✓ | URL-safe |
name | i18n jsonb | ✓ | Hiển thị |
description | i18n jsonb | — | |
status | text | ✓ | MaterialStatuses (mặc định ACTIVATED) |
type | text | ✓ | MaterialTypes (mặc định RAW) |
uom | jsonb | IUomRole — { base, purchase, sale } (xem ADR-0005) | |
cost | decimal(15,4) | Tham chiếu standard cost | |
weight | decimal(15,4) | — | |
categoryId | text | FK | |
metadata.inventory.allowOversell | jsonb path | Chỉ làm seed từ migration 0009_charming_armor. Đọc duy nhất lúc tạo InventoryStock lần đầu rồi copy sang InventoryStock.metadata.allowOversell; sửa giá trị sau khi stock đã seed sẽ KHÔNG ảnh hưởng runtime. Mặc định false. Toggle per-location qua PATCH /inventory-items/{id}/stocks/{stockId}. | |
metadata.inventory.isInventoryTracked | jsonb path | Mặc định true (quy ước qua IMaterialMetadata) |
Trường MaterialIdentifier
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
materialId | text | ✓ | FK |
scheme | text | ✓ | SYSTEM (tự động, tiền tố MAT) / SLUG / SKU / BARCODE / QRCODE |
value | text | ✓ | Unique theo (materialId, scheme) |
Trường MaterialRecipe
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
merchantId | text | ✓ | Owner |
principalType | text | ✓ | MATERIAL (trung gian) / PRODUCT_VARIANT (bán được) |
principalId | text | ✓ | Recipe này tạo ra cái gì |
status | text | ✓ | DRAFT / ACTIVATED / DEACTIVATED |
type | text | ✓ | KIT (auto-trừ khi bán) / MANUFACTURED (cần ProductionOrder) |
version | int | Tăng bởi updateAggregate |
Trường MaterialRecipeItem
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
materialRecipeId | text | ✓ | FK tới MaterialRecipe |
principalType | text | ✓ | Loại component đa hình (MATERIAL hoặc PRODUCT_VARIANT) |
principalId | text | ✓ | FK target id |
quantity | decimal(15,4) | ✓ | Lượng cần mỗi đơn vị principal |
uomId | text | ✓ | Soft ref |
isOptional | boolean | ✓ | Mặc định false — khi true, thiếu component không block production |
3. Vòng đời
MaterialRecipe
Chỉ recipe ACTIVATED mới được khám phá lúc runtime. Recipe DRAFT chỉ tồn tại phía editor. Recipe DEACTIVATED bị loại khỏi explosion.
Hành vi theo Type của Recipe
| Type | Trigger để tiêu thụ material | Vòng đời |
|---|---|---|
KIT | Sale payment success → kitchen ticket READY | Tự động — bung lúc runtime |
MANUFACTURED | Tạo + complete ProductionOrder thủ công | Material được tiêu thụ với PO; stock của output material được tăng |
4. Vận hành
MaterialService (material.service.ts — 773 dòng)
| Phương thức | Signature | Mục đích |
|---|---|---|
validateMaterialIds | { materialIds, merchantId, transaction? } | Kiểm tra tồn tại + scope hàng loạt |
createAggregate | { context, data: TCreateMaterialAggregateRequest } | Material + identifier + (tùy chọn) seed InventoryItem + (tùy chọn) batch VendorItem |
updateAggregate | { context, id, data: TUpdateMaterialAggregateRequest } | Patch + grant/omit batch VendorItem |
| Lifecycle hooks | beforeCreate, afterCreate | Validate category; tự tạo MaterialIdentifier (SYSTEM) + seed InventoryItem nếu isInventoryTracked !== false |
MaterialRecipeService (material-recipe.service.ts — 624 dòng)
| Phương thức | Signature | Mục đích |
|---|---|---|
createAggregate | { context, data } | Recipe + items trong một TX |
updateAggregate | { context, id, data } | Patch + add/update/delete items qua sự hiện diện id |
flattenRecipe | { principalId, principalType, maxDepth? } | Bung BOM đa cấp → Material lá |
MaterialWorkerService (material-worker.service.ts — 1386 dòng)
| Phương thức | Signature | Mục đích |
|---|---|---|
handleKitchenTicketItemStatusChanged | { data: TKitchenTicketItemStatusChangedMessage } | Kafka handler — tiêu thụ material khi status READY, restore khi VOIDED |
reserveMaterialsForSaleOrder | { saleOrderId, merchantId, inventoryLocationId?, saleOrderItems } | Gọi bởi InventoryWorkerService sau khi trừ product |
deductMaterialForSaleOrder | { ... } | Dùng bởi luồng kitchen consume |
5. REST Endpoints
| Verb | Path | Auth | Permission | Handler |
|---|---|---|---|---|
| 6× CRUD | /materials | JWT/BASIC | Material.<crud> | CRUD |
GET | /materials/overview | JWT/BASIC | Material.find | MaterialService.getOverview |
POST | /materials/aggregate | JWT/BASIC | Material.createAggregate | MaterialService.createAggregate |
PATCH | /materials/:id/aggregate | JWT/BASIC | Material.updateAggregate | MaterialService.updateAggregate |
| 6× CRUD | /material-identifiers | JWT/BASIC | MaterialIdentifier.<crud> | CRUD |
| 6× CRUD | /material-recipes | JWT/BASIC | MaterialRecipe.<crud> | CRUD |
POST | /material-recipes/aggregate | JWT/BASIC | MaterialRecipe.createAggregate | MaterialRecipeService.createAggregate |
PATCH | /material-recipes/:id/aggregate | JWT/BASIC | MaterialRecipe.updateAggregate | MaterialRecipeService.updateAggregate |
POST | /material-recipes/:id/flatten | JWT/BASIC | MaterialRecipe.flatten | MaterialRecipeService.flattenRecipe |
GET /materials/overview — Dashboard cho màn hình Materials List
Cấp số liệu cho 4 thẻ KPI ở màn hình Inventory > Materials List. Bốn metric được fan-out song song từ repository.
Query
| Tên | Bắt buộc | Mặc định | Mô tả |
|---|---|---|---|
merchantId | ✓ | — | Phải nằm trong merchant scope của user theo Casbin (admin / always-allowed bỏ qua). |
Low-stock threshold không phải query input. Từ migration
0010_mellow_thresholdđây là config inventory-domain, không còn là field củaMaterial. Overview materials là metric per-material (SUM(available)vs một threshold) nên đọc default item-level tạiInventoryItem.metadata.lowStockThreshold,COALESCEvới default hệ thống5. (Override per-location trênInventoryStock.metadatakhông áp dụng cho sum cross-bucket.) Seed quainventory.lowStockThresholdlúc tạo material; sửa default sau đó quaPATCH /inventory-items/{id}.
Response — MaterialOverviewResponse
| Card | Field | Nguồn dữ liệu |
|---|---|---|
| Total Materials | totalMaterials.total | COUNT(*) Material (theo merchant). |
| Total Materials ("Across N categories") | totalMaterials.categoryCount | COUNT(DISTINCT category_id) trên cùng tập. Material có categoryId NULL không tính. |
| Active Tracking | activeTracking.total | COUNT(*) mà metadata.inventory.isInventoryTracked không bị đặt rõ là false (giữ đúng default lúc create). |
| Low Stock | lowStock.total | Material có 0 < SUM(quantity_available) <= threshold trên toàn bộ bucket, với threshold = COALESCE((inv_item.metadata->>lowStockThreshold)::numeric, 5) — default item-level per-material. |
| Out Of Stock | outOfStock.total | Material có SUM(quantity_available) = 0 (hoặc không còn stock row). |
| Oversell | oversell.total | Material có SUM(quantity_available) < 0 — âm; chỉ xảy ra khi material cho phép oversell. Tách biệt với Out Of Stock và Low Stock. |
Authorization: dùng lại
Material.findvì đây là read-only aggregate trên cùng rows. Service trả403khi merchant nằm ngoài scope.
Caching: response được cache trong
cache-redis60 giây, key theo(merchantId). Scope check chạy trước cache lookup nên không bao giờ trả cache entry cho caller ngoài scope. Lỗi Redis fall-through sang query trực tiếp — Redis là best-effort, không phải hard dependency.
6. Sự kiện
Inbound:
| Topic | Trigger | Handler | Hành vi |
|---|---|---|---|
kitchen-ticket-item.status-changed (KafkaTopics.KITCHEN_TICKET_ITEM_STATUS_CHANGED) | KitchenTicketItem.status đổi (READY / VOIDED) | MaterialWorkerService.handleKitchenTicketItemStatusChanged | Khi READY: trừ stock thành phần + ghi tracking. Khi VOIDED: restore. |
Outbound:
| Topic | Khi nào | Payload |
|---|---|---|
material.stock-changed (KafkaTopics.MATERIAL_STOCK_CHANGED) | Sau khi mutate stock thành phần | { materialId, merchantId, inventoryStockId, quantityBefore, quantityAfter, delta, referenceType, referenceId } |
material.transferred (KafkaTopics.MATERIAL_TRANSFERRED) | Chuyển material cross-location | { materialId, fromLocationId, toLocationId, quantity, reason } |
7. Luồng Bung BOM
8. Luồng Reservation (sau sale-payment)
Thời điểm reservation: ngay sau khi trừ stock product (trong
InventoryWorkerService.handlePaymentSuccess). Reservation không tiêu thụquantityOnHand— chỉ giảmquantityAvailablevà tăngquantityReserved. Tiêu thụ thực sự xảy ra khi kitchen READY.
9. Idempotency
| Luồng | Key |
|---|---|
| Reserve (sale payment) | (SALE_ORDER, saleOrderId, materialStockId) |
| Consume (kitchen READY) | (KITCHEN_TICKET_ITEM, kitchenTicketItemId, materialStockId) |
| Restore (kitchen VOIDED) | nghịch đảo của trên; tracking row được ghi với reason RESERVATION_RELEASE |
10. Trang liên quan
- Inventory Stock — ngữ nghĩa
adjustStock - Inventory Tracking — audit trail
- Vendor & VendorItem — sourcing cho material
- Mô hình miền — schema đầy đủ
- API Sự kiện — tham chiếu Kafka topic đầy đủ
- ADR-0005 UoM storage hai lớp