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

365 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```json
{ "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
```json
{ "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`
```json
{ "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`
```json
{ "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.
#### `…/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`
```json
{ "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 —
```json
{ "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`.*