diff --git a/Backend/ERPCore/Controllers/ProductionRunsController.cs b/Backend/ERPCore/Controllers/ProductionRunsController.cs
new file mode 100644
index 0000000..00d0eb8
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ProductionRunsController.cs
@@ -0,0 +1,157 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Production;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).
+[Route("api/v1/production-runs")]
+public sealed class ProductionRunsController : ApiControllerBase
+{
+ private readonly IProductionRunService _runs;
+
+ public ProductionRunsController(IProductionRunService runs) => _runs = runs;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query,
+ [FromQuery] ProductionRunStatus? status,
+ [FromQuery] int? templateId,
+ [FromQuery] int? warehouseId,
+ CancellationToken ct)
+ => Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
+
+ [HttpGet("{runId:int}")]
+ [ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int runId, CancellationToken ct)
+ {
+ var result = await _runs.GetAsync(runId, ct);
+ if (result is null) return NotFound();
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreateRunRequest request, CancellationToken ct)
+ {
+ var result = await _runs.CreateAsync(request, ct);
+
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
+ }
+
+ [HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
+ [ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> UpdateQuantities(
+ int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
+ => Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
+
+ // --- stage actions (docs/30 §D.3) ---------------------------------------
+ //
+ // Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
+ // §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
+ // the status guards instead: a double-fire finds the stage already moved on and gets a
+ // 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
+ // deviation from §D.3's "idempotency-key honored".
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/start")]
+ [ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Start(
+ int runId, int runStageId,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.StartStageAsync(runId, runStageId, ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
+ [ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Complete(
+ int runId, int runStageId, [FromBody] CompleteStageRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
+ [ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Approve(
+ int runId, int runStageId, [FromBody] ApproveStageRequest? request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
+ [ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Transfer(
+ int runId, int runStageId, [FromBody] TransferRemainderRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
+
+ [HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
+ [ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> ReturnLeftover(
+ int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
+ [ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> RejectIntake(
+ int runId, int runStageId, [FromBody] RejectRequest? request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
+
+ /// Terminal reject — resets the whole run for a rework pass (FR-MFG-16).
+ [HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
+ [ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Reject(
+ int runId, int runStageId, [FromBody] RejectRequest? request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
+
+ [HttpPost("{runId:int}/cancel")]
+ [ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Cancel(
+ int runId, [FromBody] CancelRunRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.CancelAsync(runId, request, ct));
+}
diff --git a/Backend/ERPCore/Controllers/ProductionTemplatesController.cs b/Backend/ERPCore/Controllers/ProductionTemplatesController.cs
new file mode 100644
index 0000000..c637741
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ProductionTemplatesController.cs
@@ -0,0 +1,73 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Production;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).
+[Route("api/v1/production-templates")]
+public sealed class ProductionTemplatesController : ApiControllerBase
+{
+ private readonly IProductionTemplateService _templates;
+
+ public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
+ => Ok(await _templates.ListAsync(query, status, ct));
+
+ [HttpGet("{templateId:int}")]
+ [ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int templateId, CancellationToken ct)
+ {
+ var result = await _templates.GetAsync(templateId, ct);
+ if (result is null) return NotFound();
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create(
+ [FromBody] SaveTemplateRequest request, CancellationToken ct)
+ {
+ var result = await _templates.CreateAsync(request, ct);
+
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
+ }
+
+ [HttpPut("{templateId:int}")]
+ [ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Update(
+ int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _templates.UpdateAsync(templateId, request, expected, ct);
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPatch("{templateId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(
+ int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
+ {
+ await _templates.SetStatusAsync(templateId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs
index 0a84d94..564e516 100644
--- a/Backend/ERPCore/Domain/DocumentTypes.cs
+++ b/Backend/ERPCore/Domain/DocumentTypes.cs
@@ -14,4 +14,7 @@ public static class DocumentTypes
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
+
+ /// Production run (docs/30 FR-MFG-08) — PRD-2026-00001.
+ public const string Production = "PRD";
}
diff --git a/Backend/ERPCore/Domain/Entities/ProductionRun.cs b/Backend/ERPCore/Domain/Entities/ProductionRun.cs
new file mode 100644
index 0000000..4bab646
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ProductionRun.cs
@@ -0,0 +1,59 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One execution instance of a template (FR-MFG-08), numbered PRD-2026-00001.
+/// Every stage, input, output and edge is copied from the template at creation
+/// with quantities scaled by , so a completed run stays
+/// readable even if the template is later edited (FR-MFG-06).
+/// The run's cost pool is derived, never stored:
+/// Σ RunStageInput.ConsumedValue − Σ RunStageInput.ReturnedValue. The terminal
+/// approve divides it by the good quantity to cost the finished layer, then closes it
+/// (FR-MFG-13, 409 RUN_COST_CLOSED).
+/// Mutable aggregate with a token. Model: docs/30 Part C.
+///
+public class ProductionRun
+{
+ public int RunId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int TemplateId { get; set; }
+ public ProductionTemplate? Template { get; set; }
+
+ /// Stock inputs are consumed from, and the finished good received into, this warehouse.
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ /// Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.
+ public int? OutputBinId { get; set; }
+ public Bin? OutputBin { get; set; }
+
+ /// Target quantity of the finished item; drives .
+ public decimal TargetQty { get; set; }
+
+ /// TargetQty / terminalOutput.QtyPerBatch, applied to every copied quantity.
+ public decimal ScaleFactor { get; set; }
+
+ public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
+
+ /// Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.
+ public int ReworkCount { get; set; }
+
+ public int? CancelReasonCodeId { get; set; }
+ public ReasonCode? CancelReason { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+
+ /// Set by the terminal approve only. A cancelled run leaves this null.
+ public DateTime? CompletedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Stages { get; set; } = new List();
+ public ICollection Edges { get; set; } = new List();
+ public ICollection Events { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/ProductionTemplate.cs b/Backend/ERPCore/Domain/Entities/ProductionTemplate.cs
new file mode 100644
index 0000000..2a79aea
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ProductionTemplate.cs
@@ -0,0 +1,43 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// A reusable production-line definition — the stage graph designed on the canvas
+/// (FR-MFG-01). Never hard-deleted once referenced by a run; deactivated instead
+/// (FR-MD-08 posture). Editing is locked while any run of it is InProgress
+/// (FR-MFG-06, 409 TEMPLATE_IN_USE) — edit-lock replaces versioning, which is
+/// why runs copy display fields at creation. Mutable aggregate with a
+/// token. Model: docs/30 Part C.
+///
+public class ProductionTemplate
+{
+ public int TemplateId { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+
+ ///
+ /// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
+ /// verbatim and never interpreted server-side.
+ ///
+ ///
+ /// Not in docs/30 Part C — added because the builder canvas already draws these and
+ /// without somewhere to keep them a save would silently discard the user's layout notes.
+ /// They carry no graph semantics: no ports, no edges, and the validator never sees them.
+ /// Nullable so a template that has none stores nothing rather than an empty array.
+ ///
+ public string? Annotations { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public uint RowVersion { get; set; }
+
+ public ICollection Stages { get; set; } = new List();
+ public ICollection Edges { get; set; } = new List();
+ public ICollection Runs { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunEdge.cs b/Backend/ERPCore/Domain/Entities/RunEdge.cs
new file mode 100644
index 0000000..ba70037
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunEdge.cs
@@ -0,0 +1,28 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A parent → child arrow copied from the template's set at run
+/// creation.
+/// Addition to docs/30 Part C (recorded). The doc's entity model has no run
+/// edge table, but the run graph needs its own copy: deriving edges at read time through
+/// RunStage.TemplateStageId → STAGE_EDGE would let a later template edit silently
+/// rewrite completed-run history — the exact thing FR-MFG-06 exists to prevent — and
+/// breaks outright once that link is nulled by a stage deletion.
+/// Used for the run canvas, child-readiness evaluation and reject-intake's
+/// "delivering parents". Note that WIP delivery is routed by
+/// RunStageInput.FromRunOutputId, not by these edges — an edge is display and
+/// validation only.
+///
+public class RunEdge
+{
+ public int RunEdgeId { get; set; }
+
+ public int RunId { get; set; }
+ public ProductionRun? Run { get; set; }
+
+ public int ParentRunStageId { get; set; }
+ public RunStage? ParentRunStage { get; set; }
+
+ public int ChildRunStageId { get; set; }
+ public RunStage? ChildRunStage { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStage.cs b/Backend/ERPCore/Domain/Entities/RunStage.cs
new file mode 100644
index 0000000..9d223b6
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStage.cs
@@ -0,0 +1,59 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One stage of a run — a copy of a taken at run creation
+/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
+/// Whether this stage is terminal is derived, never stored: a stage is
+/// terminal when it has no outbound . Storing it would let it
+/// drift from the edge set.
+///
+public class RunStage
+{
+ public int RunStageId { get; set; }
+
+ public int RunId { get; set; }
+ public ProductionRun? Run { get; set; }
+
+ ///
+ /// Provenance link back to the template stage. Nullable: a template PUT may
+ /// delete a stage while completed/cancelled runs still reference it (the edit-lock
+ /// only blocks edits during InProgress runs), so the FK is SET NULL rather
+ /// than blocking the edit forever. Everything needed to display a historical run is
+ /// copied below, which is exactly what FR-MFG-06 anticipates.
+ ///
+ public int? TemplateStageId { get; set; }
+ public TemplateStage? TemplateStage { get; set; }
+
+ // --- copied from the template at run creation (FR-MFG-06) ---
+ public string Name { get; set; } = string.Empty;
+ public string? RoleLabel { get; set; }
+ public int EstimatedMinutes { get; set; }
+ public decimal PosX { get; set; }
+ public decimal PosY { get; set; }
+
+ public ProductionStageStatus Status { get; set; } = ProductionStageStatus.Waiting;
+
+ /// Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).
+ public DateTime? ActualStartAt { get; set; }
+ public DateTime? ActualEndAt { get; set; }
+
+ /// Copied from the template stage; definitions survive a rework.
+ public string FieldDefs { get; set; } = "[]";
+
+ /// Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.
+ public string? FieldValues { get; set; }
+
+ ///
+ /// Concurrency token. Stage actions re-read the stage inside their transaction and
+ /// let this xmin check serialize concurrent requests — it is what stops two
+ /// simultaneous terminal approves from both reading Done and posting two
+ /// receipts. See the idempotency note in ProductionRunService.
+ ///
+ public uint RowVersion { get; set; }
+
+ public ICollection Inputs { get; set; } = new List();
+ public ICollection Outputs { get; set; } = new List();
+ public ICollection Events { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStageEvent.cs b/Backend/ERPCore/Domain/Entities/RunStageEvent.cs
new file mode 100644
index 0000000..631dbcc
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStageEvent.cs
@@ -0,0 +1,40 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Immutable history of everything that happened to a run (docs/30 Part C). Written by
+/// every mutating action and never updated or deleted, so a run's story — including the
+/// figures discarded by each rework — survives in full.
+/// Addition to docs/30 Part C (recorded): . The doc hangs
+/// events off the stage only, which leaves run-level events (cancel, terminal reject)
+/// with no home and forces the detail timeline to join through stages. Keeping both
+/// links makes optional and the timeline a single query.
+///
+public class RunStageEvent
+{
+ public int EventId { get; set; }
+
+ public int RunId { get; set; }
+ public ProductionRun? Run { get; set; }
+
+ /// Null for run-level events (Cancel).
+ public int? RunStageId { get; set; }
+ public RunStage? RunStage { get; set; }
+
+ public RunStageEventType EventType { get; set; }
+
+ public string? Note { get; set; }
+
+ ///
+ /// Event-specific detail as jsonb — consumed layers on a Start, pulled-back
+ /// quantities on a RejectIntake, the full pre-rework snapshot on a TerminalReject.
+ /// Pre-serialized string, written only through ProductionJson.
+ ///
+ public string? Payload { get; set; }
+
+ public int UserId { get; set; }
+ public User? User { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStageInput.cs b/Backend/ERPCore/Domain/Entities/RunStageInput.cs
new file mode 100644
index 0000000..a1b9c09
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStageInput.cs
@@ -0,0 +1,55 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One input line of a run stage — a copy of a with its
+/// quantity scaled at creation, plus the live consumption/delivery figures.
+/// Model: docs/30 Part C.
+/// Stock inputs accumulate /
+/// at each start and / on leftover
+/// return or run cancel. Those four columns are the whole cost pool
+/// (Σ consumed − Σ returned) and are deliberately not reset by a terminal
+/// reject — already-consumed material stays in the pool (FR-MFG-16).
+/// Upstream inputs accumulate as parent stages
+/// transfer WIP in. The stage becomes Ready only when every upstream input has
+/// DeliveredQty >= PlannedQty (FR-MFG-09, an all-parents join).
+///
+public class RunStageInput
+{
+ public int RunInputId { get; set; }
+
+ public int RunStageId { get; set; }
+ public RunStage? RunStage { get; set; }
+
+ public StageInputSource Source { get; set; }
+
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ /// The parent output feeding this input. This — not — is what routes a transfer.
+ public int? FromRunOutputId { get; set; }
+ public RunStageOutput? FromRunOutput { get; set; }
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ /// Scaled at creation; per-run editable until the stage starts (FR-MFG-08, 409 STAGE_NOT_EDITABLE).
+ public decimal PlannedQty { get; set; }
+
+ ///
+ /// Stock inputs only, in the item's base UOM. A start consumes
+ /// max(0, PlannedQty − ConsumedQty) and adds to these, so a rework restart
+ /// with an unchanged planned quantity consumes nothing and one with a raised planned
+ /// quantity consumes only the delta (FR-MFG-16).
+ ///
+ public decimal ConsumedQty { get; set; }
+ public decimal ConsumedValue { get; set; }
+
+ /// Upstream inputs only: accumulated by parent transfers.
+ public decimal DeliveredQty { get; set; }
+
+ /// Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.
+ public decimal ReturnedQty { get; set; }
+ public decimal ReturnedValue { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStageOutput.cs b/Backend/ERPCore/Domain/Entities/RunStageOutput.cs
new file mode 100644
index 0000000..6b7d801
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStageOutput.cs
@@ -0,0 +1,43 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// One output line of a run stage — a copy of a with its
+/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
+/// Model: docs/30 Part C.
+/// Available to transfer is derived, never stored:
+/// ProducedQty − ScrappedQty − TransferredQty. Every transfer path checks it and
+/// raises 422 TRANSFER_EXCEEDS_AVAILABLE (FR-MFG-12).
+/// Scrap cost is absorbed into the run cost pool as normal yield loss — no
+/// write-off ledger entry is posted (FR-MFG-11).
+///
+public class RunStageOutput
+{
+ public int RunOutputId { get; set; }
+
+ public int RunStageId { get; set; }
+ public RunStage? RunStage { get; set; }
+
+ /// Null on intermediate (WIP) outputs; set on the terminal output — the finished good.
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ /// Scaled at creation; per-run editable until the stage starts.
+ public decimal PlannedQty { get; set; }
+
+ /// Recorded at complete. A re-complete after a rework overwrites this, never adds to it.
+ public decimal ProducedQty { get; set; }
+
+ public decimal ScrappedQty { get; set; }
+
+ /// Mandatory when > 0, context Production (FR-MFG-11).
+ public int? ScrapReasonCodeId { get; set; }
+ public ReasonCode? ScrapReason { get; set; }
+
+ /// Total WIP handed to children so far, across approve and any later partial transfers.
+ public decimal TransferredQty { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StageEdge.cs b/Backend/ERPCore/Domain/Entities/StageEdge.cs
new file mode 100644
index 0000000..c402369
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StageEdge.cs
@@ -0,0 +1,21 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A parent → child arrow on the template canvas. The edge set must form a DAG with
+/// at least one entry stage and exactly one terminal stage; that is enforced in
+/// ProductionGraphValidator on every save, not by the database (FR-MFG-02).
+/// Model: docs/30 Part C.
+///
+public class StageEdge
+{
+ public int EdgeId { get; set; }
+
+ public int TemplateId { get; set; }
+ public ProductionTemplate? Template { get; set; }
+
+ public int ParentStageId { get; set; }
+ public TemplateStage? ParentStage { get; set; }
+
+ public int ChildStageId { get; set; }
+ public TemplateStage? ChildStage { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StageInput.cs b/Backend/ERPCore/Domain/Entities/StageInput.cs
new file mode 100644
index 0000000..dd63212
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StageInput.cs
@@ -0,0 +1,39 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
+/// shapes applies, enforced by ProductionGraphValidator:
+///
+/// - — set,
+/// null. FIFO-consumed from the run warehouse at stage
+/// start. Allowed on any stage, e.g. packaging added late.
+/// - — set to
+/// an output of a direct parent stage, null. Flows as
+/// internal WIP and never touches stock or the ledger.
+///
+/// Model: docs/30 Part C.
+///
+public class StageInput
+{
+ public int InputId { get; set; }
+
+ public int StageId { get; set; }
+ public TemplateStage? Stage { get; set; }
+
+ public StageInputSource Source { get; set; }
+
+ /// Required when is Stock; null when Upstream.
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ /// Required when is Upstream; must belong to a direct parent.
+ public int? FromOutputId { get; set; }
+ public StageOutput? FromOutput { get; set; }
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ public decimal QtyPerBatch { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StageOutput.cs b/Backend/ERPCore/Domain/Entities/StageOutput.cs
new file mode 100644
index 0000000..2bd69e6
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StageOutput.cs
@@ -0,0 +1,27 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
+/// internal WIP only — is null, no stock and no ledger row
+/// is ever written for them. The terminal stage is the exception: it has exactly one
+/// output and that output must reference a real Item (the finished good), which
+/// is what the production receipt creates a layer for. Model: docs/30 Part C.
+///
+public class StageOutput
+{
+ public int OutputId { get; set; }
+
+ public int StageId { get; set; }
+ public TemplateStage? Stage { get; set; }
+
+ /// Null on intermediate stages; required on the terminal stage.
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ public decimal QtyPerBatch { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/TemplateStage.cs b/Backend/ERPCore/Domain/Entities/TemplateStage.cs
new file mode 100644
index 0000000..4223c18
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/TemplateStage.cs
@@ -0,0 +1,36 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
+/// estimated duration, a formula ( + ) and
+/// custom field definitions. Model: docs/30 Part C.
+///
+public class TemplateStage
+{
+ public int StageId { get; set; }
+
+ public int TemplateId { get; set; }
+ public ProductionTemplate? Template { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ /// Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).
+ public string? RoleLabel { get; set; }
+
+ public int EstimatedMinutes { get; set; }
+
+ /// Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).
+ public decimal PosX { get; set; }
+ public decimal PosY { get; set; }
+
+ ///
+ /// Custom field definitions as jsonb: [{ key, label, type, options?, required }]
+ /// (FR-MFG-07). Held as a pre-serialized string, matching the AuditLog.ChangeSet
+ /// precedent; always written through ProductionJson so the column can only ever
+ /// hold canonical JSON.
+ ///
+ public string FieldDefs { get; set; } = "[]";
+
+ public ICollection Inputs { get; set; } = new List();
+ public ICollection Outputs { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Enums/CustomFieldType.cs b/Backend/ERPCore/Domain/Enums/CustomFieldType.cs
new file mode 100644
index 0000000..6419636
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/CustomFieldType.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 fieldType).
+/// Lives inside the field_defs jsonb rather than a column, but is modelled as an
+/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
+/// is the only type that reads options.
+///
+public enum CustomFieldType
+{
+ Text,
+ Number,
+ Checkbox,
+ Date,
+ Select
+}
diff --git a/Backend/ERPCore/Domain/Enums/ProductionRunStatus.cs b/Backend/ERPCore/Domain/Enums/ProductionRunStatus.cs
new file mode 100644
index 0000000..7d9a024
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/ProductionRunStatus.cs
@@ -0,0 +1,14 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
+/// and reaches exactly one final state: it completes at the
+/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
+/// stock return (FR-MFG-17). Stored as a string.
+///
+public enum ProductionRunStatus
+{
+ InProgress,
+ Completed,
+ Cancelled
+}
diff --git a/Backend/ERPCore/Domain/Enums/ProductionStageStatus.cs b/Backend/ERPCore/Domain/Enums/ProductionStageStatus.cs
new file mode 100644
index 0000000..f5e04a0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/ProductionStageStatus.cs
@@ -0,0 +1,22 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Status of one stage within a production run (docs/30 §B.4, §D.5).
+///
+/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
+/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
+/// InProgress ──complete (produced/scrap/fields)──▶ Done
+/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
+///
+/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
+/// rework transition back into this set and is recorded as a
+/// instead. Stored as a string.
+///
+public enum ProductionStageStatus
+{
+ Waiting,
+ Ready,
+ InProgress,
+ Done,
+ Approved
+}
diff --git a/Backend/ERPCore/Domain/Enums/ReasonContext.cs b/Backend/ERPCore/Domain/Enums/ReasonContext.cs
index b569040..10a8b18 100644
--- a/Backend/ERPCore/Domain/Enums/ReasonContext.cs
+++ b/Backend/ERPCore/Domain/Enums/ReasonContext.cs
@@ -5,5 +5,8 @@ public enum ReasonContext
{
Adjustment,
Return,
- Count
+ Count,
+
+ /// Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).
+ Production
}
diff --git a/Backend/ERPCore/Domain/Enums/RunStageEventType.cs b/Backend/ERPCore/Domain/Enums/RunStageEventType.cs
new file mode 100644
index 0000000..0e1e173
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/RunStageEventType.cs
@@ -0,0 +1,20 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Kind of entry in a run's immutable history (docs/30 Part C, RUN_STAGE_EVENT).
+/// Every mutating action on a run writes exactly one event carrying who/when plus a
+/// jsonb payload; additionally snapshots the whole run's
+/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
+///
+public enum RunStageEventType
+{
+ Start,
+ Complete,
+ Approve,
+ Transfer,
+ RejectIntake,
+ TerminalReject,
+ LeftoverReturn,
+ Cancel,
+ QuantityEdit
+}
diff --git a/Backend/ERPCore/Domain/Enums/StageInputSource.cs b/Backend/ERPCore/Domain/Enums/StageInputSource.cs
new file mode 100644
index 0000000..2635415
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/StageInputSource.cs
@@ -0,0 +1,14 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
+/// inputs reference an Item and are FIFO-consumed from the run
+/// warehouse when the stage starts. inputs reference a direct
+/// parent stage's output and flow as internal WIP — they never touch the ledger.
+/// Stored as a string.
+///
+public enum StageInputSource
+{
+ Stock,
+ Upstream
+}
diff --git a/Backend/ERPCore/Domain/LedgerSourceTypes.cs b/Backend/ERPCore/Domain/LedgerSourceTypes.cs
new file mode 100644
index 0000000..e6cab32
--- /dev/null
+++ b/Backend/ERPCore/Domain/LedgerSourceTypes.cs
@@ -0,0 +1,34 @@
+namespace ERPCore.Domain;
+
+///
+/// Additional STOCK_LEDGER.source_doc_type values for manufacturing movements
+/// (docs/30 §A.2). source_doc_id is always the run_id, so
+/// LIKE 'PRD%' traces every stock movement a run caused.
+///
+///
+/// Deviation from docs/30 §A.2 (recorded). The doc proposes the long names
+/// ProductionIssue/ProductionReceipt/ProductionReturn/
+/// ProductionCancelReturn, but both stock_ledger.SourceDocType and
+/// journal_entry_stubs.SourceDocType are varchar(10) and every existing
+/// value is a short prefix (GRN, TRF, ADJ, PRET). Widening
+/// those columns would be a second Phase-1 schema change beyond the single deviation
+/// §A.1 declares (NFR-08), so the short codes below extend the existing convention
+/// instead. docs/30 §A.2 and §D.5 are amended to match.
+/// These are not entries — production issues no
+/// document per movement. The run's own document number uses
+/// (PRD-2026-00001).
+///
+public static class LedgerSourceTypes
+{
+ /// Stock consumed by a stage start (FR-MFG-10). Outbound.
+ public const string ProductionIssue = "PRDI";
+
+ /// Finished goods received at the terminal approve (FR-MFG-13). Inbound.
+ public const string ProductionReceipt = "PRDR";
+
+ /// Unconsumed material returned before receipt (FR-MFG-14). Inbound.
+ public const string ProductionReturn = "PRDL";
+
+ /// Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.
+ public const string ProductionCancelReturn = "PRDC";
+}
diff --git a/Backend/ERPCore/Dtos/Production/RunDtos.cs b/Backend/ERPCore/Dtos/Production/RunDtos.cs
new file mode 100644
index 0000000..50de0a8
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Production/RunDtos.cs
@@ -0,0 +1,113 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Production;
+
+// Production run contract (docs/30-BACKEND-PHASE2.md §D.2–D.3).
+//
+// Statuses are never client-settable (02-SECURITY §B.6): a run's status and every stage
+// status move only through the stage-action endpoints. Requests here carry quantities and
+// references, nothing else.
+
+// --- responses ---------------------------------------------------------------
+
+/// Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).
+public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
+
+/// Row on the run board (docs/30 §D.2 GET /production-runs).
+public sealed record RunSummaryDto(
+ int RunId, string DocNo, int TemplateId, string TemplateName,
+ decimal TargetQty, ProductionRunStatus Status, int ReworkCount,
+ StageSummaryDto StageSummary, int WarehouseId,
+ int? FinishedItemId, string? FinishedItemName,
+ int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
+
+///
+/// The run cost pool (FR-MFG-13). Always derived from the stage inputs, never stored —
+/// surfaced so the UI can preview the finished unit cost before approving the terminal.
+///
+public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
+
+public sealed record RunStageInputDto(
+ int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
+ decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
+ decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
+
+///
+/// One output of a run stage. AvailableToTransfer is derived — produced − scrapped −
+/// transferred (FR-MFG-12) — and never stored.
+///
+public sealed record RunStageOutputDto(
+ int RunOutputId, int? ItemId, string Name, int UomId,
+ decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
+ decimal TransferredQty, decimal AvailableToTransfer);
+
+///
+/// One stage of a run. IsTerminal (no outbound edge) and IsEntry (no inbound
+/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
+/// ActualMinutes is the elapsed whole minutes once the stage has finished, null while
+/// it is still running (FR-MFG-19). FieldValues is the raw jsonb captured at complete,
+/// passed through verbatim.
+///
+public sealed record RunStageDto(
+ int RunStageId, int? TemplateStageId, string Name, string? RoleLabel,
+ int EstimatedMinutes, decimal PosX, decimal PosY,
+ ProductionStageStatus Status, bool IsTerminal, bool IsEntry,
+ DateTime? ActualStartAt, DateTime? ActualEndAt, int? ActualMinutes,
+ IReadOnlyList FieldDefs, JsonElement? FieldValues,
+ IReadOnlyList Inputs, IReadOnlyList Outputs);
+
+public sealed record RunEdgeDto(int RunEdgeId, int ParentRunStageId, int ChildRunStageId);
+
+public sealed record RunEventDto(
+ int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
+ JsonElement? Payload, int UserId, DateTime CreatedAt);
+
+/// Full run graph (docs/30 §D.2 GET /production-runs/{id}).
+public sealed record RunGraphDto(
+ int RunId, string DocNo, int TemplateId, string TemplateName,
+ int WarehouseId, int? OutputBinId,
+ decimal TargetQty, decimal ScaleFactor,
+ ProductionRunStatus Status, int ReworkCount, int? CancelReasonCodeId,
+ CostPoolDto CostPool,
+ IReadOnlyList Stages, IReadOnlyList Edges, IReadOnlyList Events,
+ int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
+
+// --- requests ----------------------------------------------------------------
+
+public sealed class CreateRunRequest
+{
+ [Range(1, int.MaxValue)]
+ public int TemplateId { get; set; }
+
+ /// Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).
+ [Range(0.0001, double.MaxValue)]
+ public decimal TargetQty { get; set; }
+
+ [Range(1, int.MaxValue)]
+ public int WarehouseId { get; set; }
+
+ /// Optional bin for the finished goods; must belong to .
+ public int? OutputBinId { get; set; }
+}
+
+///
+/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
+/// (409 STAGE_NOT_EDITABLE).
+///
+public sealed class UpdateStageQuantitiesRequest
+{
+ public List Inputs { get; set; } = new();
+ public List Outputs { get; set; } = new();
+}
+
+public sealed class StageQuantityLine
+{
+ /// The runInputId or runOutputId being adjusted.
+ [Range(1, int.MaxValue)]
+ public int Id { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal PlannedQty { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Production/StageActionDtos.cs b/Backend/ERPCore/Dtos/Production/StageActionDtos.cs
new file mode 100644
index 0000000..c4ad03b
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Production/StageActionDtos.cs
@@ -0,0 +1,177 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Production;
+
+// Stage-action contract (docs/30-BACKEND-PHASE2.md §D.3). Every action is transactional,
+// guards on the stage's current status, and returns the refreshed stage plus whatever
+// stock effects it caused.
+
+// --- start -------------------------------------------------------------------
+
+/// One FIFO layer a stage start drew from, at that layer's cost.
+public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
+
+/// What a single Stock input consumed at start (FR-MFG-10).
+public sealed record ConsumedInputDto(
+ int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList ConsumedLayers);
+
+public sealed record StartStageResultDto(
+ int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
+ IReadOnlyList Consumed, IReadOnlyList LedgerRefs,
+ RunStageDto Stage);
+
+// --- complete ----------------------------------------------------------------
+
+public sealed class CompleteStageRequest
+{
+ [Required, MinLength(1)]
+ public List Outputs { get; set; } = new();
+
+ ///
+ /// Values for the stage's custom fields, keyed by fieldDefs[].key. Every field
+ /// marked required must be present and non-empty (400 REQUIRED_FIELD_MISSING).
+ ///
+ public JsonElement? FieldValues { get; set; }
+}
+
+public sealed class CompleteOutputLine
+{
+ [Range(1, int.MaxValue)]
+ public int RunOutputId { get; set; }
+
+ [Range(0, double.MaxValue)]
+ public decimal ProducedQty { get; set; }
+
+ [Range(0, double.MaxValue)]
+ public decimal ScrappedQty { get; set; }
+
+ /// Mandatory once > 0; must be a Production reason.
+ public int? ScrapReasonCodeId { get; set; }
+}
+
+// --- approve / transfer ------------------------------------------------------
+
+///
+/// One WIP hand-off from a parent output to a child input. Deliveries route by
+/// fromRunOutputId, not by edge — see the note on RunEdge.
+///
+public sealed record TransferDto(
+ int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
+ decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
+
+/// The finished-goods layer a terminal approve created (FR-MFG-13).
+public sealed record ReceiptDto(
+ int LayerId, int ItemId, int WarehouseId, int? BinId,
+ decimal QtyReceived, decimal UnitCost, decimal Value);
+
+public sealed record ApproveStageResultDto(
+ int RunStageId, ProductionStageStatus Status, ProductionRunStatus RunStatus,
+ IReadOnlyList Transfers,
+ ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList LedgerRefs,
+ RunStageDto Stage);
+
+public sealed class ApproveStageRequest
+{
+ ///
+ /// Optional partial transfers. Omitted or empty means transfer the full available
+ /// quantity of every output (FR-MFG-12).
+ ///
+ public List Transfers { get; set; } = new();
+}
+
+public sealed class TransferLine
+{
+ [Range(1, int.MaxValue)]
+ public int RunOutputId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal Qty { get; set; }
+
+ ///
+ /// Optional explicit target. When an output feeds several child inputs the server
+ /// otherwise fills them in runInputId order; naming one removes the ambiguity.
+ ///
+ public int? RunInputId { get; set; }
+}
+
+public sealed class TransferRemainderRequest
+{
+ [Range(1, int.MaxValue)]
+ public int RunOutputId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal Qty { get; set; }
+
+ public int? RunInputId { get; set; }
+}
+
+public sealed record TransferResultDto(
+ int RunStageId, IReadOnlyList Transfers, RunStageDto Stage);
+
+// --- leftover return ---------------------------------------------------------
+
+public sealed class ReturnLeftoverRequest
+{
+ ///
+ /// Quantity to return, in the item's base UOM — the same unit
+ /// consumedQty/returnedQty are stored in, since the return posts straight
+ /// to stock. Cannot exceed consumed − already returned.
+ ///
+ [Range(0.0001, double.MaxValue)]
+ public decimal Qty { get; set; }
+
+ /// Mandatory, and must be a Production-context reason (FR-MFG-14).
+ public int? ReasonCodeId { get; set; }
+}
+
+public sealed record ProductionCreatedLayerDto(int LayerId, decimal UnitCost);
+
+public sealed record ReturnLeftoverResultDto(
+ int RunInputId, decimal ReturnedQty, decimal ReturnedValue,
+ ProductionCreatedLayerDto CreatedLayer, IReadOnlyList LedgerRefs, CostPoolDto CostPool);
+
+// --- rejection / rework ------------------------------------------------------
+
+public sealed class RejectRequest
+{
+ [StringLength(500)]
+ public string? Note { get; set; }
+}
+
+/// One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).
+public sealed record PulledBackDto(
+ int RunInputId, int ParentRunStageId, int ParentRunOutputId,
+ decimal Qty, ProductionStageStatus PriorParentStatus, ProductionStageStatus ParentStatus);
+
+public sealed record RejectIntakeResultDto(
+ int RunStageId, ProductionStageStatus Status,
+ IReadOnlyList PulledBack, RunGraphDto Run);
+
+public sealed record TerminalRejectResultDto(
+ int RunStageId, int ReworkCount, RunGraphDto Run);
+
+// --- cancel ------------------------------------------------------------------
+
+public sealed class CancelRunRequest
+{
+ public int? ReasonCodeId { get; set; }
+
+ [StringLength(500)]
+ public string? Note { get; set; }
+}
+
+public sealed record CancelReturnDto(int ItemId, decimal Qty, decimal UnitCost, int LayerId);
+
+///
+/// Scrapped output quantities written off by a cancel. Recorded on the event only — scrap
+/// sits on outputs, which never entered stock, so there is nothing to return (FR-MFG-17).
+///
+public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
+
+public sealed record CancelRunResultDto(
+ int RunId, ProductionRunStatus Status,
+ IReadOnlyList Returns,
+ IReadOnlyList ScrappedWrittenOff,
+ IReadOnlyList LedgerRefs);
diff --git a/Backend/ERPCore/Dtos/Production/TemplateDtos.cs b/Backend/ERPCore/Dtos/Production/TemplateDtos.cs
new file mode 100644
index 0000000..d9185f1
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Production/TemplateDtos.cs
@@ -0,0 +1,177 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Production;
+
+// Production template contract (docs/30-BACKEND-PHASE2.md §D.1).
+//
+// The `Key` vocabulary: every stage and every output carries a client-facing string key
+// alongside its database id. On a GET the key IS the stringified id; on a save the client
+// echoes those keys back for rows it kept and mints "tmp-" keys for rows it just
+// drew. Edges and Upstream inputs then reference stages/outputs *by key* only, which is
+// what lets one payload shape — and one validator — serve both POST (nothing has an id
+// yet) and PUT (most things do).
+
+// --- responses ---------------------------------------------------------------
+
+///
+/// Row on the template list (docs/30 §D.1 GET /production-templates).
+///
+///
+/// StageNames is an addition to the documented shape. The template overview is a
+/// canvas showing every template as a production line with its stages left-to-right
+/// (docs/21 §1), so it needs the names for every listed row — without them the client would
+/// have to fetch each template's full graph just to label the boxes. Ordered by stage id,
+/// matching the graph endpoint.
+///
+public sealed record TemplateSummaryDto(
+ int TemplateId, string Code, string Name, EntityStatus Status,
+ int StageCount, IReadOnlyList StageNames,
+ int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
+
+/// One custom field definition, serialized verbatim into the stage's field_defs jsonb.
+public sealed record FieldDefDto(
+ string Key, string Label, CustomFieldType Type, IReadOnlyList? Options, bool Required);
+
+public sealed record StageInputDto(
+ int InputId, StageInputSource Source, int? ItemId,
+ int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
+
+public sealed record StageOutputDto(
+ int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
+
+public sealed record TemplateStageDto(
+ int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
+ decimal PosX, decimal PosY, IReadOnlyList FieldDefs,
+ IReadOnlyList Inputs, IReadOnlyList Outputs);
+
+public sealed record TemplateEdgeDto(
+ int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
+
+///
+/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
+/// meaning whatsoever, and invisible to the graph validator.
+///
+public sealed record CanvasAnnotationDto(
+ string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
+ string? Label, decimal? Rotation);
+
+/// Full graph (docs/30 §D.1 GET /production-templates/{id}).
+///
+/// ActiveRunCount and Annotations are additions to the documented shape.
+/// The first is what puts the builder into its edit-locked state (docs/21 §2) and mirrors
+/// the condition UpdateAsync enforces — without it the builder would need a second
+/// request to the list endpoint just to know whether to disable itself.
+///
+public sealed record TemplateGraphDto(
+ int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
+ IReadOnlyList Stages, IReadOnlyList Edges,
+ IReadOnlyList Annotations, int ActiveRunCount,
+ int CreatedBy, DateTime CreatedAt);
+
+// --- requests ----------------------------------------------------------------
+// Narrow by design (02-SECURITY §B.6): no status, no ids, no createdBy, no timestamps.
+// POST and PUT share this shape; PUT replaces the whole graph.
+
+public sealed class SaveTemplateRequest
+{
+ [Required, StringLength(30, MinimumLength = 1)]
+ public string Code { get; set; } = string.Empty;
+
+ [Required, StringLength(150, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(500)]
+ public string? Description { get; set; }
+
+ [Required, MinLength(1)]
+ public List Stages { get; set; } = new();
+
+ /// Empty is legal — a single-stage template is both entry and terminal.
+ public List Edges { get; set; } = new();
+
+ ///
+ /// Canvas boxes/lines. Capped because this is free-form client state going into a jsonb
+ /// column — a hand-rolled request should not be able to store an unbounded blob.
+ ///
+ [MaxLength(200)]
+ public List Annotations { get; set; } = new();
+}
+
+public sealed class SaveStageRequest
+{
+ /// Existing stage id as a string, or a client-minted tmp-* key for a new stage.
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string Key { get; set; } = string.Empty;
+
+ [Required, StringLength(150, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(60)]
+ public string? RoleLabel { get; set; }
+
+ [Range(0, 1_000_000)]
+ public int EstimatedMinutes { get; set; }
+
+ /// Canvas coordinates, stored verbatim and never interpreted server-side.
+ public decimal PosX { get; set; }
+ public decimal PosY { get; set; }
+
+ public List FieldDefs { get; set; } = new();
+ public List Inputs { get; set; } = new();
+ public List Outputs { get; set; } = new();
+}
+
+public sealed class SaveInputRequest
+{
+ [Required]
+ public StageInputSource Source { get; set; }
+
+ /// Required when is Stock; must be null when Upstream.
+ public int? ItemId { get; set; }
+
+ /// Required when is Upstream; must name an output of a direct parent.
+ [StringLength(60)]
+ public string? FromOutputKey { get; set; }
+
+ [Range(1, int.MaxValue)]
+ public int UomId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal QtyPerBatch { get; set; }
+}
+
+public sealed class SaveOutputRequest
+{
+ /// Existing output id as a string, or a tmp-* key. Referenced by Upstream inputs.
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string Key { get; set; } = string.Empty;
+
+ /// Required on the terminal stage's single output; must be null on every other output.
+ public int? ItemId { get; set; }
+
+ [Required, StringLength(150, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [Range(1, int.MaxValue)]
+ public int UomId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal QtyPerBatch { get; set; }
+}
+
+public sealed class SaveEdgeRequest
+{
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string ParentKey { get; set; } = string.Empty;
+
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string ChildKey { get; set; } = string.Empty;
+}
+
+/// Body of PATCH /production-templates/{id}/status (docs/30 §D.1).
+public sealed class UpdateTemplateStatusRequest
+{
+ [Required]
+ public EntityStatus Status { get; set; }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs
new file mode 100644
index 0000000..ca46d0f
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs
@@ -0,0 +1,277 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+// Manufacturing / Production Lines (docs/30 Part C). All eleven tables live in this one
+// file, following the StockConfiguration.cs precedent of grouping an aggregate's
+// configurations together.
+//
+// Cascade shape (deliberate): the two aggregate roots cascade to their own children —
+// template → stages/edges → inputs/outputs, run → stages/edges/events → inputs/outputs.
+// Every *cross* reference is Restrict, because making them cascade would give EF two
+// delete paths to the same table and the model validator rejects that. The consequence
+// is an ordering rule for the service layer: when replacing a graph, delete edges before
+// stages and inputs before outputs.
+
+public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("production_templates");
+ builder.HasKey(t => t.TemplateId);
+
+ builder.Property(t => t.Code).IsRequired().HasMaxLength(30);
+ builder.HasIndex(t => t.Code).IsUnique();
+
+ builder.Property(t => t.Name).IsRequired().HasMaxLength(150);
+ builder.Property(t => t.Description).HasMaxLength(500);
+
+ builder.Property(t => t.Status).HasConversion().HasMaxLength(20).IsRequired();
+
+ // Same jsonb-as-string mapping as TemplateStage.FieldDefs and AuditLog.ChangeSet: a
+ // typed/owned mapping would make AuditScribe emit audit rows for the nested entries.
+ builder.Property(t => t.Annotations).HasColumnType("jsonb");
+
+ builder.Property(t => t.CreatedAt).IsRequired();
+
+ // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
+ builder.Property(t => t.RowVersion).IsRowVersion();
+
+ builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(t => t.Status);
+ }
+}
+
+public sealed class TemplateStageConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("template_stages");
+ builder.HasKey(s => s.StageId);
+
+ builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
+ builder.Property(s => s.RoleLabel).HasMaxLength(60);
+ builder.Property(s => s.PosX).HasPrecision(18, 4);
+ builder.Property(s => s.PosY).HasPrecision(18, 4);
+ builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
+
+ builder.HasOne(s => s.Template).WithMany(t => t.Stages)
+ .HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+public sealed class StageEdgeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("stage_edges");
+ builder.HasKey(e => e.EdgeId);
+
+ builder.HasOne(e => e.Template).WithMany(t => t.Edges)
+ .HasForeignKey(e => e.TemplateId).OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(e => e.ParentStage).WithMany()
+ .HasForeignKey(e => e.ParentStageId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(e => e.ChildStage).WithMany()
+ .HasForeignKey(e => e.ChildStageId).OnDelete(DeleteBehavior.Restrict);
+
+ // One arrow per ordered pair; self-loops and cycles are rejected by the validator.
+ builder.HasIndex(e => new { e.ParentStageId, e.ChildStageId }).IsUnique();
+ }
+}
+
+public sealed class StageInputConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("stage_inputs");
+ builder.HasKey(i => i.InputId);
+
+ builder.Property(i => i.Source).HasConversion().HasMaxLength(20).IsRequired();
+ builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
+
+ builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
+ .HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(i => i.FromOutput).WithMany()
+ .HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+public sealed class StageOutputConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("stage_outputs");
+ builder.HasKey(o => o.OutputId);
+
+ builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
+ builder.Property(o => o.QtyPerBatch).HasPrecision(18, 4);
+
+ builder.HasOne(o => o.Stage).WithMany(s => s.Outputs)
+ .HasForeignKey(o => o.StageId).OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+public sealed class ProductionRunConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("production_runs");
+ builder.HasKey(r => r.RunId);
+
+ builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
+ builder.HasIndex(r => r.DocNo).IsUnique();
+
+ builder.Property(r => r.TargetQty).HasPrecision(18, 4);
+ builder.Property(r => r.ScaleFactor).HasPrecision(18, 6);
+ builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired();
+ builder.Property(r => r.CreatedAt).IsRequired();
+ builder.Property(r => r.RowVersion).IsRowVersion();
+
+ builder.HasOne(r => r.Template).WithMany(t => t.Runs)
+ .HasForeignKey(r => r.TemplateId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(r => r.OutputBin).WithMany().HasForeignKey(r => r.OutputBinId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(r => r.CancelReason).WithMany().HasForeignKey(r => r.CancelReasonCodeId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+
+ // Run board filters (docs/30 §D.2) and the template edit-lock count (FR-MFG-06).
+ builder.HasIndex(r => r.Status);
+ builder.HasIndex(r => new { r.TemplateId, r.Status });
+ }
+}
+
+public sealed class RunStageConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("run_stages");
+ builder.HasKey(s => s.RunStageId);
+
+ builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
+ builder.Property(s => s.RoleLabel).HasMaxLength(60);
+ builder.Property(s => s.PosX).HasPrecision(18, 4);
+ builder.Property(s => s.PosY).HasPrecision(18, 4);
+ builder.Property(s => s.Status).HasConversion().HasMaxLength(20).IsRequired();
+ builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
+ builder.Property(s => s.FieldValues).HasColumnType("jsonb");
+ builder.Property(s => s.RowVersion).IsRowVersion();
+
+ builder.HasOne(s => s.Run).WithMany(r => r.Stages)
+ .HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade);
+
+ // SetNull, not Restrict: a template edit may delete a stage that completed or
+ // cancelled runs still point at. Everything needed to render such a run is copied
+ // onto this row, so losing the provenance link is the intended trade (FR-MFG-06).
+ builder.HasOne(s => s.TemplateStage).WithMany()
+ .HasForeignKey(s => s.TemplateStageId).OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasIndex(s => new { s.RunId, s.Status });
+ }
+}
+
+public sealed class RunEdgeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("run_edges");
+ builder.HasKey(e => e.RunEdgeId);
+
+ builder.HasOne(e => e.Run).WithMany(r => r.Edges)
+ .HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(e => e.ParentRunStage).WithMany()
+ .HasForeignKey(e => e.ParentRunStageId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(e => e.ChildRunStage).WithMany()
+ .HasForeignKey(e => e.ChildRunStageId).OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(e => new { e.ParentRunStageId, e.ChildRunStageId }).IsUnique();
+ }
+}
+
+public sealed class RunStageInputConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("run_stage_inputs");
+ builder.HasKey(i => i.RunInputId);
+
+ builder.Property(i => i.Source).HasConversion().HasMaxLength(20).IsRequired();
+ builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
+ builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
+ builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
+ builder.Property(i => i.DeliveredQty).HasPrecision(18, 4);
+ builder.Property(i => i.ReturnedQty).HasPrecision(18, 4);
+ builder.Property(i => i.ReturnedValue).HasPrecision(18, 4);
+
+ builder.HasOne(i => i.RunStage).WithMany(s => s.Inputs)
+ .HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(i => i.FromRunOutput).WithMany()
+ .HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
+
+ // Transfers route by the source output, so this is the hot lookup.
+ builder.HasIndex(i => i.FromRunOutputId);
+ }
+}
+
+public sealed class RunStageOutputConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("run_stage_outputs");
+ builder.HasKey(o => o.RunOutputId);
+
+ builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
+ builder.Property(o => o.PlannedQty).HasPrecision(18, 4);
+ builder.Property(o => o.ProducedQty).HasPrecision(18, 4);
+ builder.Property(o => o.ScrappedQty).HasPrecision(18, 4);
+ builder.Property(o => o.TransferredQty).HasPrecision(18, 4);
+
+ builder.HasOne(o => o.RunStage).WithMany(s => s.Outputs)
+ .HasForeignKey(o => o.RunStageId).OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(o => o.ScrapReason).WithMany().HasForeignKey(o => o.ScrapReasonCodeId).OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+public sealed class RunStageEventConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ // Append-only history: the app never updates or deletes these rows.
+ builder.ToTable("run_stage_events");
+ builder.HasKey(e => e.EventId);
+
+ builder.Property(e => e.EventType).HasConversion().HasMaxLength(20).IsRequired();
+ builder.Property(e => e.Note).HasMaxLength(500);
+ builder.Property(e => e.Payload).HasColumnType("jsonb");
+ builder.Property(e => e.CreatedAt).IsRequired();
+
+ builder.HasOne(e => e.Run).WithMany(r => r.Events)
+ .HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
+
+ // SetNull rather than Cascade: the run-level cascade above already removes these
+ // rows, and a second cascade path (run → stage → event) is what EF rejects.
+ builder.HasOne(e => e.RunStage).WithMany(s => s.Events)
+ .HasForeignKey(e => e.RunStageId).OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
+
+ // The run-detail timeline reads the whole run's history in one ordered pass.
+ builder.HasIndex(e => new { e.RunId, e.EventId });
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
index 79a2352..8972da9 100644
--- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -30,6 +30,12 @@ public static class DataSeeder
("WRONG", "Wrong Item", ReasonContext.Return),
("OVER", "Over-supply", ReasonContext.Return),
("QREJ", "Quality Reject", ReasonContext.Return),
+ // Manufacturing (docs/30 §A.2) — scrap at stage complete, leftover return before
+ // receipt, and the mandatory reason on a run cancel.
+ ("PRD-SCRAP", "Production Scrap", ReasonContext.Production),
+ ("PRD-LEFTOVER", "Production Leftover Return", ReasonContext.Production),
+ ("PRD-CANCEL", "Production Run Cancelled", ReasonContext.Production),
+ ("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index 06cb8e9..ca895cd 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -126,6 +126,21 @@ public class ErpDbContext : DbContext
public DbSet PayrollLineComponents => Set();
public DbSet Payslips => Set();
+ // --- Manufacturing: production templates (docs/30-BACKEND-PHASE2.md Part C) ---
+ public DbSet ProductionTemplates => Set();
+ public DbSet TemplateStages => Set();
+ public DbSet StageEdges => Set();
+ public DbSet StageInputs => Set();
+ public DbSet StageOutputs => Set();
+
+ // --- Manufacturing: production runs (docs/30-BACKEND-PHASE2.md Part C) ---
+ public DbSet ProductionRuns => Set();
+ public DbSet RunStages => Set();
+ public DbSet RunEdges => Set();
+ public DbSet RunStageInputs => Set();
+ public DbSet RunStageOutputs => Set();
+ public DbSet RunStageEvents => Set();
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs
index 42c26b7..c484971 100644
--- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs
+++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs
@@ -2400,6 +2400,136 @@ namespace ERPCore.Infra.Persistence.Migrations
});
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b =>
+ {
+ b.Property("RunId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunId"));
+
+ b.Property("CancelReasonCodeId")
+ .HasColumnType("integer");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("OutputBinId")
+ .HasColumnType("integer");
+
+ b.Property("ReworkCount")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("ScaleFactor")
+ .HasPrecision(18, 6)
+ .HasColumnType("numeric(18,6)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TargetQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("TemplateId")
+ .HasColumnType("integer");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("RunId");
+
+ b.HasIndex("CancelReasonCodeId");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("OutputBinId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("WarehouseId");
+
+ b.HasIndex("TemplateId", "Status");
+
+ b.ToTable("production_runs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b =>
+ {
+ b.Property("TemplateId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TemplateId"));
+
+ b.Property("Annotations")
+ .HasColumnType("jsonb");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("TemplateId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("Status");
+
+ b.ToTable("production_templates", (string)null);
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Property("PoId")
@@ -2769,6 +2899,261 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("role_permissions", (string)null);
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b =>
+ {
+ b.Property("RunEdgeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunEdgeId"));
+
+ b.Property("ChildRunStageId")
+ .HasColumnType("integer");
+
+ b.Property("ParentRunStageId")
+ .HasColumnType("integer");
+
+ b.Property("RunId")
+ .HasColumnType("integer");
+
+ b.HasKey("RunEdgeId");
+
+ b.HasIndex("ChildRunStageId");
+
+ b.HasIndex("RunId");
+
+ b.HasIndex("ParentRunStageId", "ChildRunStageId")
+ .IsUnique();
+
+ b.ToTable("run_edges", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b =>
+ {
+ b.Property("RunStageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunStageId"));
+
+ b.Property("ActualEndAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ActualStartAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EstimatedMinutes")
+ .HasColumnType("integer");
+
+ b.Property("FieldDefs")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("FieldValues")
+ .HasColumnType("jsonb");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("PosX")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("PosY")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RoleLabel")
+ .HasMaxLength(60)
+ .HasColumnType("character varying(60)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("RunId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TemplateStageId")
+ .HasColumnType("integer");
+
+ b.HasKey("RunStageId");
+
+ b.HasIndex("TemplateStageId");
+
+ b.HasIndex("RunId", "Status");
+
+ b.ToTable("run_stages", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b =>
+ {
+ b.Property("EventId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EventId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Payload")
+ .HasColumnType("jsonb");
+
+ b.Property("RunId")
+ .HasColumnType("integer");
+
+ b.Property("RunStageId")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("EventId");
+
+ b.HasIndex("RunStageId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("RunId", "EventId");
+
+ b.ToTable("run_stage_events", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b =>
+ {
+ b.Property("RunInputId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunInputId"));
+
+ b.Property("ConsumedQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ConsumedValue")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("DeliveredQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("FromRunOutputId")
+ .HasColumnType("integer");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("PlannedQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ReturnedQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ReturnedValue")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RunStageId")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.HasKey("RunInputId");
+
+ b.HasIndex("FromRunOutputId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("RunStageId");
+
+ b.HasIndex("UomId");
+
+ b.ToTable("run_stage_inputs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b =>
+ {
+ b.Property("RunOutputId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunOutputId"));
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("PlannedQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ProducedQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RunStageId")
+ .HasColumnType("integer");
+
+ b.Property("ScrapReasonCodeId")
+ .HasColumnType("integer");
+
+ b.Property("ScrappedQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("TransferredQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.HasKey("RunOutputId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("RunStageId");
+
+ b.HasIndex("ScrapReasonCodeId");
+
+ b.HasIndex("UomId");
+
+ b.ToTable("run_stage_outputs", (string)null);
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b =>
{
b.Property("SalaryComponentId")
@@ -2856,6 +3241,114 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("serials", (string)null);
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
+ {
+ b.Property("EdgeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EdgeId"));
+
+ b.Property("ChildStageId")
+ .HasColumnType("integer");
+
+ b.Property("ParentStageId")
+ .HasColumnType("integer");
+
+ b.Property("TemplateId")
+ .HasColumnType("integer");
+
+ b.HasKey("EdgeId");
+
+ b.HasIndex("ChildStageId");
+
+ b.HasIndex("TemplateId");
+
+ b.HasIndex("ParentStageId", "ChildStageId")
+ .IsUnique();
+
+ b.ToTable("stage_edges", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b =>
+ {
+ b.Property("InputId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("InputId"));
+
+ b.Property("FromOutputId")
+ .HasColumnType("integer");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("QtyPerBatch")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("StageId")
+ .HasColumnType("integer");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.HasKey("InputId");
+
+ b.HasIndex("FromOutputId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("StageId");
+
+ b.HasIndex("UomId");
+
+ b.ToTable("stage_inputs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b =>
+ {
+ b.Property("OutputId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("OutputId"));
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("QtyPerBatch")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("StageId")
+ .HasColumnType("integer");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.HasKey("OutputId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("StageId");
+
+ b.HasIndex("UomId");
+
+ b.ToTable("stage_outputs", (string)null);
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.Property("AdjustmentId")
@@ -3537,6 +4030,48 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("hr_tax_slabs", (string)null);
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b =>
+ {
+ b.Property("StageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("StageId"));
+
+ b.Property("EstimatedMinutes")
+ .HasColumnType("integer");
+
+ b.Property("FieldDefs")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("PosX")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("PosY")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RoleLabel")
+ .HasMaxLength(60)
+ .HasColumnType("character varying(60)");
+
+ b.Property("TemplateId")
+ .HasColumnType("integer");
+
+ b.HasKey("StageId");
+
+ b.HasIndex("TemplateId");
+
+ b.ToTable("template_stages", (string)null);
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{
b.Property("UomId")
@@ -4392,6 +4927,58 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("UpdatedByUser");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.ReasonCode", "CancelReason")
+ .WithMany()
+ .HasForeignKey("CancelReasonCodeId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.User", "Creator")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Bin", "OutputBin")
+ .WithMany()
+ .HasForeignKey("OutputBinId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template")
+ .WithMany("Runs")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
+ .WithMany()
+ .HasForeignKey("WarehouseId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("CancelReason");
+
+ b.Navigation("Creator");
+
+ b.Navigation("OutputBin");
+
+ b.Navigation("Template");
+
+ b.Navigation("Warehouse");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.User", "Creator")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Creator");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -4558,6 +5145,143 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Role");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.RunStage", "ChildRunStage")
+ .WithMany()
+ .HasForeignKey("ChildRunStageId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.RunStage", "ParentRunStage")
+ .WithMany()
+ .HasForeignKey("ParentRunStageId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run")
+ .WithMany("Edges")
+ .HasForeignKey("RunId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ChildRunStage");
+
+ b.Navigation("ParentRunStage");
+
+ b.Navigation("Run");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run")
+ .WithMany("Stages")
+ .HasForeignKey("RunId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.TemplateStage", "TemplateStage")
+ .WithMany()
+ .HasForeignKey("TemplateStageId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Run");
+
+ b.Navigation("TemplateStage");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run")
+ .WithMany("Events")
+ .HasForeignKey("RunId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage")
+ .WithMany("Events")
+ .HasForeignKey("RunStageId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("ERPCore.Domain.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Run");
+
+ b.Navigation("RunStage");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.RunStageOutput", "FromRunOutput")
+ .WithMany()
+ .HasForeignKey("FromRunOutputId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.Item", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage")
+ .WithMany("Inputs")
+ .HasForeignKey("RunStageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
+ .WithMany()
+ .HasForeignKey("UomId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("FromRunOutput");
+
+ b.Navigation("Item");
+
+ b.Navigation("RunStage");
+
+ b.Navigation("Uom");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.Item", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage")
+ .WithMany("Outputs")
+ .HasForeignKey("RunStageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ScrapReason")
+ .WithMany()
+ .HasForeignKey("ScrapReasonCodeId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
+ .WithMany()
+ .HasForeignKey("UomId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("RunStage");
+
+ b.Navigation("ScrapReason");
+
+ b.Navigation("Uom");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -4569,6 +5293,92 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Item");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ChildStage")
+ .WithMany()
+ .HasForeignKey("ChildStageId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ParentStage")
+ .WithMany()
+ .HasForeignKey("ParentStageId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template")
+ .WithMany("Edges")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ChildStage");
+
+ b.Navigation("ParentStage");
+
+ b.Navigation("Template");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.StageOutput", "FromOutput")
+ .WithMany()
+ .HasForeignKey("FromOutputId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.Item", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage")
+ .WithMany("Inputs")
+ .HasForeignKey("StageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
+ .WithMany()
+ .HasForeignKey("UomId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("FromOutput");
+
+ b.Navigation("Item");
+
+ b.Navigation("Stage");
+
+ b.Navigation("Uom");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.Item", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage")
+ .WithMany("Outputs")
+ .HasForeignKey("StageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
+ .WithMany()
+ .HasForeignKey("UomId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("Stage");
+
+ b.Navigation("Uom");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -4837,6 +5647,17 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("NavItem");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template")
+ .WithMany("Stages")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Template");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
@@ -4954,6 +5775,24 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Lines");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b =>
+ {
+ b.Navigation("Edges");
+
+ b.Navigation("Events");
+
+ b.Navigation("Stages");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b =>
+ {
+ b.Navigation("Edges");
+
+ b.Navigation("Runs");
+
+ b.Navigation("Stages");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Navigation("Lines");
@@ -4976,6 +5815,15 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Quotations");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b =>
+ {
+ b.Navigation("Events");
+
+ b.Navigation("Inputs");
+
+ b.Navigation("Outputs");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.Navigation("Lines");
@@ -4991,6 +5839,13 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Lines");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b =>
+ {
+ b.Navigation("Inputs");
+
+ b.Navigation("Outputs");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.Navigation("Lines");
diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs
index 4b7ba3e..1a40914 100644
--- a/Backend/ERPCore/Program.cs
+++ b/Backend/ERPCore/Program.cs
@@ -10,6 +10,7 @@ using ERPCore.Services;
using ERPCore.Services.Auth;
using ERPCore.Services.Hrm;
using ERPCore.Services.Interfaces;
+using ERPCore.Services.Production;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authentication;
@@ -81,6 +82,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
// Stock core + goods receipt (docs/11 §4–5)
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -136,12 +138,22 @@ builder.Services.AddScoped();
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
builder.Services.AddScoped();
+// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md Part A). Templates stand
+// alone; runs consume FifoCostingService for all stock movement. ProductionGraphValidator
+// is deliberately unregistered — it is a pure static algorithm, not an injected service.
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
// Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck();
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" }));
+builder.Services.AddSwaggerGen(o =>
+{
+ o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" });
+ o.CustomSchemaIds(t => t.FullName!.Replace("+", "."));
+});
var app = builder.Build();
diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs
index 3da76b1..41b57ad 100644
--- a/Backend/ERPCore/Services/GrnService.cs
+++ b/Backend/ERPCore/Services/GrnService.cs
@@ -33,9 +33,9 @@ public sealed class GrnService : IGrnService
private readonly IRepository _bins;
private readonly IRepository