PRD: Production orders & BOM explosion
| Module | Inventory (CORE-06) | PRD ID | PRD-PRO-001 |
| Status | Shipped | Owner | Inventory squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/inventory · @nx/core | URD | PRO · REC · TRK |
TL;DR
Gives a manufacturing-style merchant an explicit production order - a document that says "produce this much of this finished good from this recipe, at this location". Each order targets a finished good (a sellable variant or a semi-finished material), is bound to an activated recipe (BOM), and carries planned / actual / scrap quantities, a lifecycle (
DRAFT → IN_PROGRESS → DONE / CANCELLED), scheduling windows and output-lot traceability for recalls. Behind it sits a multi-level BOM explosion engine that flattens the bound recipe through nested sub-assemblies down to the leaf materials actually drawn from stock - with quantity multipliers propagated along each branch, a maximum nesting depth, and cycle detection - and a dedicated tracking vocabulary (PRODUCTION_CONSUME/PRODUCTION_OUTPUT) that records consumption and output against the order.
1. Context & Problem
The recipe model (REC) lets an F&B merchant attach a versioned bill of materials to a variant, and the runtime path auto-deducts ingredients when a kitchen ticket progresses. That covers sell-then-deduct, but it does not cover make-to-stock: a bakery that bakes 200 loaves at 6am, a central kitchen that batches a sauce, a workshop that assembles a kit. These merchants plan a production run ahead of sale, against a recipe, and need a document to track what was planned, what was actually made, and what was scrapped.
Without a production order there is no home for a planned run: no planned-vs-actual-vs-scrap figures, no production number to reference, no lifecycle to move a run from draft to done, and no output lot to trace a recall back to. There is also no general way to expand a recipe that itself contains sub-assemblies (a dough that is itself a recipe) down to the raw materials a run consumes.
This increment delivers the production-order document, its lifecycle model and identifiers, the multi-level BOM explosion engine that flattens nested recipes to leaf materials, and the production tracking vocabulary that records consumption and output - the foundation a make-to-stock run is built on.
2. Goals & Non-Goals
Goals
- A production-order document targeting a finished good (variant or material), bound to an activated recipe, scoped per merchant.
- Carry planned / actual / scrap quantities in the order's unit of measure at decimal precision.
- A lifecycle status with defined transitions (
DRAFT → IN_PROGRESS → DONE / CANCELLED) and a unique, system-generated production number. - A named production location, optional scheduling windows, and output lot / expiry for recall traceability.
- A multi-level BOM explosion engine that flattens a bound recipe through nested sub-assemblies to leaf materials, propagating quantity multipliers, using the latest activated recipe version, and guarding against runaway depth and cycles.
- A dedicated tracking vocabulary (
PRODUCTION_CONSUME/PRODUCTION_OUTPUT, reference typeProductionOrder) so component consumption and finished-good output are recorded as immutable movements against the order.
Non-Goals
- Automated lifecycle execution - the start/complete actions that physically draw components and post the finished good on completion are a forthcoming increment; this PRD delivers the document, the explosion engine, and the tracking vocabulary they will use.
- Capacity planning, work-centre scheduling, or labour/overhead costing.
- Recipe yield and scrap percentage modelling (URD scope) - scrap is captured as an absolute quantity on the order, not derived from a yield rate.
- Multi-currency production costing or inventory valuation.
- The recipe-authoring rules themselves (versioning, activation, item uniqueness) - specified in Recipes / BOM (REC).
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Production document coverage | A run is represented by one order carrying planned / actual / scrap and a unique production number |
| Lifecycle integrity | Status only moves along valid transitions; DONE and CANCELLED are terminal |
| Explosion correctness | A bound recipe flattens to exactly its leaf materials, with per-branch quantities = product of the path |
| Explosion safety | A cyclic recipe chain or a tree deeper than the maximum is refused, never looped |
| Audit vocabulary | Production movements are immutable entries typed PRODUCTION_CONSUME / PRODUCTION_OUTPUT and reference the order |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Plan a production run against a recipe and track planned vs. actual vs. scrap |
| Production staff | Work an order through its lifecycle and record what was actually made |
| Quality / Recall officer | Trace a produced lot back to the order and the components it drew |
Core scenario: a bakery owner creates a production order to bake 200 baguettes from the "Baguette - standard" activated recipe at the main kitchen, scheduled for tomorrow 06:00. The system assigns a production number and opens the order in DRAFT. The bound recipe - whose dough is itself a sub-recipe of flour, water and yeast - is flattened to its leaf materials, with each leaf quantity scaled to the planned 200 units. When the run is later worked and completed, component consumption and the finished-good output are recorded as immutable tracking entries against the order.
5. User Stories
- As an owner, I plan a run by naming the finished good, its recipe, the quantity and the location, so a production order exists to track it.
- As an owner, I record planned, actual and scrap quantities, so I can see how a run performed.
- As production staff, I move an order
DRAFT → IN_PROGRESS → DONE, or cancel it, so its state reflects reality. - As an owner, I want a recipe with sub-assemblies expanded to the raw materials a run consumes, so I see the true draw on stock.
- As an owner, I want a recipe that references itself in a loop refused, so a run can never compute an endless component list.
- As a recall officer, I trace a produced lot back to the order and read the components it consumed, so I can scope a recall.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | A production order targets a finished good (ProductVariant or Material) and is bound to a material recipe | URD-PRO-001..002 |
| FR-2 | The order carries planned / actual / scrap quantities in its unit of measure at decimal precision | URD-PRO-003 |
| FR-3 | The order moves through DRAFT → IN_PROGRESS → DONE / CANCELLED; DONE and CANCELLED are terminal | URD-PRO-004 |
| FR-4 | Each order has a unique, system-generated production number, not user-editable | URD-PRO-005 |
| FR-5 | The order names a production location where components are drawn and the good is produced | URD-PRO-006 |
| FR-6 | The order supports scheduling windows and records start / complete / cancel timestamps | URD-PRO-007..008 |
| FR-7 | The order can carry an output lot number and expiry for recall traceability | URD-PRO-009 |
| FR-8 | Production orders are merchant-isolated and soft-deleted | URD-PRO-010 · URD-CON-002 |
| FR-9 | A bound recipe is flattened across nested sub-assemblies to leaf materials, using the latest activated recipe version per principal | URD-PRO-011..012 |
| FR-10 | Flattening is bounded by a maximum nesting depth and rejects cyclic recipe chains; each leaf quantity is the product of the quantities along its path | URD-PRO-013..014 |
| FR-11 | A variant component without an activated sub-recipe is skipped; a material without a sub-recipe is a leaf consumed directly | URD-PRO-015 |
| FR-12 | Component consumption and finished-good output are recorded as immutable tracking entries referencing the order, typed PRODUCTION_CONSUME / PRODUCTION_OUTPUT | URD-PRO-016 · URD-TRK-001..005 |
Full requirement text and acceptance criteria live in the Inventory URD - PRO. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Tenancy & authz | All operations scoped per merchant (x-merchant-id) and gated by production-order permissions |
| Identifier integrity | The production number is unique and system-generated; it is never user-supplied |
| Precision | Planned / actual / scrap and component quantities use decimal precision (4 places) |
| Explosion safety | Flattening is depth-bounded and cycle-guarded - a malformed recipe graph fails fast, never loops |
| Explosion performance | The BOM graph is pre-loaded in batched, level-by-level queries so flattening runs in memory without per-node round-trips |
| Immutability | Production movements are append-only tracking entries; corrections happen via new entries, never edits |
| i18n | User-facing statuses and reason labels are bilingual ({ en, vi }) |
8. UX & Flows
Lifecycle
Multi-level BOM explosion
The production surface lets the owner pick the finished good and its recipe, set planned quantity and unit, choose the location, optionally schedule the run and label the output lot, then work the order through its lifecycle. The explosion engine expands the bound recipe to the leaf materials a run consumes.
9. Data & Domain
| Entity | Role |
|---|---|
ProductionOrder | The manufacturing document - target finished good, bound recipe, planned/actual/scrap, status, location, production number, scheduling, output lot |
MaterialRecipe | The activated bill of materials the order is bound to; sub-assembly recipes are recursed during explosion |
MaterialRecipeItem | One component line - a leaf material consumed directly, or a principal with its own activated sub-recipe |
InventoryTracking | The immutable movement behind production consumption / output, typed by the production reason codes |
Conceptual only - full schema and invariants live in the inventory domain model. Relations are soft references; integrity is enforced in the services, not by database constraints.
10. Dependencies & Assumptions
Depends on
- Recipes / BOM (REC, PRD-REC-001) - a production order is bound to an activated recipe, which the explosion engine flattens.
- Materials (MAT, PRD-MAT-001) - leaf components of an exploded recipe are materials.
- Inventory locations (LOC) - an order names the location it produces at.
- Movement audit trail (TRK) - production consumption / output are recorded as immutable tracking entries.
@nx/core- the production-order model, status vocabulary, and production reason codes.
Assumptions
- A finished good has at least one activated recipe version to bind to.
- The targeted location exists within the merchant.
- A production-variant component that should decompose carries its own activated sub-recipe; otherwise it is skipped during explosion.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| A recipe references itself, looping the component list | Explosion detects cycles and refuses the graph |
| A deeply nested recipe tree runs away | Explosion is bounded by a maximum nesting depth and fails fast beyond it |
| Stale sub-recipe version used | Explosion always resolves the latest activated version per principal |
| Variant component cannot be decomposed | A variant without an activated sub-recipe is skipped (it is not a material), warned, never emitted as a leaf |
| Lifecycle execution not yet automated | Deliberate - start/complete actions that draw components and post output land in a forthcoming increment; document, engine and tracking vocabulary are in place |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P3 (BOM + advanced) - PRO in the URD feature catalog, alongside MAT and REC |
| Rollout | Merchants on manufacturing / F&B verticals; no feature flag |
| Migration | None - the production-order table and production reason codes ship with the inventory schema |
| Launch criteria | An order can be created with target, recipe, planned quantity and location; a production number is assigned and status opens at DRAFT; status moves only along valid transitions; a bound recipe flattens to its leaf materials with multiplied quantities; cyclic / over-deep graphs are refused; production movements carry the production reason codes and reference the order |
| Monitoring | Explosion failures by reason (cycle, depth), production-order count by status, leaf-material resolution rate |
13. FAQ
How is a production order different from a kitchen recipe deduction? A kitchen deduction is sell-then-deduct - it fires when a ticket item progresses. A production order is make-to-stock - a planned run against a recipe, with planned/actual/scrap and its own lifecycle, recorded ahead of (or independent of) any sale.
What does "multi-level BOM" mean here? A recipe component can itself be a principal with its own activated recipe (a dough inside a baguette). Explosion recurses into those sub-assemblies until it reaches leaf materials, multiplying quantities along the way.
What stops a recipe loop from hanging the system? Explosion tracks visited recipes and enforces a maximum nesting depth - a cycle or an over-deep tree is refused immediately rather than looped.
Which recipe version is used? Always the latest activated version for each principal; draft or deactivated versions are ignored.
Does completing an order draw stock automatically today? Not yet - the document, recipe binding, explosion engine and production tracking vocabulary (PRODUCTION_CONSUME / PRODUCTION_OUTPUT) are in place; the automated execution that posts those movements on completion is a forthcoming increment.
References
- URD: Inventory - PRO · REC · MAT · TRK
- Sibling PRDs: Recipes / bill of material · Materials · Stock, items & movement tracking
- Module: Inventory - overview + traceability
- Developer: @nx/inventory · @nx/core · domain model