Inventory Issues — Remediation Plan
This document tracks confirmed data integrity and correctness issues in the inventory system, with prioritized implementation plans for each.
Issue Summary
| # | Issue | Priority | Effort | Status |
|---|---|---|---|---|
| 1 | BOM deduction does not decrement inventory | P1 — Critical | Medium | Fixed |
| 3 | Production run does not update WAC | P2 — Important | Medium | Fixed |
| 4 | FEL reversal does not restore inventory_detail | P2 — Important | Large | Fixed |
| 5 | Production run emits events with no listeners | P3 — Risk | Small | Fixed |
Stock count atomicity was investigated and confirmed as correct — both increase and decrease adjustments are wrapped in a single
database.transaction(). No action needed.
Issue 1 — BOM Deduction Does Not Decrement inventory
Confirmed Behavior
BomDeductionService is a pure domain service: it calculates and returns InsertableInventoryLedger[] rows but makes no repository calls. The caller (OnOrderSettledHandler.processBomDeduction()) persists those ledger rows but never calls inventoriesRepository.decreaseInventory() for the recipe ingredients.
Result: inventory_ledger shows ingredient deductions with sourceType = restaurant_order, but inventory.quantity and inventory.cost for those ingredients are never reduced. The inventory table is out of sync with the ledger for every restaurant order paid.
Fix for Issue 1
In OnOrderSettledHandler.processBomDeduction(), after building the deduction rows from BomDeductionService, add calls to inventoriesRepository.decreaseInventory() for each ingredient grouped by (locationId, productId, variantId).
Files to change:
apps/backend/src/restaurant/application/events/on-order-settled.handler.ts- Inject
InventoriesRepository - After
inventoryLedgersService.createByRowsWithTransaction(trx, rows), iterate grouped ingredients and callinventoriesRepository.decreaseInventory()within the sametrx
- Inject
apps/backend/src/restaurant/restaurant.module.ts- Add
InventoriesRepositoryto providers/imports if not already present
- Add
Steps:
- Verify transaction scope — see Cross-Cutting Concerns below before starting
- Group BOM deduction rows by
(locationId, productId, variantId)— sum quantities - For each group, call
inventoriesRepository.decreaseInventory({ locationId, productId, variantId, quantityToDecrease, userId, transaction: trx }) - Ensure all calls use the existing transaction from
OnOrderSettledHandler - Verify voided items (
voidedAt IS NOT NULL) are excluded from deduction — already handled byBomDeductionService - Write integration test: settle an order and assert
inventory.quantitydecremented for each recipe ingredient
Boundary note: The settlement sale (created immediately after BOM deduction) deducts the finished menu item from inventory via the normal sale flow. BOM deduction covers ingredients. These are distinct products and do not overlap.
Issue 3 — Production Run Does Not Update WAC
Confirmed Behavior for Issue 3
completeProductionRun() calls inventoriesRepository.increaseInventory() for each output (finished good) but never calls updateProductCostsFromReceipt(). The costToIncrease value passed to increaseInventory() comes from outputItem.cost ?? 0 — defaulting to 0 if not provided.
Result: product.cost / productVariant.avgCost remain at whatever the last purchase receipt set them to, regardless of actual production cost. All downstream cost calculations (sales, FEL, cost snapshots) use a stale WAC. product_cost_history has no record of production-driven cost changes.
Fix for Issue 3
After all output increaseInventory() calls, calculate per-unit cost for each output and call updateProductCostsFromReceipt().
Cost calculation strategy — two options:
- Option A (simple): Use
outputItem.cost / outputItem.quantityas the unit cost. Requires the user to enter the cost of the finished good on the production run output. Validate thatcost > 0before saving. - Option B (derived): Calculate total input cost from
inventory.costper unit for each input consumed, divide by total output quantity. More accurate but requires fetching current inventory costs before the decrease.
Recommend Option A first (it matches the existing data model) with a UI validation that output.cost > 0.
Files to change:
apps/backend/src/production-runs/application/production-runs.service.ts- After the output
increaseInventory()loop, callupdateProductCostsFromReceipt()for each output product/variant - Import and inject
ProductCostHistoryService
- After the output
apps/backend/src/production-runs/production-runs.module.ts- Import
ProductCostHistoryModule
- Import
packages/backend/database/src/migrations/<new>.tsALTER TYPE cost_update_source_type ADD VALUE 'production_run'— required before the service change
apps/frontend-pwa/— add UI validation: outputcostfield required and > 0
Steps:
- Create and apply migration to add
'production_run'value to thecost_update_source_typeenum - Add UI validation: production run output cost is required and positive
- In
completeProductionRun(), after allincreaseInventory()calls, iterate outputs and callupdateProductCostsFromReceipt({ productId, variantId, locationId, baseCost: output.cost, receivedQty: output.quantity, ... }) updateProductCostsFromReceipt()handles both simple and variant products internally and createsproduct_cost_historyentries- Write test: complete a production run and assert
product.costupdated to WAC of output
Issue 4 — FEL Reversal Does Not Restore inventory_detail
Confirmed Behavior for Issue 4
processCertifyCreditNoteEvent() and processCertifyCancellationEvent() call inventoriesRepository.increaseInventory() to restore quantity and cost, but never recreate inventory_detail records. Serial/batch information from the original sale is permanently lost.
Result: After a credit note, inventory.quantity is correct but inventory.quantityInventoryDetail is under-counted. Reselling the returned serial/batch items as tracked units is impossible. Reports that rely on inventory_detail will be inconsistent.
Fix for Issue 4
During FEL reversal, query inventory_ledger for the original sale's ledger rows and use the batchNumber, serialNumber, expirationDate, productionDate fields to recreate inventory_detail records.
Files to change:
apps/backend/src/inventories/application/inventories.service.ts- In
processCertifyCreditNoteEvent()(~line 1540) andprocessCertifyCancellationEvent()(~line 1608), afterincreaseInventory(), query ledger rows for the original sale - For each ledger row that has a
serialNumberorbatchNumber, callinventoryDetailsService.createInventoryDetail()
- In
Steps:
- After
increaseInventory(), queryinventory_ledgerwheresourceId = saleId AND sourceType = 'sale'to retrieve serial/batch data - Filter rows that have a non-null
serialNumberorbatchNumber - For each, call
inventoryDetailsService.createInventoryDetail()with the recovered data within the same transaction - This will emit
OnCreateInventoryDetailEvent, which incrementsinventory.quantityInventoryDetailautomatically - Handle partial credit notes: only restore detail records for the specific line items covered by the credit note (match by
sourceLineId) - Write test: process a sale with serial-tracked items, issue a credit note, assert
inventory_detailrecords are restored
Risk: If the serial was reused in another transaction between the sale and the credit note, inserting it again would create a duplicate. Query for existing inventory_detail records with the same serial before inserting and skip any that already exist.
Issue 5 — Production Run Emits Events With No Listeners
Confirmed Behavior for Issue 5
completeProductionRun() calls inventoriesRepository.decreaseInventory() and increaseInventory() directly (synchronously, within the transaction), and then emits OnInventoryDecreasedEvent and OnInventoryIncreasedEvent. No handlers currently consume these events for production runs.
Risk: If a handler is ever added that also calls decreaseInventory() or increaseInventory() on these events (following the pattern from the inbound/outbound handlers), inventory will be double-updated silently.
Fix for Issue 5
Gate: Before making any change, search for all listeners of OnInventoryDecreasedEvent.eventName and OnInventoryIncreasedEvent.eventName across the backend. If any listener exists that should react to production run completions, the rename path below is required.
- If no relevant listeners found: remove the emissions
- If listeners exist: rename to
OnProductionRunCompletedEventso handlers are unambiguously scoped to production runs and can never be confused with the standard inbound/outbound pattern
Files to change:
apps/backend/src/production-runs/application/production-runs.service.ts- Remove
this.eventEmitter.emit(OnInventoryDecreasedEvent.eventName, ...)(~line 652) - Remove
this.eventEmitter.emit(OnInventoryIncreasedEvent.eventName, ...)(~line 658) - Or replace both with
this.eventEmitter.emit(OnProductionRunCompletedEvent.eventName, ...)if listeners need to be preserved
- Remove
Steps:
- Search codebase for all
@OnEvent(OnInventoryDecreasedEvent.eventName)and@OnEvent(OnInventoryIncreasedEvent.eventName)decorators - Determine if any found handler should react to production run completions specifically
- Remove emissions (no listeners) or rename event (listeners exist)
Deployment Order and Dependencies
Critical: Issues 1 cannot be deployed in the same release. Issue 2's guard will reject restaurant order payments if deployed before Issue 1 is live. See dependency note below.
Sprint 1a — Fix the root cause first
└── Issue 1: BOM deduction — add decreaseInventory() calls — 2 days
├── Step 0: Verify transaction scope (see Cross-Cutting Concerns)
└── Deploy and stabilize before Sprint 1b
Sprint 2 — Correctness (P2)
├── Issue 3: Add productionRun enum value migration — 0.5 day
├── Issue 3: Production run WAC update — 2 days
│ └── UI validation for output cost — 0.5 day
└── Issue 4: FEL reversal inventory_detail restore — 3 days
Sprint 3 — Risk Mitigation (P3)
└── Issue 5: Search for listeners (gate) → remove or rename emissions — 0.5 day
Cross-Cutting Concerns
Deployment Order Contract
Today, decreaseInventory() is never called for recipe ingredients — so no ingredient inventory has ever been decremented. If those products have insufficient inventory.quantity (because it was never decremented before), the guard will immediately start rejecting live payments.
Deploy Issue 1, verify ingredient deductions are working correctly in staging.
Historical Data Backfill
Every restaurant order paid since go-live has left ingredient inventory.quantity over-counted. Before applying the CHECK (quantity >= 0) DB constraint, the historical over-count must be corrected, or the migration may fail once backfill brings some products to negative.
Backfill strategy:
- Query all
inventory_ledgerrows wheresourceType = 'restaurant_order'— these represent ingredient deductions that were logged but never applied toinventory - Group by
(productId, variantId, locationId)and sum quantities - For each group, subtract the total from
inventory.quantityand proportionally reduceinventory.cost - Run in a transaction; if any result would go negative, capture it for manual review before committing
-- Audit query: which products are over-counted due to missing BOM deductions?
SELECT
il.product_id,
il.location_id,
SUM(ABS(il.quantity)) AS undeducted_qty,
i.quantity AS current_inventory_qty,
i.quantity - SUM(ABS(il.quantity)) AS would_become
FROM inventory_ledger il
JOIN inventory i
ON i.product_id = il.product_id
AND i.location_id = il.location_id
WHERE il.source_type = 'restaurant_order'
AND il.quantity < 0
GROUP BY il.product_id, il.location_id, i.quantity
ORDER BY would_become ASC;
Items where would_become < 0 need manual review before applying the constraint.
Transaction Scope Prerequisite for Issue 1
Before writing the fix, confirm whether OnOrderSettledHandler.processBomDeduction() runs inside a database transaction.
- If it does: the new
decreaseInventory()calls can join the existingtrx - If it does not: a transaction must be added wrapping both the ledger write and the new
decreaseInventory()calls, otherwise a partial write (ledger persisted but inventory not decremented, or vice versa) is possible on any failure
Check apps/backend/src/restaurant/application/events/on-order-settled.handler.ts for a database.transaction() wrapper before starting implementation.
Files Touched Summary
| File | Issues |
|---|---|
apps/backend/src/restaurant/application/events/on-order-settled.handler.ts | #1 |
apps/backend/src/restaurant/restaurant.module.ts | #1 |
packages/backend/database/src/migrations/<new>.ts | #3 |
apps/backend/src/production-runs/application/production-runs.service.ts | #3, #5 |
apps/backend/src/production-runs/production-runs.module.ts | #3 |
apps/backend/src/inventories/application/inventories.service.ts | #4 |
apps/frontend-pwa/ (production run output form) | #3 |