Skip to main content

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

#IssuePriorityEffortStatus
1BOM deduction does not decrement inventoryP1 — CriticalMediumFixed
3Production run does not update WACP2 — ImportantMediumFixed
4FEL reversal does not restore inventory_detailP2 — ImportantLargeFixed
5Production run emits events with no listenersP3 — RiskSmallFixed

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 call inventoriesRepository.decreaseInventory() within the same trx
  • apps/backend/src/restaurant/restaurant.module.ts
    • Add InventoriesRepository to providers/imports if not already present

Steps:

  1. Verify transaction scope — see Cross-Cutting Concerns below before starting
  2. Group BOM deduction rows by (locationId, productId, variantId) — sum quantities
  3. For each group, call inventoriesRepository.decreaseInventory({ locationId, productId, variantId, quantityToDecrease, userId, transaction: trx })
  4. Ensure all calls use the existing transaction from OnOrderSettledHandler
  5. Verify voided items (voidedAt IS NOT NULL) are excluded from deduction — already handled by BomDeductionService
  6. Write integration test: settle an order and assert inventory.quantity decremented 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.quantity as the unit cost. Requires the user to enter the cost of the finished good on the production run output. Validate that cost > 0 before saving.
  • Option B (derived): Calculate total input cost from inventory.cost per 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, call updateProductCostsFromReceipt() for each output product/variant
    • Import and inject ProductCostHistoryService
  • apps/backend/src/production-runs/production-runs.module.ts
    • Import ProductCostHistoryModule
  • packages/backend/database/src/migrations/<new>.ts
    • ALTER TYPE cost_update_source_type ADD VALUE 'production_run' — required before the service change
  • apps/frontend-pwa/ — add UI validation: output cost field required and > 0

Steps:

  1. Create and apply migration to add 'production_run' value to the cost_update_source_type enum
  2. Add UI validation: production run output cost is required and positive
  3. In completeProductionRun(), after all increaseInventory() calls, iterate outputs and call updateProductCostsFromReceipt({ productId, variantId, locationId, baseCost: output.cost, receivedQty: output.quantity, ... })
  4. updateProductCostsFromReceipt() handles both simple and variant products internally and creates product_cost_history entries
  5. Write test: complete a production run and assert product.cost updated 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) and processCertifyCancellationEvent() (~line 1608), after increaseInventory(), query ledger rows for the original sale
    • For each ledger row that has a serialNumber or batchNumber, call inventoryDetailsService.createInventoryDetail()

Steps:

  1. After increaseInventory(), query inventory_ledger where sourceId = saleId AND sourceType = 'sale' to retrieve serial/batch data
  2. Filter rows that have a non-null serialNumber or batchNumber
  3. For each, call inventoryDetailsService.createInventoryDetail() with the recovered data within the same transaction
  4. This will emit OnCreateInventoryDetailEvent, which increments inventory.quantityInventoryDetail automatically
  5. Handle partial credit notes: only restore detail records for the specific line items covered by the credit note (match by sourceLineId)
  6. Write test: process a sale with serial-tracked items, issue a credit note, assert inventory_detail records 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 OnProductionRunCompletedEvent so 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

Steps:

  1. Search codebase for all @OnEvent(OnInventoryDecreasedEvent.eventName) and @OnEvent(OnInventoryIncreasedEvent.eventName) decorators
  2. Determine if any found handler should react to production run completions specifically
  3. 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:

  1. Query all inventory_ledger rows where sourceType = 'restaurant_order' — these represent ingredient deductions that were logged but never applied to inventory
  2. Group by (productId, variantId, locationId) and sum quantities
  3. For each group, subtract the total from inventory.quantity and proportionally reduce inventory.cost
  4. 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 existing trx
  • 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

FileIssues
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