Files
ERP-core/docs/30-BACKEND-PHASE2.md
ImanThiyanga 7d6e597389 feat: add production templates API and documentation for manufacturing phase 2
- 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.
2026-07-31 10:22:40 +05:30

38 KiB
Raw Permalink Blame History

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 in 00-CORE.md §7 routing and 01-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) and Services/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 in Dtos/Production).
  • FIFO integration: all stock consumption goes through the existing FifoCostingService.ConsumeAsync (oldest-first, SELECT … FOR UPDATE row-locked, on-hold + expired excluded) inside the UoW transaction — never a parallel consumption path. Production receipt and leftover returns create layers through FifoCostingService inbound posting, same as GRN/transfer-receive.
  • Transactions: every stock-affecting action (stage start, leftover return, terminal receipt, run cancel) runs in one ExecuteInTransactionAsync scope (NFR-02/NFR-05). Document numbers (PRD-…) issue via the existing NumberSequenceService inside the same transaction.
  • Concurrency: templates and runs carry the xmin ETag token; mutating endpoints require If-Match (412 CONCURRENCY_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_LEDGER with new source_doc_type values (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_id becomes 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.GrnLineId was already int? in the entity, in StockLayerConfiguration (no IsRequired), in the model snapshot, and in the live database — TransferService.ReceiveAsync and StockMutator already pass null. Verified against information_schema before 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.SourceDocType and journal_entry_stubs.SourceDocType are varchar(10), and every existing value is a short prefix (GRN, TRF, ADJ, PRET). The originally proposed ProductionIssue (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) PRDI Out
Finished-goods receipt (FR-MFG-13) PRDR In
Leftover return (FR-MFG-14) PRDL In
Cancel return (FR-MFG-17) PRDC In

Defined as consts in Domain/LedgerSourceTypes.cs. source_doc_id is always the run_id, so source_doc_type LIKE 'PRD%' traces every stock movement one run caused. The run's own document number still uses the PRD prefix via DocumentTypes.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:

  1. 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 through RUN_STAGE.template_stage_id → STAGE_EDGE would let a later template PUT silently 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).
  2. RUN_STAGE.pos_x / pos_y. 21-FRONTEND-PHASE2 §5 renders the run canvas from "positions from the run's copied stages", but RUN_STAGE had no position columns.
  3. RUN_STAGE_EVENT.run_id, and run_stage_id made 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_id is nullable with ON DELETE SET NULL. The edit-lock only blocks a template PUT while a run is InProgress, so Completed/Cancelled runs still hold that FK — under RESTRICT a 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_entry are never stored on RUN_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 through ProductionJson like every other jsonb column (canonical JSON only, never the raw client string), capped at 200 entries, with kind validated server-side because nothing else constrains free-form state going into jsonb. ProductionGraphValidator never sees it.

Replacement is wholesale — like STAGE_INPUT/STAGE_OUTPUT and unlike stages, since nothing references an annotation. The consequence is a contract obligation on the client: a PUT that omits annotations clears them, so a caller echoing a fetched graph back must echo these too. Pinned by an explicit preserve → clear → restore assertion in m2_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 is InProgress (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 by stageId turned 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, and POST/PUT accept them. See the PRODUCTION_TEMPLATE.annotations note in Part C, including the obligation to echo them back.
  • Stages and outputs carry a client-facing key alongside 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 mints tmp-<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 so RUN_STAGE.template_stage_id stays valid for historical runs. The builder uses the key as its React Flow node id, which is why parentKey/childKey need no translation on save.
  • PATCH /status is never edit-locked. Deactivating is the FR-MFG-01 path and only blocks new runs, so it stays available while runs are in flight — unlike PUT. Note it bumps the row's xmin and 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-Key is accepted and ignored, matching the Phase-1 posture exactly (GrnsController takes the header, GrnService.ConfirmAsync ignores it). Replay safety comes from the status guards this section already specifies: a double-fire finds the stage already moved on and gets a 409, which 21-FRONTEND-PHASE2 §6 already tells the client to treat as a silent refetch. No key store was built. RunStage additionally carries an xmin RowVersion so two genuinely concurrent terminal approves cannot both read Done and post two receipts.
  • UOM conversion on Stock inputs. Not mentioned anywhere in this doc, but STAGE_INPUT.uom_id is a free FK while the FIFO engine works exclusively in the item's base UOM. All consumption therefore converts through the shared IUomConverter (extracted from GrnService.ToBaseAsync). Consequence for the contract: plannedQty is in the input's declared UOM, while consumedQty/consumedValue/returnedQty/returnedValue are in the item's base UOM. An input whose UOM has no conversion defined for the item is refused with 422, 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 by fromRunOutputId; RUN_EDGE is display and validation only. When one output feeds several inputs and no explicit target is given, they fill in runInputId order up to each one's outstanding need with any overflow to the last; the request accepts an optional runInputId to 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 with 422 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 plannedQty after it went Ready demotes it back to Waiting. 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_DISCONNECTED is 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 as GRAPH_TERMINAL_COUNT instead — the better message, since it names both offenders.

…/startReady → 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.

…/completeInProgress → 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.

…/approveDone → 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 Ready or Waiting. This section said Ready only, while 21-FRONTEND-PHASE2 §5 offers the action on "a Ready/Waiting stage with deliveries". The frontend reading is the correct one — a partially delivered stage is still Waiting and 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 transferredQty is decremented, never zeroed. The parent may also have delivered to a different child; on its re-approve, available = produced scrapped transferred must still account for that other delivery. ActualStartAt is preserved (the original start stands, FR-MFG-19); only ActualEndAt is 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_at no — stays InProgress / null
RUN_STAGE.status yes — entries Ready, all others Waiting
RUN_STAGE.actual_start_at / actual_end_at / field_values yes → null
RUN_STAGE.field_defs / name / role_label / estimated_minutes / pos_x / pos_y / template_stage_id no
RUN_STAGE_OUTPUT.produced_qty / scrapped_qty / scrap_reason_code_id / transferred_qty yes → 0 / null
RUN_STAGE_INPUT.delivered_qty yes → 0 (the WIP is gone)
RUN_STAGE_*.planned_qty no — 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_value no — already-consumed material stays in the cost pool
Stock layers / ledger rows untouched — no reversal entries

One TerminalReject event 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. ScrappedQty lives 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 returns consumedQty returnedQty per Stock input at its consumed weighted cost (value = the exact consumedValue returnedValue residual, so the pool nets to zero), and records the scrapped output quantities on the cancel event as scrappedWrittenOff. completedAt stays 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)

  1. Enum extensions + STOCK_LAYER.grn_line_id nullable migration (shared deviation, A.1)
  2. Template entities + graph validator + /production-templates CRUD
  3. Run creation (scaling, copy-on-create, numbering) + run list/detail
  4. Stage start (FIFO consume) → complete → approve (transfers, readiness)
  5. Terminal receipt + cost pool
  6. Remaining-qty trio: scrap (in complete), leftover return, partial transfer
  7. Reject-intake + terminal reject (rework) + run cancel
  8. 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 (ApproveStageAsync branches 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:

  1. Σ PRDI.value Σ PRDL.value == PRDR.value for a completed run;
  2. a full leftover return leaves returnedValue == consumedValue exactly;
  3. 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.