Skip to content

Material & BOM

1. Overview

PropertyValue
IDFEAT-INV-MAT
StatusStable (recipes Beta — KIT type only; MANUFACTURED Beta)
Ownerinventory-team
Depends onInventoryItem, 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

FieldTypeRequiredDescription
merchantIdtextOwner
identifiertextAuto, MAT prefix
slugtextURL-safe
namei18n jsonbDisplay
descriptioni18n jsonb
statustextMaterialStatuses (default ACTIVATED)
typetextMaterialTypes (default RAW)
uomjsonbIUomRole{ base, purchase, sale } (see ADR-0005)
costdecimal(15,4)Standard cost reference
weightdecimal(15,4)
categoryIdtextFK
metadata.inventory.allowOverselljsonb pathSeed-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.isInventoryTrackedjsonb pathDefault true (convention via IMaterialMetadata)

MaterialIdentifier fields

FieldTypeRequiredDescription
materialIdtextFK
schemetextSYSTEM (auto, prefix MAT) / SLUG / SKU / BARCODE / QRCODE
valuetextUnique per (materialId, scheme)

MaterialRecipe fields

FieldTypeRequiredDescription
merchantIdtextOwner
principalTypetextMATERIAL (intermediate) / PRODUCT_VARIANT (sellable)
principalIdtextWhat this recipe produces
statustextDRAFT / ACTIVATED / DEACTIVATED
typetextKIT (auto-deduct at sale) / MANUFACTURED (requires ProductionOrder)
versionintBumped by updateAggregate

MaterialRecipeItem fields

FieldTypeRequiredDescription
materialRecipeIdtextFK to MaterialRecipe
principalTypetextPolymorphic component type (MATERIAL or PRODUCT_VARIANT)
principalIdtextFK target id
quantitydecimal(15,4)Required quantity per principal unit
uomIdtextSoft ref
isOptionalbooleanDefault 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

TypeTrigger to consume materialsLifecycle
KITSale payment success → kitchen ticket READYAuto — explode at runtime
MANUFACTUREDManual ProductionOrder create + completeMaterials consumed against PO; output material's stock incremented

4. Operations

MaterialService (material.service.ts — 773 lines)

MethodSignaturePurpose
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 hooksbeforeCreate, afterCreateValidate category; auto-create MaterialIdentifier (SYSTEM) + seed InventoryItem if isInventoryTracked !== false

MaterialRecipeService (material-recipe.service.ts — 624 lines)

MethodSignaturePurpose
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)

MethodSignaturePurpose
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

VerbPathAuthPermissionHandler
6× CRUD/materialsJWT/BASICMaterial.<crud>CRUD
GET/materials/overviewJWT/BASICMaterial.findMaterialService.getOverview
POST/materials/aggregateJWT/BASICMaterial.createAggregateMaterialService.createAggregate
PATCH/materials/:id/aggregateJWT/BASICMaterial.updateAggregateMaterialService.updateAggregate
6× CRUD/material-identifiersJWT/BASICMaterialIdentifier.<crud>CRUD
6× CRUD/material-recipesJWT/BASICMaterialRecipe.<crud>CRUD
POST/material-recipes/aggregateJWT/BASICMaterialRecipe.createAggregateMaterialRecipeService.createAggregate
PATCH/material-recipes/:id/aggregateJWT/BASICMaterialRecipe.updateAggregateMaterialRecipeService.updateAggregate
POST/material-recipes/:id/flattenJWT/BASICMaterialRecipe.flattenMaterialRecipeService.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

NameRequiredDefaultDescription
merchantIdMust 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_threshold it's an inventory-domain config, not a Material field. The materials overview is a per-material metric (SUM(available) vs one threshold) so it reads the item-level default at InventoryItem.metadata.lowStockThreshold, COALESCEd against the system default 5. (Per-location InventoryStock.metadata overrides don't apply to a cross-bucket sum.) Seed it via inventory.lowStockThreshold on material create; edit the default later via PATCH /inventory-items/{id}.

Response — MaterialOverviewResponse

CardFieldSource
Total MaterialstotalMaterials.totalCOUNT(*) Material (merchant-scoped).
Total Materials ("Across N categories")totalMaterials.categoryCountCOUNT(DISTINCT category_id) from the same set. Materials with a NULL categoryId contribute nothing.
Active TrackingactiveTracking.totalCOUNT(*) where metadata.inventory.isInventoryTracked is not explicitly false (matches the create-time default convention).
Low StocklowStock.totalMaterials with 0 < SUM(quantity_available) <= threshold across every bucket, where threshold = COALESCE((inv_item.metadata->>lowStockThreshold)::numeric, 5) — per-material item default.
Out Of StockoutOfStock.totalMaterials with SUM(quantity_available) = 0 across every bucket (or no stock rows at all).
Overselloversell.totalMaterials with SUM(quantity_available) < 0 — negative; only reachable when the material allows oversell. Disjoint from Out Of Stock and Low Stock.

Authorization: reuses Material.find since this is a read-only aggregate over the same rows. The service rejects mis-scoped merchants with HTTP 403.

Caching: response is cached in cache-redis for 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:

TopicTriggerHandlerBehavior
kitchen-ticket-item.status-changed (KafkaTopics.KITCHEN_TICKET_ITEM_STATUS_CHANGED)KitchenTicketItem.status changes (READY / VOIDED)MaterialWorkerService.handleKitchenTicketItemStatusChangedOn READY: deduct ingredient stocks + write tracking. On VOIDED: restore.

Outbound:

TopicWhenPayload
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 consume quantityOnHand — only quantityAvailable is reduced and quantityReserved is increased. Actual consumption happens at kitchen READY.

9. Idempotency

FlowKey
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

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