Material & BOM
1. Overview
| Property | Value |
|---|---|
| ID | FEAT-INV-MAT |
| Status | Stable (recipes Beta — KIT type only; MANUFACTURED Beta) |
| Owner | inventory-team |
| Depends on | InventoryItem, InventoryStock, Category, UnitOfMeasure, KitchenTicketItem (sale) |
Material is a separate principal type from ProductVariant. Materials are tracked in inventory but not directly sold — they participate in stock through MaterialRecipe (BOM). When a ProductVariant has an active recipe, ingredient materials are reserved at sale-payment time and consumed when the kitchen ticket reaches READY.
2. Entity Model
Material fields
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | text | ✓ | Owner |
identifier | text | ✓ | Auto, MAT prefix |
slug | text | ✓ | URL-safe |
name | i18n jsonb | ✓ | Display |
description | i18n jsonb | — | |
status | text | ✓ | MaterialStatuses (default ACTIVATED) |
type | text | ✓ | MaterialTypes (default RAW) |
uom | jsonb | IUomRole — { base, purchase, sale } (see ADR-0005) | |
cost | decimal(15,4) | Standard cost reference | |
weight | decimal(15,4) | — | |
categoryId | text | FK | |
metadata.inventory.allowOversell | jsonb path | Seed-only as of migration 0009_charming_armor. Read once at first-time InventoryStock creation and copied into InventoryStock.metadata.allowOversell; editing the value after stocks have been seeded does not affect runtime behavior. Default false. Toggle per location via PATCH /inventory-items/{id}/stocks/{stockId}. | |
metadata.inventory.isInventoryTracked | jsonb path | Default true (convention via IMaterialMetadata) |
MaterialIdentifier fields
| Field | Type | Required | Description |
|---|---|---|---|
materialId | text | ✓ | FK |
scheme | text | ✓ | SYSTEM (auto, prefix MAT) / SLUG / SKU / BARCODE / QRCODE |
value | text | ✓ | Unique per (materialId, scheme) |
MaterialRecipe fields
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | text | ✓ | Owner |
principalType | text | ✓ | MATERIAL (intermediate) / PRODUCT_VARIANT (sellable) |
principalId | text | ✓ | What this recipe produces |
status | text | ✓ | DRAFT / ACTIVATED / DEACTIVATED |
type | text | ✓ | KIT (auto-deduct at sale) / MANUFACTURED (requires ProductionOrder) |
version | int | Bumped by updateAggregate |
MaterialRecipeItem fields
| Field | Type | Required | Description |
|---|---|---|---|
materialRecipeId | text | ✓ | FK to MaterialRecipe |
principalType | text | ✓ | Polymorphic component type (MATERIAL or PRODUCT_VARIANT) |
principalId | text | ✓ | FK target id |
quantity | decimal(15,4) | ✓ | Required quantity per principal unit |
uomId | text | ✓ | Soft ref |
isOptional | boolean | ✓ | Default false — when true, missing component does not block production |
3. Lifecycle
MaterialRecipe
Only ACTIVATED recipes are explored at runtime. DRAFT recipes are editor-side only. DEACTIVATED recipes are excluded from explosion.
Recipe Type Behavior
| Type | Trigger to consume materials | Lifecycle |
|---|---|---|
KIT | Sale payment success → kitchen ticket READY | Auto — explode at runtime |
MANUFACTURED | Manual ProductionOrder create + complete | Materials consumed against PO; output material's stock incremented |
4. Operations
MaterialService (material.service.ts — 773 lines)
| Method | Signature | Purpose |
|---|---|---|
validateMaterialIds | { materialIds, merchantId, transaction? } | Bulk existence + scope check |
createAggregate | { context, data: TCreateMaterialAggregateRequest } | Material + identifier + (optional) seed InventoryItem + (optional) VendorItem batch |
updateAggregate | { context, id, data: TUpdateMaterialAggregateRequest } | Patch + grant/omit VendorItem batch |
| Lifecycle hooks | beforeCreate, afterCreate | Validate category; auto-create MaterialIdentifier (SYSTEM) + seed InventoryItem if isInventoryTracked !== false |
MaterialRecipeService (material-recipe.service.ts — 624 lines)
| Method | Signature | Purpose |
|---|---|---|
createAggregate | { context, data } | Recipe + items in one TX |
updateAggregate | { context, id, data } | Patch + add/update/delete items via id-presence |
flattenRecipe | { principalId, principalType, maxDepth? } | Multi-level BOM expansion → leaf Materials |
MaterialWorkerService (material-worker.service.ts — 1386 lines)
| Method | Signature | Purpose |
|---|---|---|
handleKitchenTicketItemStatusChanged | { data: TKitchenTicketItemStatusChangedMessage } | Kafka handler — consume materials when status READY, restore on VOIDED |
reserveMaterialsForSaleOrder | { saleOrderId, merchantId, inventoryLocationId?, saleOrderItems } | Called by InventoryWorkerService post-product-deduct |
deductMaterialForSaleOrder | { ... } | Used by kitchen consume flow |
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 — Materials List dashboard
Powers the four KPI cards on the Inventory > Materials List screen. The four metrics are fanned out from the repository in parallel.
Query
| Name | Required | Default | Description |
|---|---|---|---|
merchantId | ✓ | — | Must fall inside the caller's Casbin merchant scope (admin / always-allowed bypass). |
Low-stock threshold is not a query input. As of migration
0010_mellow_thresholdit's an inventory-domain config, not aMaterialfield. The materials overview is a per-material metric (SUM(available)vs one threshold) so it reads the item-level default atInventoryItem.metadata.lowStockThreshold,COALESCEd against the system default5. (Per-locationInventoryStock.metadataoverrides don't apply to a cross-bucket sum.) Seed it viainventory.lowStockThresholdon material create; edit the default later viaPATCH /inventory-items/{id}.
Response — MaterialOverviewResponse
| Card | Field | Source |
|---|---|---|
| Total Materials | totalMaterials.total | COUNT(*) Material (merchant-scoped). |
| Total Materials ("Across N categories") | totalMaterials.categoryCount | COUNT(DISTINCT category_id) from the same set. Materials with a NULL categoryId contribute nothing. |
| Active Tracking | activeTracking.total | COUNT(*) where metadata.inventory.isInventoryTracked is not explicitly false (matches the create-time default convention). |
| Low Stock | lowStock.total | Materials with 0 < SUM(quantity_available) <= threshold across every bucket, where threshold = COALESCE((inv_item.metadata->>lowStockThreshold)::numeric, 5) — per-material item default. |
| Out Of Stock | outOfStock.total | Materials with SUM(quantity_available) = 0 across every bucket (or no stock rows at all). |
| Oversell | oversell.total | Materials with SUM(quantity_available) < 0 — negative; only reachable when the material allows oversell. Disjoint from Out Of Stock and Low Stock. |
Authorization: reuses
Material.findsince this is a read-only aggregate over the same rows. The service rejects mis-scoped merchants with HTTP403.
Caching: response is cached in
cache-redisfor 60 seconds keyed per(merchantId). The merchant-scope check runs before the cache lookup, so a cached entry can never be returned to an unauthorised caller. Cache failures fall through to the live query — Redis is best-effort, never a hard dependency.
6. Events
Inbound:
| Topic | Trigger | Handler | Behavior |
|---|---|---|---|
kitchen-ticket-item.status-changed (KafkaTopics.KITCHEN_TICKET_ITEM_STATUS_CHANGED) | KitchenTicketItem.status changes (READY / VOIDED) | MaterialWorkerService.handleKitchenTicketItemStatusChanged | On READY: deduct ingredient stocks + write tracking. On VOIDED: restore. |
Outbound:
| Topic | When | Payload |
|---|---|---|
material.stock-changed (KafkaTopics.MATERIAL_STOCK_CHANGED) | After ingredient stock mutation | { materialId, merchantId, inventoryStockId, quantityBefore, quantityAfter, delta, referenceType, referenceId } |
material.transferred (KafkaTopics.MATERIAL_TRANSFERRED) | Cross-location material movement | { materialId, fromLocationId, toLocationId, quantity, reason } |
7. BOM Explosion Flow
8. Reservation Flow (post sale-payment)
Reservation timing: immediately after product stock deduction (within
InventoryWorkerService.handlePaymentSuccess). Reservation does not consumequantityOnHand— onlyquantityAvailableis reduced andquantityReservedis increased. Actual consumption happens at kitchen READY.
9. Idempotency
| Flow | Key |
|---|---|
| Reserve (sale payment) | (SALE_ORDER, saleOrderId, materialStockId) |
| Consume (kitchen READY) | (KITCHEN_TICKET_ITEM, kitchenTicketItemId, materialStockId) |
| Restore (kitchen VOIDED) | inverse of above; tracking row written with reason RESERVATION_RELEASE |
10. Related Pages
- Inventory Stock —
adjustStocksemantics - Inventory Tracking — audit trail
- Vendor & VendorItem — sourcing for materials
- Domain Model — full schemas
- API Events — full Kafka topic reference
- ADR-0005 UoM storage two-layer