- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates. - Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs. - Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
38 KiB
30 · BACKEND-PHASE2 — Manufacturing: Production Lines (Model, Rules & API)
Purpose: Single backend source of truth for Phase 2 (Manufacturing / Production Lines): requirements, entity model, business rules, status machines, stock/costing integration, and the full API contract. Frontend consumption rules live in
21-FRONTEND-PHASE2.md. Register this doc in00-CORE.md §7routing and01-DOC-GUIDE.md §2.Phase note: Manufacturing is implemented as Phase 2; Sales & CRM moves to a later phase. This phase builds on the Phase-1 Manufacturing seam (
10-BACKEND-PHASE1 §B.7): the generic FIFO goods-issue/consumption engine (FifoCostingService.ConsumeAsync) is the consumption path, extending FR-STK-03.
Part A — Architecture placement
- Services:
Services/Production/ProductionTemplateService(template CRUD + graph validation) andServices/Production/ProductionRunService(run lifecycle, stage transitions, transfers, receipt). Both follow the Phase-1 layering: controllers → services → repositories/UoW; entities never leave the service layer (DTOs inDtos/Production). - FIFO integration: all stock consumption goes through the existing
FifoCostingService.ConsumeAsync(oldest-first,SELECT … FOR UPDATErow-locked, on-hold + expired excluded) inside the UoW transaction — never a parallel consumption path. Production receipt and leftover returns create layers throughFifoCostingServiceinbound posting, same as GRN/transfer-receive. - Transactions: every stock-affecting action (stage start, leftover return, terminal receipt, run cancel) runs in one
ExecuteInTransactionAsyncscope (NFR-02/NFR-05). Document numbers (PRD-…) issue via the existingNumberSequenceServiceinside the same transaction. - Concurrency: templates and runs carry the
xminETag token; mutating endpoints requireIf-Match(412CONCURRENCY_CONFLICT). Stage-transition endpoints additionally guard on current status (409 on wrong state) so two users can't double-fire an action. - Ledger: every stock movement posts to the append-only
STOCK_LEDGERwith newsource_doc_typevalues (ProductionIssue,ProductionReceipt,ProductionReturn,ProductionCancelReturn). GL-ready journal entries emitted as data, same posture as FR-STK-13.
A.1 Phase-1 schema deviation (explicit)
NFR-08 says Phase 2+ integrates without altering Phase-1 schema. One deviation was anticipated here:
STOCK_LAYER.grn_line_idbecomes nullable — a production receipt creates a layer with no GRN line. Layer origin remains fully traceable through the ledger (source_doc_type='PRDR',source_doc_id=run_id).
AS BUILT — this is a no-op; no migration was written.
StockLayer.GrnLineIdwas alreadyint?in the entity, inStockLayerConfiguration(noIsRequired), in the model snapshot, and in the live database —TransferService.ReceiveAsyncandStockMutatoralready passnull. Verified againstinformation_schemabefore M1. Phase-1 schema was therefore not altered at all by manufacturing, and NFR-08 holds without exception.
(Sales' hold_status addition is not pulled in here; it stays with the Sales phase.)
A.2 Enum extensions (additive)
ReasonContext+=Production(covers scrap, leftover return, cancel return). Seed reason codes:PRD-SCRAP,PRD-LEFTOVER,PRD-CANCEL,PRD-REWORK-LOSS.- Ledger
source_doc_type+= four manufacturing movement types.
AS BUILT — short codes, not the long names. Both
stock_ledger.SourceDocTypeandjournal_entry_stubs.SourceDocTypearevarchar(10), and every existing value is a short prefix (GRN,TRF,ADJ,PRET). The originally proposedProductionIssue(15) /ProductionReceipt(17) /ProductionReturn(16) /ProductionCancelReturn(22) do not fit, and widening the columns would have been a second Phase-1 schema change beyond the single deviation §A.1 declares. The values are therefore:
Movement Code Direction Stock consumed at stage start (FR-MFG-10) PRDIOut Finished-goods receipt (FR-MFG-13) PRDRIn Leftover return (FR-MFG-14) PRDLIn Cancel return (FR-MFG-17) PRDCIn Defined as consts in
Domain/LedgerSourceTypes.cs.source_doc_idis always therun_id, sosource_doc_type LIKE 'PRD%'traces every stock movement one run caused. The run's own document number still uses thePRDprefix viaDocumentTypes.Production(PRD-2026-00001).
Part B — Requirements
B.1 Scope
In scope: production templates designed on a canvas — a directed acyclic graph of stages, each with an input/output formula, role label, estimated time, custom fields, and stored layout; production runs instantiated from a template with auto-scaled, per-run-editable quantities; stage execution lifecycle (waiting → ready → in-progress → done → approved, with rejection/rework paths); real stock integration — FIFO consumption of stock inputs at stage start, internal WIP flow between stages, cost-rolled-up finished-goods receipt at the terminal stage; scrap, leftover return, and partial downstream transfer; run cancellation with stock return; run board listing with per-stage progress.
Out of scope (deferred, hooks retained): role-based enforcement of the stage role label (label is informational this phase — FR-X-01 posture unchanged); machine/work-center capacity and scheduling; labor/overhead absorption into cost (cost = material roll-up only; a cost-component seam is noted in FR-MFG-19); co-products / multiple finished items per run (terminal stage has exactly one output item); template versioning (edit-lock replaces it, FR-MFG-06); production planning / MRP.
B.2 Definitions
Template = reusable production-line definition (the graph) · Stage = one box on the canvas · Edge = parent→child arrow · Entry stage = stage with no inbound edges · Terminal stage = the single stage with no outbound edges · Run = one execution instance of a template (PRD-…) · WIP = work-in-progress quantities flowing between run stages (internal, not stock) · Stock input = a stage input drawn from warehouse stock via FIFO · Upstream input = a stage input fed by a parent stage's output · Cost pool = Σ consumed stock value − Σ leftover returns, absorbed into the finished layer.
B.3 Functional requirements (FR-MFG)
| ID | Requirement | Pri |
|---|---|---|
| FR-MFG-01 | Maintain Production Templates: code, name, description, status (Active/Inactive), and a stage graph. Deactivate — never hard-delete — once any run references the template (MASTER_IN_USE posture, FR-MD-08). |
M |
| FR-MFG-02 | A template's stage graph is a DAG with ≥1 entry stage and exactly one terminal stage (multiple starts converging to one end). Server validates on every save: no cycles, terminal count = 1, no disconnected stages, every non-entry stage reachable from an entry and reaching the terminal. | M |
| FR-MFG-03 | Each stage carries: name, role label (free text, e.g. "QA" — informational only this phase), estimated minutes, canvas position (posX,posY — stored, never interpreted server-side), a formula (inputs + outputs with quantities per batch), and custom field definitions (jsonb). |
M |
| FR-MFG-04 | A stage input is either Stock (references an Item + UOM; FIFO-consumed from the run warehouse when the stage starts — allowed on any stage, e.g. packaging added late) or Upstream (references an output of a direct parent stage; flows as internal WIP, never touches the ledger). | M |
| FR-MFG-05 | A stage output is a named quantity (+UOM). Intermediate outputs are internal WIP only (item_id null — no stock, no ledger). The terminal stage's output must reference a real Item (the finished good) and there is exactly one terminal output. |
M |
| FR-MFG-06 | Edit-lock instead of versioning: a template cannot be edited (PUT) while any run of it is in a non-final status (InProgress) → 409 TEMPLATE_IN_USE. Editing is allowed again when all runs are Completed/Cancelled. Run rows copy display fields (name, role, estimated minutes, field defs, scaled quantities) at creation so completed-run history stays readable even if the template later changes. |
M |
| FR-MFG-07 | Custom fields: field defs are jsonb on the template stage — [{ key, label, type: Text|Number|Checkbox|Date|Select, options?, required }]. Values are jsonb on the run stage, captured at complete; required fields must be present or complete is rejected (400 REQUIRED_FIELD_MISSING). |
M |
| FR-MFG-08 | Run creation: POST with template, target quantity of the finished item, source warehouse, optional output bin. All formula quantities auto-scale by targetQty / terminalOutputQtyPerBatch; scaled planned quantities are copied to run rows and are per-run editable on any stage that has not yet started (in/out both). Doc no from NumberSequence: PRD-2026-00001. |
M |
| FR-MFG-09 | Stage readiness: entry stages are Ready at run creation; every other stage is Waiting and becomes Ready only when every Upstream input has received delivered qty ≥ its planned qty (all-parents join, per-edge full delivery). |
M |
| FR-MFG-10 | Stage start (Ready → InProgress): FIFO-consume all the stage's Stock inputs from the run warehouse in one transaction (existing blocks apply: STOCK_NEGATIVE_BLOCKED, ONHOLD_NOT_ISSUABLE, EXPIRED_BATCH_BLOCKED); record consumed qty + value per input; stamp actualStartAt. |
M |
| FR-MFG-11 | Stage complete (InProgress → Done): record per output produced qty, scrapped qty (+ mandatory reason code when scrap > 0, context Production), and custom field values; stamp actualEndAt. Scrap cost is absorbed into the run cost pool (normal yield loss); no write-off ledger entry. |
M |
| FR-MFG-12 | Stage approve (Done → Approved): non-terminal — transfers output WIP to children. Default transfers the full available produced qty per outbound edge; a partial transfer amount may be given, with the remainder held on the stage and transferable later via a dedicated transfer action (never exceeding produced − scrapped − already transferred → 422 TRANSFER_EXCEEDS_AVAILABLE). |
M |
| FR-MFG-13 | Terminal approve = production receipt: creates a StockLayer for the finished item at the run warehouse/output bin — qty = terminal good produced qty; unit cost = run cost pool ÷ good qty (material roll-up); posts an inbound ledger entry (ProductionReceipt); run → Completed. After receipt the cost pool is closed: no further leftover returns (409 RUN_COST_CLOSED). |
M |
| FR-MFG-14 | Leftover return (remaining-qty b): unconsumed Stock-input quantity may be returned to stock before receipt: creates an inbound layer at the weighted cost actually consumed for that input (cost-preserving), ledger ProductionReturn, mandatory reason code; reduces the cost pool. Cannot exceed consumed − already returned (422 LEFTOVER_EXCEEDS_CONSUMED). |
M |
| FR-MFG-15 | Reject-intake (downstream reject): a Ready stage may reject its received work → its delivering parent stage(s) revert Approved → InProgress (rework), their transferred quantities are pulled back onto the parent, and the rejecting stage returns to Waiting. The parent re-completes/re-approves; consumed stock stays consumed. |
M |
| FR-MFG-16 | Terminal reject (end-of-process reject): rejecting at the terminal stage resets the whole run to its start — entry stages Ready, all others Waiting, all WIP transfer/produced figures cleared into a rework history snapshot, reworkCount +1. Already-consumed materials remain in the cost pool (no automatic re-consumption; if rework needs more material, edit planned Stock-input qty upward — the delta is consumed at that stage's next start). |
M |
| FR-MFG-17 | Run cancel: allowed while InProgress → status Cancelled (mandatory reason code). Net consumed-and-not-returned stock quantities are returned to stock via an adjustment-style inbound at their consumed weighted cost (ProductionCancelReturn ledger entries); quantities already scrapped stay scrapped (their cost is written off with the cancel — recorded on the run, no GL posting this phase). Completed runs cannot be cancelled (409 RUN_NOT_CANCELLABLE). |
M |
| FR-MFG-18 | Run board: list runs with doc no, template, target qty, status, rework count, timestamps, and a per-status stage count summary ({waiting, ready, inProgress, done, approved}) for progress display. |
M |
| FR-MFG-19 | Track estimated vs actual: estimatedMinutes (copied from template) plus actualStartAt/actualEndAt per run stage. (Seam: a future cost-component table can add labor/overhead to the cost pool without schema change to this phase's tables.) |
S |
| FR-MFG-20 | All mutations audit-stamped (actor from token sub), doc numbers sequential per year, statuses never client-settable (narrow DTOs, 02-SECURITY B.6). |
M |
B.4 Status machines
Run: InProgress → Completed | Cancelled
Run stage:
Waiting ──(all upstream inputs fully delivered)──▶ Ready
Ready ──start (consume Stock inputs)──▶ InProgress
InProgress ──complete (produced/scrap/fields)──▶ Done
Done ──approve──▶ Approved ──(non-terminal: WIP transfers out; terminal: receipt, run Completed)
Rework paths:
Ready ──reject-intake──▶ Waiting (and delivering parents: Approved ──▶ InProgress, transfers pulled back)
Terminal Done ──reject──▶ run reset (entries Ready, others Waiting, reworkCount+1)
Rejected is not a resting state — a rejection immediately produces the rework transition above; the rejection event itself is recorded in the stage's history (who/when/note).
Part C — Entity model (authoritative)
PRODUCTION_TEMPLATE(template_id PK, code UQ, name, description, status, created_by FK→USER, created_at)
TEMPLATE_STAGE(stage_id PK, template_id FK→PRODUCTION_TEMPLATE, name, role_label,
estimated_minutes, pos_x, pos_y, field_defs jsonb)
STAGE_EDGE(edge_id PK, template_id FK→PRODUCTION_TEMPLATE,
parent_stage_id FK→TEMPLATE_STAGE, child_stage_id FK→TEMPLATE_STAGE)
-- UQ(parent_stage_id, child_stage_id); parent != child; DAG enforced in service
STAGE_INPUT(input_id PK, stage_id FK→TEMPLATE_STAGE, source, -- Stock | Upstream
item_id FK→ITEM, -- required when Stock, null when Upstream
from_output_id FK→STAGE_OUTPUT, -- required when Upstream (must belong to a direct parent), null when Stock
uom_id FK→UOM, qty_per_batch)
STAGE_OUTPUT(output_id PK, stage_id FK→TEMPLATE_STAGE,
item_id FK→ITEM, -- null on intermediate stages; REQUIRED on the terminal stage
name, uom_id FK→UOM, qty_per_batch)
-- terminal stage: exactly one output row
PRODUCTION_RUN(run_id PK, doc_no UQ, template_id FK→PRODUCTION_TEMPLATE,
warehouse_id FK→WAREHOUSE, output_bin_id FK→BIN,
target_qty, scale_factor, status, rework_count,
cancel_reason_code_id FK→REASON_CODE, created_by FK→USER, created_at, completed_at)
RUN_STAGE(run_stage_id PK, run_id FK→PRODUCTION_RUN, template_stage_id FK→TEMPLATE_STAGE,
name, role_label, estimated_minutes, -- copied at run creation (FR-MFG-06)
status, actual_start_at, actual_end_at,
field_defs jsonb, field_values jsonb)
RUN_STAGE_INPUT(run_input_id PK, run_stage_id FK→RUN_STAGE, source,
item_id FK→ITEM, from_run_output_id FK→RUN_STAGE_OUTPUT, uom_id FK→UOM,
planned_qty, -- scaled at creation, editable until stage start
consumed_qty, consumed_value, -- Stock inputs: set at start
delivered_qty, -- Upstream inputs: accumulated by parent transfers
returned_qty, returned_value) -- leftover returns (FR-MFG-14)
RUN_STAGE_OUTPUT(run_output_id PK, run_stage_id FK→RUN_STAGE,
item_id FK→ITEM, name, uom_id FK→UOM,
planned_qty, produced_qty, scrapped_qty,
scrap_reason_code_id FK→REASON_CODE, transferred_qty)
-- available-to-transfer = produced_qty − scrapped_qty − transferred_qty (derived, never stored)
RUN_STAGE_EVENT(event_id PK, run_id FK→PRODUCTION_RUN, run_stage_id FK→RUN_STAGE [nullable],
event_type, -- Start|Complete|Approve|Transfer|RejectIntake|TerminalReject|LeftoverReturn|Cancel|QuantityEdit
note, payload jsonb, user_id FK→USER, created_at) -- immutable history incl. rework snapshots
RUN_EDGE(run_edge_id PK, run_id FK→PRODUCTION_RUN,
parent_run_stage_id FK→RUN_STAGE, child_run_stage_id FK→RUN_STAGE)
-- UQ(parent_run_stage_id, child_run_stage_id); copied from STAGE_EDGE at run creation
AS BUILT — three additions to this model, all load-bearing:
RUN_EDGE(new table). The doc had no run-edge table, but the run graph needs its own copy for the read-only canvas, child-readiness evaluation and reject-intake's "delivering parents". Deriving edges at read time throughRUN_STAGE.template_stage_id → STAGE_EDGEwould let a later templatePUTsilently rewrite a completed run's shape — exactly what FR-MFG-06 exists to prevent — and breaks outright once that link is nulled (see 3 below).RUN_STAGE.pos_x/pos_y.21-FRONTEND-PHASE2 §5renders the run canvas from "positions from the run's copied stages", butRUN_STAGEhad no position columns.RUN_STAGE_EVENT.run_id, andrun_stage_idmade nullable. Run-level events (cancel) have no stage to hang off, and the run-detail timeline becomes one ordered query instead of a join through stages.Also as built:
RUN_STAGE.template_stage_idis nullable withON DELETE SET NULL. The edit-lock only blocks a templatePUTwhile a run isInProgress, soCompleted/Cancelledruns still hold that FK — underRESTRICTa stage deletion would fail forever, making templates progressively un-editable. Every display field is already copied onto the run row (FR-MFG-06), so losing the provenance link is the intended trade rather than a loss.
is_terminal/is_entryare never stored onRUN_STAGE— both are derived from the run edge set and surfaced as computed DTO fields, so they cannot drift from it.
AS BUILT — a fourth addition, made when the builder was wired:
PRODUCTION_TEMPLATE.annotations(jsonb, nullable).The builder canvas already supported free-floating grouping boxes and divider lines — annotations with no ports, no edges and no graph semantics — and this model had nowhere to keep them, so every save would have silently discarded the user's layout notes. Silent data loss is worse than either deleting the feature or storing a little opaque client state, so the column exists.
Shape: an array of
{ kind: "box"|"line", posX, posY, width, height, label?, rotation? }, written throughProductionJsonlike every other jsonb column (canonical JSON only, never the raw client string), capped at 200 entries, withkindvalidated server-side because nothing else constrains free-form state going into jsonb.ProductionGraphValidatornever sees it.Replacement is wholesale — like
STAGE_INPUT/STAGE_OUTPUTand unlike stages, since nothing references an annotation. The consequence is a contract obligation on the client: aPUTthat omitsannotationsclears them, so a caller echoing a fetched graph back must echo these too. Pinned by an explicit preserve → clear → restore assertion inm2_templates.py.
Costing invariants: run cost pool = Σ consumed_value − Σ returned_value across all run-stage inputs. Terminal receipt unit_cost = cost_pool / good_qty (rounded to cost precision; remainder absorbed into the layer value so Σ ledger value is exact). No stock/ledger row is ever written for Upstream (WIP) movements.
Part D — API contract
Base conventions identical to Phase 1 (11-BACKEND-PHASE1 §1): versioned base path, paging envelope, ProblemDetails errors, ETag/If-Match on mutable resources, Idempotency-Key honored on stage-transition posts.
D.1 Templates
GET /production-templates
Query: q, status, + paging → list envelope of
{ templateId, code, name, status, stageCount, activeRunCount, createdBy, createdAt }.
GET /production-templates/{id} → full graph
{ "templateId": 7, "code": "PT-CHAIR", "name": "Wooden chair line", "status": "Active",
"stages": [
{ "stageId": 21, "name": "Cut frame", "roleLabel": "Carpentry", "estimatedMinutes": 60,
"posX": 80, "posY": 120,
"fieldDefs": [ { "key": "moisture_ok", "label": "Moisture check", "type": "Checkbox", "required": true } ],
"inputs": [ { "inputId": 61, "source": "Stock", "itemId": 1001, "uomId": 3, "qtyPerBatch": 8 } ],
"outputs": [ { "outputId": 91, "itemId": null, "name": "Frame set", "uomId": 5, "qtyPerBatch": 1 } ] },
{ "stageId": 24, "name": "Assemble & QA", "roleLabel": "QA", "estimatedMinutes": 45,
"posX": 560, "posY": 200, "fieldDefs": [],
"inputs": [ { "inputId": 66, "source": "Upstream", "fromOutputId": 91, "uomId": 5, "qtyPerBatch": 1 },
{ "inputId": 67, "source": "Stock", "itemId": 1044, "uomId": 3, "qtyPerBatch": 12 } ],
"outputs": [ { "outputId": 95, "itemId": 2001, "name": "Chair", "uomId": 5, "qtyPerBatch": 1 } ] } ],
"edges": [ { "edgeId": 11, "parentStageId": 21, "childStageId": 24 } ] }
POST /production-templates · PUT /production-templates/{id}
Full-graph payload (same shape as GET, without ids on POST; PUT replaces the graph, requires If-Match).
409 TEMPLATE_IN_USE— PUT while any run isInProgress(FR-MFG-06).422 GRAPH_CYCLE·422 GRAPH_TERMINAL_COUNT(≠1 terminal) ·422 GRAPH_DISCONNECTED·422 GRAPH_INPUT_SOURCE_INVALID(Upstream input not fed by a direct parent's output) ·422 TERMINAL_OUTPUT_ITEM_REQUIRED.
PATCH /production-templates/{id}/status { "status": "Inactive" } → 204. Inactive templates cannot start new runs.
AS BUILT — §D.1 additions, all driven by what the canvas actually needs:
- The list row also carries
stageNames, in flow order. The template overview draws every template as a production line with its stages left to right (21-FRONTEND-PHASE2 §1); without the names on the list row, labelling n rows would take n extra graph fetches. Ordering bystageIdturned out to be insertion order, which routinely puts the terminal stage first and draws the line backwards, so the server toposorts (Kahn, tie-broken by id so parallel branches are stable, falling back to id order if the graph is ever cyclic so a listing can never fail on bad data).- The graph response also carries
activeRunCount— the same figure the list row has, and the one that puts the builder into its edit-locked state. Without it the builder would have to call the list endpoint purely to decide whether to disable itself.- The graph response also carries
annotations, andPOST/PUTaccept them. See thePRODUCTION_TEMPLATE.annotationsnote in Part C, including the obligation to echo them back.- Stages and outputs carry a client-facing
keyalongside their id, and edges/Upstream inputs reference keys. On a GET the key is the stringified id; on a save the client echoes it back for rows it kept and mintstmp-<uuid>for rows it drew. One payload shape and one validator therefore serve both POST (nothing has an id) and PUT (most things do), and a recognised stage key is diffed in place soRUN_STAGE.template_stage_idstays valid for historical runs. The builder uses the key as its React Flow node id, which is whyparentKey/childKeyneed no translation on save.PATCH /statusis never edit-locked. Deactivating is the FR-MFG-01 path and only blocks new runs, so it stays available while runs are in flight — unlikePUT. Note it bumps the row'sxminand therefore invalidates any ETag the caller is holding.
D.2 Runs
GET /production-runs
Query: q, status (InProgress|Completed|Cancelled), templateId, warehouseId, + paging. Newest first. → list envelope of
{ "runId": 501, "docNo": "PRD-2026-00001", "templateId": 7, "templateName": "Wooden chair line",
"targetQty": 50, "status": "InProgress", "reworkCount": 0,
"stageSummary": { "waiting": 1, "ready": 0, "inProgress": 1, "done": 0, "approved": 1 },
"createdBy": 17, "createdAt": "2026-07-28T08:00:00Z", "completedAt": null }
POST /production-runs
{ "templateId": 7, "targetQty": 50, "warehouseId": 1, "outputBinId": 90 }
201 Created — run with all stages copied + quantities scaled (scaleFactor = targetQty / terminalOutputQtyPerBatch); entry stages Ready, others Waiting. 422 TEMPLATE_INACTIVE if template is Inactive.
GET /production-runs/{id} → full run graph: every run stage with status, planned/actual quantities per input & output, deliveredQty, timestamps, fieldDefs/fieldValues, edges, and event history.
PUT /production-runs/{id}/stages/{sid}/quantities
{ "inputs": [ { "runInputId": 301, "plannedQty": 420 } ],
"outputs": [ { "runOutputId": 401, "plannedQty": 52 } ] }
Per-run scaling override (FR-MFG-08). 409 STAGE_NOT_EDITABLE once the stage has started. Raising a Stock input's planned qty after a rework start consumes only the delta at next start (FR-MFG-16).
D.3 Stage actions
All are POST /production-runs/{id}/stages/{sid}/…, transactional, and return the refreshed run stage (plus any stock effects).
AS BUILT — clarifications and deviations, all verified by
Backend/smoke/:
Idempotency-Keyis accepted and ignored, matching the Phase-1 posture exactly (GrnsControllertakes the header,GrnService.ConfirmAsyncignores it). Replay safety comes from the status guards this section already specifies: a double-fire finds the stage already moved on and gets a409, which21-FRONTEND-PHASE2 §6already tells the client to treat as a silent refetch. No key store was built.RunStageadditionally carries anxminRowVersionso two genuinely concurrent terminal approves cannot both readDoneand post two receipts.- UOM conversion on Stock inputs. Not mentioned anywhere in this doc, but
STAGE_INPUT.uom_idis a free FK while the FIFO engine works exclusively in the item's base UOM. All consumption therefore converts through the sharedIUomConverter(extracted fromGrnService.ToBaseAsync). Consequence for the contract:plannedQtyis in the input's declared UOM, whileconsumedQty/consumedValue/returnedQty/returnedValueare in the item's base UOM. An input whose UOM has no conversion defined for the item is refused with422, never assumed 1:1.- Transfers route by input, not by edge. FR-MFG-12 says "per outbound edge", but the model connects an output to a specific input (
RUN_STAGE_INPUT.from_run_output_id): an edge can exist with no input drawing from it, and one output can feed inputs on several children. Delivery therefore routes byfromRunOutputId;RUN_EDGEis display and validation only. When one output feeds several inputs and no explicit target is given, they fill inrunInputIdorder up to each one's outstanding need with any overflow to the last; the request accepts an optionalrunInputIdto remove the ambiguity.- A re-complete overwrites, it does not accumulate. Completing a stage that already has
producedQty > 0(a rework re-complete) replaces the figures. Adding would double the produced quantity on every rework pass. A re-complete that would drop the good quantity below what has already been transferred is refused with422 TRANSFER_EXCEEDS_AVAILABLE.- Start consumes only the delta. Every start consumes
max(0, plannedBase − consumedQty). A rework restart with an unchanged planned quantity therefore makes no FIFO call at all, and one after a raise consumes only the increase. This is what makes FR-MFG-16's "edit planned Stock-input qty upward" work.- Quantity edits re-evaluate readiness. Raising a child's Upstream
plannedQtyafter it wentReadydemotes it back toWaiting. Not stated here; it is the only behaviour consistent with FR-MFG-09.- Batch/serial-tracked finished goods are refused with
422. This doc defines no batch or serial creation on receipt, so a tracked finished item has no valid path; failing loudly beats silently receiving untracked stock.GRAPH_DISCONNECTEDis unreachable in practice and kept only as defence in depth: once the cycle and terminal-count checks pass, an acyclic graph with exactly one sink necessarily has every stage reachable from a source and reaching that sink. An isolated stage surfaces asGRAPH_TERMINAL_COUNTinstead — the better message, since it names both offenders.
…/start → Ready → InProgress
200 { runStageId, status: "InProgress", actualStartAt, consumed: [ { runInputId, itemId, qty, value, consumedLayers: [...] } ], ledgerRefs: [...] }
409 STAGE_NOT_READY · 409 STOCK_NEGATIVE_BLOCKED · 409 ONHOLD_NOT_ISSUABLE · 409 EXPIRED_BATCH_BLOCKED.
…/complete → InProgress → Done
{ "outputs": [ { "runOutputId": 401, "producedQty": 50, "scrappedQty": 2, "scrapReasonCodeId": 12 } ],
"fieldValues": { "moisture_ok": true } }
400 REQUIRED_FIELD_MISSING · 400 REASON_CODE_REQUIRED (scrap > 0 without reason) · 409 STAGE_NOT_IN_PROGRESS.
…/approve → Done → Approved
Optional body for partial transfer: { "transfers": [ { "runOutputId": 401, "qty": 30 } ] } (default = full available per outbound edge).
Non-terminal 200: { status: "Approved", transfers: [ { edge, runOutputId, qty, childDeliveredQty } ] } — children whose every upstream input reaches planned qty flip Waiting → Ready.
Terminal 200: production receipt —
{ "status": "Approved", "runStatus": "Completed",
"receipt": { "layerId": 9100, "itemId": 2001, "warehouseId": 1, "binId": 90,
"qtyReceived": 50, "unitCost": 21.38, "value": 1069.00 },
"costPool": { "consumed": 1101.00, "returned": 32.00, "net": 1069.00 },
"ledgerRefs": [ 61022 ] }
422 TRANSFER_EXCEEDS_AVAILABLE · 409 STAGE_NOT_DONE.
…/transfer — later partial transfer of held remainder (remaining-qty c)
{ "runOutputId": 401, "qty": 20 } → 200 with updated transferredQty + child readiness. Only from Approved stages; 422 TRANSFER_EXCEEDS_AVAILABLE.
…/reject-intake — downstream reject (FR-MFG-15)
{ "note": "Frames warped" } → 200: this stage Waiting, delivering parents Approved → InProgress with pulled-back transferredQty/child deliveredQty; event logged. 409 STAGE_REJECT_INVALID if the stage has no delivered intake.
AS BUILT — allowed from
ReadyorWaiting. This section saidReadyonly, while21-FRONTEND-PHASE2 §5offers the action on "a Ready/Waiting stage with deliveries". The frontend reading is the correct one — a partially delivered stage is stillWaitingand is exactly the case a user needs to reject — so the guard is on delivered intake > 0, not on the status. Reconciled in both docs.The parent's
transferredQtyis decremented, never zeroed. The parent may also have delivered to a different child; on its re-approve,available = produced − scrapped − transferredmust still account for that other delivery.ActualStartAtis preserved (the original start stands, FR-MFG-19); onlyActualEndAtis cleared.
…/reject — terminal reject (FR-MFG-16), terminal stage only
{ "note": "Final QA failed batch" } → 200: run reset (entries Ready, others Waiting), reworkCount incremented, prior figures snapshotted into RUN_STAGE_EVENT. Consumed stock unaffected. Allowed only from Done — approving the terminal completes the run and closes the cost pool, so there is no path back from Approved.
AS BUILT — exactly what resets and what survives. Getting either column wrong silently corrupts the cost pool, so it is enumerated:
Field Reset? PRODUCTION_RUN.rework_count+1 PRODUCTION_RUN.status/completed_atno — stays InProgress/ nullRUN_STAGE.statusyes — entries Ready, all othersWaitingRUN_STAGE.actual_start_at/actual_end_at/field_valuesyes → null RUN_STAGE.field_defs/name/role_label/estimated_minutes/pos_x/pos_y/template_stage_idno RUN_STAGE_OUTPUT.produced_qty/scrapped_qty/scrap_reason_code_id/transferred_qtyyes → 0 / null RUN_STAGE_INPUT.delivered_qtyyes → 0 (the WIP is gone) RUN_STAGE_*.planned_qtyno — per-run edits must survive; FR-MFG-16 expects them to be raised for the rework RUN_STAGE_INPUT.consumed_qty/consumed_value/returned_qty/returned_valueno — already-consumed material stays in the cost pool Stock layers / ledger rows untouched — no reversal entries One
TerminalRejectevent per pass carries the whole pre-reset snapshot (not one row per stage), so "what did rework #2 look like" is a single read.
POST /production-runs/{id}/inputs/{runInputId}/return-leftover (remaining-qty b)
{ "qty": 5, "reasonCodeId": 14 } → 200 { returnedQty, returnedValue, createdLayer: { layerId, unitCost }, ledgerRefs } — inbound at consumed weighted cost.
422 LEFTOVER_EXCEEDS_CONSUMED · 409 RUN_COST_CLOSED (after terminal receipt) · 400 REASON_CODE_REQUIRED.
POST /production-runs/{id}/cancel
{ "reasonCodeId": 15, "note": "Order cancelled" } → 200 { status: "Cancelled", returns: [ { itemId, qty, unitCost, layerId } ], scrappedWrittenOff: [ { runOutputId, name, qty } ], ledgerRefs } (FR-MFG-17).
409 RUN_NOT_CANCELLABLE if Completed or already Cancelled.
AS BUILT — "net consumed − returned − scrapped" is not computable as written.
ScrappedQtylives on outputs, in output UOM, and there is no per-input scrap anywhere in the model, so scrap cannot be deducted from an input return. Physically the doc's intent is already met: scrap is recorded against outputs (finished or intermediate WIP) which never entered stock, so there is nothing to deduct. As built, the cancel returnsconsumedQty − returnedQtyper Stock input at its consumed weighted cost (value = the exactconsumedValue − returnedValueresidual, so the pool nets to zero), and records the scrapped output quantities on the cancel event asscrappedWrittenOff.completedAtstays null — a cancelled run never completed.
D.4 Error catalog additions
| Code | HTTP | When |
|---|---|---|
TEMPLATE_IN_USE |
409 | Template edit while a run is InProgress. |
TEMPLATE_INACTIVE |
422 | Run creation from an Inactive template. |
GRAPH_CYCLE / GRAPH_TERMINAL_COUNT / GRAPH_DISCONNECTED / GRAPH_INPUT_SOURCE_INVALID / TERMINAL_OUTPUT_ITEM_REQUIRED |
422 | Graph validation on template save. |
STAGE_NOT_READY / STAGE_NOT_IN_PROGRESS / STAGE_NOT_DONE / STAGE_NOT_EDITABLE / STAGE_REJECT_INVALID |
409 | Action fired against the wrong stage status. |
REQUIRED_FIELD_MISSING |
400 | Complete without a required custom field value. |
TRANSFER_EXCEEDS_AVAILABLE |
422 | Transfer > produced − scrapped − transferred. |
LEFTOVER_EXCEEDS_CONSUMED |
422 | Leftover return > consumed − already returned. |
RUN_COST_CLOSED |
409 | Leftover return after terminal receipt. |
RUN_NOT_CANCELLABLE |
409 | Cancel on a Completed run. |
Reused from Phase 1: STOCK_NEGATIVE_BLOCKED, ONHOLD_NOT_ISSUABLE, EXPIRED_BATCH_BLOCKED, REASON_CODE_REQUIRED, CONCURRENCY_CONFLICT, IDEMPOTENCY_REPLAY, MASTER_IN_USE. |
D.5 Enumerations
| Enum | Values |
|---|---|
Template status |
Active, Inactive |
Run status |
InProgress, Completed, Cancelled |
Run-stage status |
Waiting, Ready, InProgress, Done, Approved |
stageInputSource |
Stock, Upstream |
Custom fieldType |
Text, Number, Checkbox, Date, Select |
ReasonContext (extended) |
+ Production |
Ledger source_doc_type (extended) |
+ ProductionIssue, ProductionReceipt, ProductionReturn, ProductionCancelReturn |
Part E — Implementation order (PROGRESS seed)
- Enum extensions +
STOCK_LAYER.grn_line_idnullable migration (shared deviation, A.1) - Template entities + graph validator +
/production-templatesCRUD - Run creation (scaling, copy-on-create, numbering) + run list/detail
- Stage start (FIFO consume) → complete → approve (transfers, readiness)
- Terminal receipt + cost pool
- Remaining-qty trio: scrap (in complete), leftover return, partial transfer
- Reject-intake + terminal reject (rework) + run cancel
- Event history + estimated-vs-actual surfacing
AS BUILT — all eight steps complete and smoke-verified (see
Backend/PROGRESS.md). Step 1 turned out to be a no-op (§A.1). The 11 tables shipped as one migration,AddManufacturingPhase2, because the entities are mutually FK-referential and would not compile split across migrations. Steps 4 and 5 are one method (ApproveStageAsyncbranches on terminal) but were verified as separate gates.Verification lives in
Backend/smoke/*.py— re-runnable scripts, one per milestone, against local Postgres with a real AuthHex session. Three invariants are asserted directly against the ledger:
Σ PRDI.value − Σ PRDL.value == PRDR.valuefor a completed run;- a full leftover return leaves
returnedValue == consumedValueexactly;- after a cancel,
Σ PRDC.value == Σ (consumedValue − returnedValue)and on-hand is restored.
End of 30-BACKEND-PHASE2.md. Frontend consumption: 21-FRONTEND-PHASE2.md. Record work: Backend/PROGRESS.md.