You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #558 fixed the leaf half of cost propagation: receiving a PO now auto-syncs standard_cost ← average_cost for purchased items. The parent half remains manual — manufactured assemblies that consume the updated component keep their stale rolled-up cost until someone runs "Recost All".
Concrete scenario (COMP-007, 2026-04-25)
Operator receives 100× COMP-007 at $0.0575/unit via PO.
Assembly FG-008 has BOM 2× COMP-007. Its standard_cost was rolled up when COMP-007 was $0 and still reads as if the component is free.
Until someone clicks "Recost All", FG-008.standard_cost is wrong — and RoutingOperationMaterial.unit_cost on any routing that references COMP-007 is also wrong.
Verified at purchase_order_service.py:921–942 — the sync loop updates leaf standard_cost directly, with no recalculate_bom_cost call for parents.
Investigation findings
Where component costs are consumed — two paths:
BOMLine.component → recalculate_bom_cost() (in bom_management_service.py) reads each line's component.standard_cost to compute BOM material cost.
RoutingOperationMaterial.unit_cost property (manufacturing.py:326) reads component.standard_cost or average_cost or last_cost, then divides by purchase_factor. This feeds extended_cost, which feeds Routing.recalculate_totals() → total_cost.
Both paths flow into calculate_item_cost() / recost_item() in item_service.py. A stale leaf cost corrupts both BOM and routing rollups for every ancestor.
Estimated tree depth — shallow.
FilaOps targets 3D print farms. Typical tree: raw material (filament, grams) → printed part → assembled product = 2–3 levels. The schema supports arbitrary depth (BOMLine.component_id → Product that itself has a BOM) but no deep multi-level assemblies have been observed in production data.
Cycle risk — yes, schema allows it. BOMLine.component_id is a plain FK to products.id. The only constraint is uq_bom_lines_bom_component (same component can't appear twice in one BOM), which does not prevent cross-BOM cycles. A cycle BOM(A) → component B + BOM(B) → component A is schema-valid. The existing recost_all_items has no visited-set and processes items in DB insert order — it won't infinite-loop (no recursion in the current code) but will produce wrong results on any cycle. A synchronous cascade must detect and break cycles explicitly.
Job runner availability — no. requirements.txt contains no Celery, RQ, Dramatiq, APScheduler, ARQ, or Huey. FastAPI's in-process BackgroundTasks is used in two admin endpoints (pro_install.py, system.py) but provides no persistence, retry, or durability — a uvicorn restart between receipt commit and task execution silently drops the work.
Design options
Option 1 — Synchronous cascade in receive_purchase_order
After the existing standard_cost sync loop (lines 921–942), walk the BOM graph upward: for each updated product ID query BOMLine WHERE component_id IN (changed_ids) joined to BOM WHERE active = true, call recost_item() for each parent product, then recurse (BFS) until the frontier is empty. All writes share the same SQLAlchemy session and commit with the receipt.
Pros: Zero stale state — the receipt transaction commits with all ancestors current. Simple mental model, no moving parts, no new infrastructure. Cons: Transaction size and receive latency grow proportionally to tree depth × fan-out. A recost failure deep in the tree rolls back the entire receipt. Concurrent receives of the same component contend on shared parent rows.
Option 2 — Background job (async)
After the receipt commits, enqueue the changed product IDs into a background worker that calls recost_item() asynchronously, level by level.
Pros: PO receive stays fast regardless of tree depth. Recost failures are isolated from the receipt transaction. Cons: No persistent job runner exists in Core — adding Celery/RQ means new infrastructure, new ops burden, and a new Core dependency. FastAPI BackgroundTasks is in-process only; any process restart between receipt and task execution drops the work silently. Eventual-consistency window: parent costs are stale from receipt until the worker runs (seconds to minutes depending on load).
Option 3 — "Ripple Recost" button in receive UI
After receipt, surface the list of affected parent products in the confirmation response and add a "Cascade Recost" call-to-action in the UI that triggers recost_all_items scoped to those parent IDs.
Pros: Zero new backend complexity beyond a scoped recost call. Explicit user intent — no surprise cost shifts. Trivial to implement and test. Cons: Still manual — the original complaint from the COMP-007 incident. Users who dismiss the modal or skip the banner will have stale parents indefinitely.
Recommendation
Option 1 (synchronous cascade), with depth and timeout guards.
No persistent job runner exists, making Option 2 impractical without significant new infrastructure. Option 3 keeps the manual burden that triggered this issue. Because FilaOps BOM trees are shallow in practice (2–3 levels), the transaction overhead of synchronous BFS recursion is bounded and acceptable. Depth and timeout guards prevent runaway behavior on pathological or cyclic data without adding external dependencies.
Cycle / N+1 mitigations
BFS with a visited-set: Start from the set of changed product IDs; expand level-by-level querying BOMLine WHERE component_id IN (current_frontier) joined to BOM WHERE active = true; track every product_id added to the visited set and skip re-processing. Break when the frontier is empty.
Single eager-loaded query per BFS level: One IN query per level fetches all parent product_ids — not one query per parent. Mirrors the batch pattern already used in the product_receipt_accum BOM/routing membership checks at lines 922–932.
Hard depth cap at 10 levels (configurable via env var RECOST_MAX_DEPTH): if BFS reaches depth 10, emit a logger.warning with the full product chain and stop. Ten levels is far beyond any realistic 3D-print-farm assembly tree; hitting it almost certainly indicates a data problem.
Side note — recost_all_items ordering: the existing bulk recost iterates in DB insert order without topological sort; on a 2-level tree it can process a parent before its newly-updated child. This is a pre-existing issue (out of scope for this change) but worth a follow-up cleanup (topological sort via BFS from leaf products upward).
Acceptance criteria
Receiving a PO that changes a leaf component's standard_cost propagates to all active manufactured parents within the same transaction (Option 1).
Tests for:
Depth-1 parent (assembly directly consuming the received component — happy path)
Depth-3 chain (A → B → C → leaf: all three ancestors recosted)
Cycle (BOM(A) → component B + BOM(B) → component A): cascade detects the cycle, logs a warning, does not infinite-loop, receipts successfully
Parent whose BOM does not include the changed component (no-op — skip)
Problem
PR #558 fixed the leaf half of cost propagation: receiving a PO now auto-syncs
standard_cost ← average_costfor purchased items. The parent half remains manual — manufactured assemblies that consume the updated component keep their stale rolled-up cost until someone runs "Recost All".Concrete scenario (COMP-007, 2026-04-25)
COMP-007at$0.0575/unitvia PO.COMP-007.standard_costupdates$0 → $0.0575✓ (PR fix: auto-sync standard_cost on PO receive for purchased items #558).FG-008has BOM2× COMP-007. Itsstandard_costwas rolled up when COMP-007 was$0and still reads as if the component is free.FG-008.standard_costis wrong — andRoutingOperationMaterial.unit_coston any routing that references COMP-007 is also wrong.Verified at
purchase_order_service.py:921–942— the sync loop updates leafstandard_costdirectly, with norecalculate_bom_costcall for parents.Investigation findings
Where component costs are consumed — two paths:
BOMLine.component→recalculate_bom_cost()(inbom_management_service.py) reads each line'scomponent.standard_costto compute BOM material cost.RoutingOperationMaterial.unit_costproperty (manufacturing.py:326) readscomponent.standard_cost or average_cost or last_cost, then divides bypurchase_factor. This feedsextended_cost, which feedsRouting.recalculate_totals()→total_cost.Both paths flow into
calculate_item_cost()/recost_item()initem_service.py. A stale leaf cost corrupts both BOM and routing rollups for every ancestor.Estimated tree depth — shallow.
FilaOps targets 3D print farms. Typical tree: raw material (filament, grams) → printed part → assembled product = 2–3 levels. The schema supports arbitrary depth (
BOMLine.component_id→Productthat itself has aBOM) but no deep multi-level assemblies have been observed in production data.Cycle risk — yes, schema allows it.
BOMLine.component_idis a plain FK toproducts.id. The only constraint isuq_bom_lines_bom_component(same component can't appear twice in one BOM), which does not prevent cross-BOM cycles. A cycleBOM(A) → component B+BOM(B) → component Ais schema-valid. The existingrecost_all_itemshas no visited-set and processes items in DB insert order — it won't infinite-loop (no recursion in the current code) but will produce wrong results on any cycle. A synchronous cascade must detect and break cycles explicitly.Job runner availability — no.
requirements.txtcontains no Celery, RQ, Dramatiq, APScheduler, ARQ, or Huey. FastAPI's in-processBackgroundTasksis used in two admin endpoints (pro_install.py,system.py) but provides no persistence, retry, or durability — a uvicorn restart between receipt commit and task execution silently drops the work.Design options
Option 1 — Synchronous cascade in
receive_purchase_orderAfter the existing
standard_costsync loop (lines 921–942), walk the BOM graph upward: for each updated product ID queryBOMLine WHERE component_id IN (changed_ids)joined toBOM WHERE active = true, callrecost_item()for each parent product, then recurse (BFS) until the frontier is empty. All writes share the same SQLAlchemy session and commit with the receipt.Pros: Zero stale state — the receipt transaction commits with all ancestors current. Simple mental model, no moving parts, no new infrastructure.
Cons: Transaction size and receive latency grow proportionally to tree depth × fan-out. A recost failure deep in the tree rolls back the entire receipt. Concurrent receives of the same component contend on shared parent rows.
Option 2 — Background job (async)
After the receipt commits, enqueue the changed product IDs into a background worker that calls
recost_item()asynchronously, level by level.Pros: PO receive stays fast regardless of tree depth. Recost failures are isolated from the receipt transaction.
Cons: No persistent job runner exists in Core — adding Celery/RQ means new infrastructure, new ops burden, and a new Core dependency. FastAPI
BackgroundTasksis in-process only; any process restart between receipt and task execution drops the work silently. Eventual-consistency window: parent costs are stale from receipt until the worker runs (seconds to minutes depending on load).Option 3 — "Ripple Recost" button in receive UI
After receipt, surface the list of affected parent products in the confirmation response and add a "Cascade Recost" call-to-action in the UI that triggers
recost_all_itemsscoped to those parent IDs.Pros: Zero new backend complexity beyond a scoped recost call. Explicit user intent — no surprise cost shifts. Trivial to implement and test.
Cons: Still manual — the original complaint from the COMP-007 incident. Users who dismiss the modal or skip the banner will have stale parents indefinitely.
Recommendation
Option 1 (synchronous cascade), with depth and timeout guards.
No persistent job runner exists, making Option 2 impractical without significant new infrastructure. Option 3 keeps the manual burden that triggered this issue. Because FilaOps BOM trees are shallow in practice (2–3 levels), the transaction overhead of synchronous BFS recursion is bounded and acceptable. Depth and timeout guards prevent runaway behavior on pathological or cyclic data without adding external dependencies.
Cycle / N+1 mitigations
BOMLine WHERE component_id IN (current_frontier)joined toBOM WHERE active = true; track everyproduct_idadded to the visited set and skip re-processing. Break when the frontier is empty.INquery per level fetches all parentproduct_ids — not one query per parent. Mirrors the batch pattern already used in theproduct_receipt_accumBOM/routing membership checks at lines 922–932.RECOST_MAX_DEPTH): if BFS reaches depth 10, emit alogger.warningwith the full product chain and stop. Ten levels is far beyond any realistic 3D-print-farm assembly tree; hitting it almost certainly indicates a data problem.recost_all_itemsordering: the existing bulk recost iterates in DB insert order without topological sort; on a 2-level tree it can process a parent before its newly-updated child. This is a pre-existing issue (out of scope for this change) but worth a follow-up cleanup (topological sort via BFS from leaf products upward).Acceptance criteria
standard_costpropagates to all active manufactured parents within the same transaction (Option 1).BOM(A) → component B+BOM(B) → component A): cascade detects the cycle, logs a warning, does not infinite-loop, receipts successfullytest_purchased_item_standard_cost_auto_synced/test_manufactured_item_standard_cost_not_auto_synced.References
purchase_order_service.py:921–942— the sync loop that needs cascade extensionitem_service.py:calculate_item_cost,recost_item,recost_all_items— recost infrastructure a cascade will callmanufacturing.py:RoutingOperationMaterial.unit_cost(line 326) — second path through which stale component cost corrupts parent routing costDesign analysis by Claude (claude-sonnet-4-6). Investigation read:
purchase_order_service.py,item_service.py,bom.py,manufacturing.py,requirements.txt.