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.
This commit is contained in:
2026-07-31 10:22:40 +05:30
parent 415ac94ab2
commit 7d6e597389
80 changed files with 11037 additions and 886 deletions
@@ -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;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2D.3).</summary>
[Route("api/v1/production-runs")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> 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<ActionResult<RunGraphDto>> 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<ActionResult<RunGraphDto>> 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<ActionResult<RunStageDto>> 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<ActionResult<StartStageResultDto>> 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<ActionResult<RunStageDto>> 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<ActionResult<ApproveStageResultDto>> 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<ActionResult<TransferResultDto>> 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<ActionResult<ReturnLeftoverResultDto>> 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<ActionResult<RejectIntakeResultDto>> 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));
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TerminalRejectResultDto>> 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<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CancelAsync(runId, request, ct));
}
@@ -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;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> 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<ActionResult<TemplateGraphDto>> 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<ActionResult<TemplateGraphDto>> 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<ActionResult<TemplateGraphDto>> 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<IActionResult> SetStatus(
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
{
await _templates.SetStatusAsync(templateId, request.Status, ct);
return NoContent();
}
}
+3
View File
@@ -14,4 +14,7 @@ public static class DocumentTypes
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
public const string Production = "PRD";
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One execution instance of a template (FR-MFG-08), numbered <c>PRD-2026-00001</c>.
/// Every stage, input, output and edge is <b>copied</b> from the template at creation
/// with quantities scaled by <see cref="ScaleFactor"/>, so a completed run stays
/// readable even if the template is later edited (FR-MFG-06).
/// <para>The run's <b>cost pool</b> is derived, never stored:
/// <c>Σ RunStageInput.ConsumedValue Σ RunStageInput.ReturnedValue</c>. The terminal
/// approve divides it by the good quantity to cost the finished layer, then closes it
/// (FR-MFG-13, <c>409 RUN_COST_CLOSED</c>).</para>
/// Mutable aggregate with a <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
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; }
/// <summary>Stock inputs are consumed from, and the finished good received into, this warehouse.</summary>
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.</summary>
public int? OutputBinId { get; set; }
public Bin? OutputBin { get; set; }
/// <summary>Target quantity of the finished item; drives <see cref="ScaleFactor"/>.</summary>
public decimal TargetQty { get; set; }
/// <summary><c>TargetQty / terminalOutput.QtyPerBatch</c>, applied to every copied quantity.</summary>
public decimal ScaleFactor { get; set; }
public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
/// <summary>Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.</summary>
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; }
/// <summary>Set by the terminal approve only. A cancelled run leaves this null.</summary>
public DateTime? CompletedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<RunStage> Stages { get; set; } = new List<RunStage>();
public ICollection<RunEdge> Edges { get; set; } = new List<RunEdge>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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, <c>409 TEMPLATE_IN_USE</c>) — edit-lock replaces versioning, which is
/// why runs copy display fields at creation. Mutable aggregate with a
/// <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
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;
/// <summary>
/// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
/// verbatim and never interpreted server-side.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<TemplateStage> Stages { get; set; } = new List<TemplateStage>();
public ICollection<StageEdge> Edges { get; set; } = new List<StageEdge>();
public ICollection<ProductionRun> Runs { get; set; } = new List<ProductionRun>();
}
@@ -0,0 +1,28 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow copied from the template's <see cref="StageEdge"/> set at run
/// creation.
/// <para><b>Addition to docs/30 Part C (recorded).</b> The doc's entity model has no run
/// edge table, but the run graph needs its own copy: deriving edges at read time through
/// <c>RunStage.TemplateStageId → STAGE_EDGE</c> 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.</para>
/// <para>Used for the run canvas, child-readiness evaluation and reject-intake's
/// "delivering parents". Note that <b>WIP delivery is routed by
/// <c>RunStageInput.FromRunOutputId</c>, not by these edges</b> — an edge is display and
/// validation only.</para>
/// </summary>
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; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One stage of a run — a copy of a <see cref="TemplateStage"/> taken at run creation
/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
/// <para><b>Whether this stage is terminal is derived</b>, never stored: a stage is
/// terminal when it has no outbound <see cref="RunEdge"/>. Storing it would let it
/// drift from the edge set.</para>
/// </summary>
public class RunStage
{
public int RunStageId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>
/// Provenance link back to the template stage. <b>Nullable</b>: 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 <c>SET NULL</c> rather
/// than blocking the edit forever. Everything needed to display a historical run is
/// copied below, which is exactly what FR-MFG-06 anticipates.
/// </summary>
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;
/// <summary>Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).</summary>
public DateTime? ActualStartAt { get; set; }
public DateTime? ActualEndAt { get; set; }
/// <summary>Copied from the template stage; definitions survive a rework.</summary>
public string FieldDefs { get; set; } = "[]";
/// <summary>Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.</summary>
public string? FieldValues { get; set; }
/// <summary>
/// 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 <c>Done</c> and posting two
/// receipts. See the idempotency note in <c>ProductionRunService</c>.
/// </summary>
public uint RowVersion { get; set; }
public ICollection<RunStageInput> Inputs { get; set; } = new List<RunStageInput>();
public ICollection<RunStageOutput> Outputs { get; set; } = new List<RunStageOutput>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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.
/// <para><b>Addition to docs/30 Part C (recorded):</b> <see cref="RunId"/>. 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 <see cref="RunStageId"/> optional and the timeline a single query.</para>
/// </summary>
public class RunStageEvent
{
public int EventId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>Null for run-level events (Cancel).</summary>
public int? RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public RunStageEventType EventType { get; set; }
public string? Note { get; set; }
/// <summary>
/// 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 <c>ProductionJson</c>.
/// </summary>
public string? Payload { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,55 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One input line of a run stage — a copy of a <see cref="StageInput"/> with its
/// quantity scaled at creation, plus the live consumption/delivery figures.
/// Model: docs/30 Part C.
/// <para><b>Stock inputs</b> accumulate <see cref="ConsumedQty"/>/<see cref="ConsumedValue"/>
/// at each start and <see cref="ReturnedQty"/>/<see cref="ReturnedValue"/> on leftover
/// return or run cancel. Those four columns are the whole cost pool
/// (<c>Σ consumed Σ returned</c>) and are deliberately <b>not</b> reset by a terminal
/// reject — already-consumed material stays in the pool (FR-MFG-16).</para>
/// <para><b>Upstream inputs</b> accumulate <see cref="DeliveredQty"/> as parent stages
/// transfer WIP in. The stage becomes Ready only when every upstream input has
/// <c>DeliveredQty &gt;= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
/// </summary>
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; }
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
public decimal PlannedQty { get; set; }
/// <summary>
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
/// <c>max(0, PlannedQty ConsumedQty)</c> 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).
/// </summary>
public decimal ConsumedQty { get; set; }
public decimal ConsumedValue { get; set; }
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
public decimal DeliveredQty { get; set; }
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
public decimal ReturnedQty { get; set; }
public decimal ReturnedValue { get; set; }
}
@@ -0,0 +1,43 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
/// Model: docs/30 Part C.
/// <para><b>Available to transfer is derived, never stored:</b>
/// <c>ProducedQty ScrappedQty TransferredQty</c>. Every transfer path checks it and
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
/// write-off ledger entry is posted (FR-MFG-11).</para>
/// </summary>
public class RunStageOutput
{
public int RunOutputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
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; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
public decimal ProducedQty { get; set; }
public decimal ScrappedQty { get; set; }
/// <summary>Mandatory when <see cref="ScrappedQty"/> &gt; 0, context <c>Production</c> (FR-MFG-11).</summary>
public int? ScrapReasonCodeId { get; set; }
public ReasonCode? ScrapReason { get; set; }
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
public decimal TransferredQty { get; set; }
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
/// Model: docs/30 Part C.
/// </summary>
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; }
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
/// <list type="bullet">
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
/// internal WIP and never touches stock or the ledger.</item>
/// </list>
/// Model: docs/30 Part C.
/// </summary>
public class StageInput
{
public int InputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
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; }
}
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
/// <b>internal WIP only</b> — <see cref="ItemId"/> 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 <b>must</b> reference a real Item (the finished good), which
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
/// </summary>
public class StageOutput
{
public int OutputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
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; }
}
@@ -0,0 +1,36 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
/// custom field definitions. Model: docs/30 Part C.
/// </summary>
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;
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
/// <summary>
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
/// hold canonical JSON.
/// </summary>
public string FieldDefs { get; set; } = "[]";
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
/// Lives inside the <c>field_defs</c> 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.
/// <see cref="Select"/> is the only type that reads <c>options</c>.
/// </summary>
public enum CustomFieldType
{
Text,
Number,
Checkbox,
Date,
Select
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
/// <see cref="InProgress"/> 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.
/// </summary>
public enum ProductionRunStatus
{
InProgress,
Completed,
Cancelled
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
/// <code>
/// 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)
/// </code>
/// 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
/// <see cref="RunStageEventType"/> instead. Stored as a string.
/// </summary>
public enum ProductionStageStatus
{
Waiting,
Ready,
InProgress,
Done,
Approved
}
@@ -5,5 +5,8 @@ public enum ReasonContext
{
Adjustment,
Return,
Count
Count,
/// <summary>Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).</summary>
Production
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Kind of entry in a run's immutable history (docs/30 Part C, <c>RUN_STAGE_EVENT</c>).
/// Every mutating action on a run writes exactly one event carrying who/when plus a
/// jsonb payload; <see cref="TerminalReject"/> additionally snapshots the whole run's
/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
/// </summary>
public enum RunStageEventType
{
Start,
Complete,
Approve,
Transfer,
RejectIntake,
TerminalReject,
LeftoverReturn,
Cancel,
QuantityEdit
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
/// <see cref="Stock"/> inputs reference an Item and are FIFO-consumed from the run
/// warehouse when the stage starts. <see cref="Upstream"/> inputs reference a direct
/// parent stage's output and flow as internal WIP — they never touch the ledger.
/// Stored as a string.
/// </summary>
public enum StageInputSource
{
Stock,
Upstream
}
@@ -0,0 +1,34 @@
namespace ERPCore.Domain;
/// <summary>
/// Additional <c>STOCK_LEDGER.source_doc_type</c> values for manufacturing movements
/// (docs/30 §A.2). <c>source_doc_id</c> is always the <c>run_id</c>, so
/// <c>LIKE 'PRD%'</c> traces every stock movement a run caused.
/// </summary>
/// <remarks>
/// <para><b>Deviation from docs/30 §A.2 (recorded).</b> The doc proposes the long names
/// <c>ProductionIssue</c>/<c>ProductionReceipt</c>/<c>ProductionReturn</c>/
/// <c>ProductionCancelReturn</c>, but both <c>stock_ledger.SourceDocType</c> and
/// <c>journal_entry_stubs.SourceDocType</c> are <c>varchar(10)</c> and every existing
/// value is a short prefix (<c>GRN</c>, <c>TRF</c>, <c>ADJ</c>, <c>PRET</c>). 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.</para>
/// <para>These are <b>not</b> <see cref="DocumentTypes"/> entries — production issues no
/// document per movement. The run's own document number uses
/// <see cref="DocumentTypes.Production"/> (<c>PRD-2026-00001</c>).</para>
/// </remarks>
public static class LedgerSourceTypes
{
/// <summary>Stock consumed by a stage start (FR-MFG-10). Outbound.</summary>
public const string ProductionIssue = "PRDI";
/// <summary>Finished goods received at the terminal approve (FR-MFG-13). Inbound.</summary>
public const string ProductionReceipt = "PRDR";
/// <summary>Unconsumed material returned before receipt (FR-MFG-14). Inbound.</summary>
public const string ProductionReturn = "PRDL";
/// <summary>Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.</summary>
public const string ProductionCancelReturn = "PRDC";
}
+113
View File
@@ -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.2D.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 ---------------------------------------------------------------
/// <summary>Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).</summary>
public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
/// <summary>Row on the run board (docs/30 §D.2 <c>GET /production-runs</c>).</summary>
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);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced scrapped
/// transferred (FR-MFG-12) — and never stored.
/// </summary>
public sealed record RunStageOutputDto(
int RunOutputId, int? ItemId, string Name, int UomId,
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
decimal TransferredQty, decimal AvailableToTransfer);
/// <summary>
/// One stage of a run. <c>IsTerminal</c> (no outbound edge) and <c>IsEntry</c> (no inbound
/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
/// <c>ActualMinutes</c> is the elapsed whole minutes once the stage has finished, null while
/// it is still running (FR-MFG-19). <c>FieldValues</c> is the raw jsonb captured at complete,
/// passed through verbatim.
/// </summary>
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<FieldDefDto> FieldDefs, JsonElement? FieldValues,
IReadOnlyList<RunStageInputDto> Inputs, IReadOnlyList<RunStageOutputDto> 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);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
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<RunStageDto> Stages, IReadOnlyList<RunEdgeDto> Edges, IReadOnlyList<RunEventDto> Events,
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
// --- requests ----------------------------------------------------------------
public sealed class CreateRunRequest
{
[Range(1, int.MaxValue)]
public int TemplateId { get; set; }
/// <summary>Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).</summary>
[Range(0.0001, double.MaxValue)]
public decimal TargetQty { get; set; }
[Range(1, int.MaxValue)]
public int WarehouseId { get; set; }
/// <summary>Optional bin for the finished goods; must belong to <see cref="WarehouseId"/>.</summary>
public int? OutputBinId { get; set; }
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
public List<StageQuantityLine> Inputs { get; set; } = new();
public List<StageQuantityLine> Outputs { get; set; } = new();
}
public sealed class StageQuantityLine
{
/// <summary>The <c>runInputId</c> or <c>runOutputId</c> being adjusted.</summary>
[Range(1, int.MaxValue)]
public int Id { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
}
@@ -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 -------------------------------------------------------------------
/// <summary>One FIFO layer a stage start drew from, at that layer's cost.</summary>
public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
/// <summary>What a single Stock input consumed at start (FR-MFG-10).</summary>
public sealed record ConsumedInputDto(
int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList<ConsumedLayerDto> ConsumedLayers);
public sealed record StartStageResultDto(
int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
IReadOnlyList<ConsumedInputDto> Consumed, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
// --- complete ----------------------------------------------------------------
public sealed class CompleteStageRequest
{
[Required, MinLength(1)]
public List<CompleteOutputLine> Outputs { get; set; } = new();
/// <summary>
/// Values for the stage's custom fields, keyed by <c>fieldDefs[].key</c>. Every field
/// marked required must be present and non-empty (<c>400 REQUIRED_FIELD_MISSING</c>).
/// </summary>
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; }
/// <summary>Mandatory once <see cref="ScrappedQty"/> &gt; 0; must be a Production reason.</summary>
public int? ScrapReasonCodeId { get; set; }
}
// --- approve / transfer ------------------------------------------------------
/// <summary>
/// One WIP hand-off from a parent output to a child input. Deliveries route by
/// <c>fromRunOutputId</c>, not by edge — see the note on <c>RunEdge</c>.
/// </summary>
public sealed record TransferDto(
int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
/// <summary>The finished-goods layer a terminal approve created (FR-MFG-13).</summary>
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<TransferDto> Transfers,
ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
public sealed class ApproveStageRequest
{
/// <summary>
/// Optional partial transfers. Omitted or empty means transfer the full available
/// quantity of every output (FR-MFG-12).
/// </summary>
public List<TransferLine> 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; }
/// <summary>
/// Optional explicit target. When an output feeds several child inputs the server
/// otherwise fills them in <c>runInputId</c> order; naming one removes the ambiguity.
/// </summary>
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<TransferDto> Transfers, RunStageDto Stage);
// --- leftover return ---------------------------------------------------------
public sealed class ReturnLeftoverRequest
{
/// <summary>
/// Quantity to return, in the item's <b>base</b> UOM — the same unit
/// <c>consumedQty</c>/<c>returnedQty</c> are stored in, since the return posts straight
/// to stock. Cannot exceed consumed already returned.
/// </summary>
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
/// <summary>Mandatory, and must be a Production-context reason (FR-MFG-14).</summary>
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<int> LedgerRefs, CostPoolDto CostPool);
// --- rejection / rework ------------------------------------------------------
public sealed class RejectRequest
{
[StringLength(500)]
public string? Note { get; set; }
}
/// <summary>One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).</summary>
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<PulledBackDto> 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);
/// <summary>
/// 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).
/// </summary>
public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
public sealed record CancelRunResultDto(
int RunId, ProductionRunStatus Status,
IReadOnlyList<CancelReturnDto> Returns,
IReadOnlyList<ScrapWriteOffDto> ScrappedWrittenOff,
IReadOnlyList<int> LedgerRefs);
@@ -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-<uuid>" 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 ---------------------------------------------------------------
/// <summary>
/// Row on the template list (docs/30 §D.1 <c>GET /production-templates</c>).
/// </summary>
/// <remarks>
/// <c>StageNames</c> 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.
/// </remarks>
public sealed record TemplateSummaryDto(
int TemplateId, string Code, string Name, EntityStatus Status,
int StageCount, IReadOnlyList<string> StageNames,
int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
/// <summary>One custom field definition, serialized verbatim into the stage's <c>field_defs</c> jsonb.</summary>
public sealed record FieldDefDto(
string Key, string Label, CustomFieldType Type, IReadOnlyList<string>? 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<FieldDefDto> FieldDefs,
IReadOnlyList<StageInputDto> Inputs, IReadOnlyList<StageOutputDto> Outputs);
public sealed record TemplateEdgeDto(
int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
/// <summary>
/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
/// meaning whatsoever, and invisible to the graph validator.
/// </summary>
public sealed record CanvasAnnotationDto(
string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
string? Label, decimal? Rotation);
/// <summary>Full graph (docs/30 §D.1 <c>GET /production-templates/{id}</c>).</summary>
/// <remarks>
/// <c>ActiveRunCount</c> and <c>Annotations</c> 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 <c>UpdateAsync</c> enforces — without it the builder would need a second
/// request to the list endpoint just to know whether to disable itself.
/// </remarks>
public sealed record TemplateGraphDto(
int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
IReadOnlyList<TemplateStageDto> Stages, IReadOnlyList<TemplateEdgeDto> Edges,
IReadOnlyList<CanvasAnnotationDto> 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<SaveStageRequest> Stages { get; set; } = new();
/// <summary>Empty is legal — a single-stage template is both entry and terminal.</summary>
public List<SaveEdgeRequest> Edges { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
[MaxLength(200)]
public List<CanvasAnnotationDto> Annotations { get; set; } = new();
}
public sealed class SaveStageRequest
{
/// <summary>Existing stage id as a string, or a client-minted <c>tmp-*</c> key for a new stage.</summary>
[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; }
/// <summary>Canvas coordinates, stored verbatim and never interpreted server-side.</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
public List<FieldDefDto> FieldDefs { get; set; } = new();
public List<SaveInputRequest> Inputs { get; set; } = new();
public List<SaveOutputRequest> Outputs { get; set; } = new();
}
public sealed class SaveInputRequest
{
[Required]
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; must be null when Upstream.</summary>
public int? ItemId { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must name an output of a direct parent.</summary>
[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
{
/// <summary>Existing output id as a string, or a <c>tmp-*</c> key. Referenced by Upstream inputs.</summary>
[Required, StringLength(60, MinimumLength = 1)]
public string Key { get; set; } = string.Empty;
/// <summary>Required on the terminal stage's single output; must be null on every other output.</summary>
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;
}
/// <summary>Body of <c>PATCH /production-templates/{id}/status</c> (docs/30 §D.1).</summary>
public sealed class UpdateTemplateStatusRequest
{
[Required]
public EntityStatus Status { get; set; }
}
@@ -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<ProductionTemplate>
{
public void Configure(EntityTypeBuilder<ProductionTemplate> 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<string>().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<TemplateStage>
{
public void Configure(EntityTypeBuilder<TemplateStage> 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<StageEdge>
{
public void Configure(EntityTypeBuilder<StageEdge> 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<StageInput>
{
public void Configure(EntityTypeBuilder<StageInput> builder)
{
builder.ToTable("stage_inputs");
builder.HasKey(i => i.InputId);
builder.Property(i => i.Source).HasConversion<string>().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<StageOutput>
{
public void Configure(EntityTypeBuilder<StageOutput> 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<ProductionRun>
{
public void Configure(EntityTypeBuilder<ProductionRun> 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<string>().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<RunStage>
{
public void Configure(EntityTypeBuilder<RunStage> 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<string>().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<RunEdge>
{
public void Configure(EntityTypeBuilder<RunEdge> 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<RunStageInput>
{
public void Configure(EntityTypeBuilder<RunStageInput> builder)
{
builder.ToTable("run_stage_inputs");
builder.HasKey(i => i.RunInputId);
builder.Property(i => i.Source).HasConversion<string>().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<RunStageOutput>
{
public void Configure(EntityTypeBuilder<RunStageOutput> 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<RunStageEvent>
{
public void Configure(EntityTypeBuilder<RunStageEvent> 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<string>().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 });
}
}
@@ -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)
@@ -126,6 +126,21 @@ public class ErpDbContext : DbContext
public DbSet<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
public DbSet<Payslip> Payslips => Set<Payslip>();
// --- Manufacturing: production templates (docs/30-BACKEND-PHASE2.md Part C) ---
public DbSet<ProductionTemplate> ProductionTemplates => Set<ProductionTemplate>();
public DbSet<TemplateStage> TemplateStages => Set<TemplateStage>();
public DbSet<StageEdge> StageEdges => Set<StageEdge>();
public DbSet<StageInput> StageInputs => Set<StageInput>();
public DbSet<StageOutput> StageOutputs => Set<StageOutput>();
// --- Manufacturing: production runs (docs/30-BACKEND-PHASE2.md Part C) ---
public DbSet<ProductionRun> ProductionRuns => Set<ProductionRun>();
public DbSet<RunStage> RunStages => Set<RunStage>();
public DbSet<RunEdge> RunEdges => Set<RunEdge>();
public DbSet<RunStageInput> RunStageInputs => Set<RunStageInput>();
public DbSet<RunStageOutput> RunStageOutputs => Set<RunStageOutput>();
public DbSet<RunStageEvent> RunStageEvents => Set<RunStageEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -2400,6 +2400,136 @@ namespace ERPCore.Infra.Persistence.Migrations
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b =>
{
b.Property<int>("RunId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RunId"));
b.Property<int?>("CancelReasonCodeId")
.HasColumnType("integer");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int?>("OutputBinId")
.HasColumnType("integer");
b.Property<int>("ReworkCount")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<decimal>("ScaleFactor")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<decimal>("TargetQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("TemplateId")
.HasColumnType("integer");
b.Property<int>("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<int>("TemplateId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TemplateId"));
b.Property<string>("Annotations")
.HasColumnType("jsonb");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("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<int>("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<int>("RunEdgeId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RunEdgeId"));
b.Property<int>("ChildRunStageId")
.HasColumnType("integer");
b.Property<int>("ParentRunStageId")
.HasColumnType("integer");
b.Property<int>("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<int>("RunStageId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RunStageId"));
b.Property<DateTime?>("ActualEndAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ActualStartAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("EstimatedMinutes")
.HasColumnType("integer");
b.Property<string>("FieldDefs")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("FieldValues")
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<decimal>("PosX")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("PosY")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("RoleLabel")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<int>("RunId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int?>("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<int>("EventId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("EventId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Payload")
.HasColumnType("jsonb");
b.Property<int>("RunId")
.HasColumnType("integer");
b.Property<int?>("RunStageId")
.HasColumnType("integer");
b.Property<int>("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<int>("RunInputId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RunInputId"));
b.Property<decimal>("ConsumedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ConsumedValue")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("DeliveredQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("FromRunOutputId")
.HasColumnType("integer");
b.Property<int?>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("PlannedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReturnedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReturnedValue")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("RunStageId")
.HasColumnType("integer");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("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<int>("RunOutputId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RunOutputId"));
b.Property<int?>("ItemId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<decimal>("PlannedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ProducedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("RunStageId")
.HasColumnType("integer");
b.Property<int?>("ScrapReasonCodeId")
.HasColumnType("integer");
b.Property<decimal>("ScrappedQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("TransferredQty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("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<int>("SalaryComponentId")
@@ -2856,6 +3241,114 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
{
b.Property<int>("EdgeId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("EdgeId"));
b.Property<int>("ChildStageId")
.HasColumnType("integer");
b.Property<int>("ParentStageId")
.HasColumnType("integer");
b.Property<int>("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<int>("InputId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("InputId"));
b.Property<int?>("FromOutputId")
.HasColumnType("integer");
b.Property<int?>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("QtyPerBatch")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("StageId")
.HasColumnType("integer");
b.Property<int>("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<int>("OutputId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("OutputId"));
b.Property<int?>("ItemId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<decimal>("QtyPerBatch")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("StageId")
.HasColumnType("integer");
b.Property<int>("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<int>("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<int>("StageId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("StageId"));
b.Property<int>("EstimatedMinutes")
.HasColumnType("integer");
b.Property<string>("FieldDefs")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<decimal>("PosX")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("PosY")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("RoleLabel")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<int>("TemplateId")
.HasColumnType("integer");
b.HasKey("StageId");
b.HasIndex("TemplateId");
b.ToTable("template_stages", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{
b.Property<int>("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");
+13 -1
View File
@@ -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<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Stock core + goods receipt (docs/11 §45)
builder.Services.AddScoped<IUomConverter, UomConverter>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
@@ -136,12 +138,22 @@ builder.Services.AddScoped<IPayslipService, PayslipService>();
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
builder.Services.AddScoped<IHrReportService, HrReportService>();
// 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<IProductionTemplateService, ProductionTemplateService>();
builder.Services.AddScoped<IProductionRunService, ProductionRunService>();
// Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
// 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();
+10 -15
View File
@@ -33,9 +33,9 @@ public sealed class GrnService : IGrnService
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<UomConversion> _conversions;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IUomConverter _uomConverter;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -45,7 +45,7 @@ public sealed class GrnService : IGrnService
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_grns = grns;
@@ -57,9 +57,9 @@ public sealed class GrnService : IGrnService
_bins = bins;
_vendors = vendors;
_batches = batches;
_conversions = conversions;
_layers = layers;
_ledger = ledger;
_uomConverter = uomConverter;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
@@ -345,19 +345,14 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
/// <summary>
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
/// identical, so receive costing is unchanged.
/// </summary>
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
{
if (uomId == item.BaseUomId)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
}
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
@@ -29,11 +29,22 @@ public interface IFifoCostingService
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
/// <summary>Append an immutable ledger entry (value = qtyBase × unitCost).</summary>
/// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
/// </summary>
/// <param name="valueOverride">
/// Posts this exact value instead of deriving it from qty × unit cost. Needed when the
/// authoritative figure is a total rather than a rate: a production receipt must carry the
/// run's cost pool exactly, but <c>unitCost = pool / goodQty</c> rounds to 6 dp, and at
/// 100+ units that rounding error exceeds the ledger's 4 dp tick — so the derived value
/// would drift from the pool (FR-MFG-13). Also used by leftover and cancel returns, whose
/// value is the exact consumed residual. Omit for every rate-driven movement.
/// </param>
Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default);
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
decimal? valueOverride = null);
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -0,0 +1,99 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Production run lifecycle (docs/30 §D.2D.3, FR-MFG-08..19). Every stock-affecting
/// action runs inside a single <c>ExecuteInTransactionAsync</c> scope and consumes stock
/// only through <c>IFifoCostingService</c> (NFR-02/NFR-05).
/// </summary>
public interface IProductionRunService
{
Task<PagedResponse<RunSummaryDto>> ListAsync(
PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId,
CancellationToken ct = default);
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
/// <summary>
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
/// <c>PRD-…</c> document number, and leaves entry stages Ready with the rest Waiting.
/// </summary>
Task<ETagged<RunGraphDto>> CreateAsync(CreateRunRequest request, CancellationToken ct = default);
/// <summary>
/// Per-run quantity override on a stage that has not started
/// (<c>409 STAGE_NOT_EDITABLE</c> once it has). Re-evaluates the stage's readiness,
/// since raising an upstream input's planned quantity can un-ready it.
/// </summary>
Task<RunStageDto> UpdateStageQuantitiesAsync(
int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default);
/// <summary>
/// <c>Ready → InProgress</c> (FR-MFG-10). FIFO-consumes every Stock input from the run
/// warehouse in one transaction and stamps <c>actualStartAt</c>.
/// <para>Consumes <c>max(0, plannedBase consumedQty)</c> per input, 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).</para>
/// </summary>
Task<StartStageResultDto> StartStageAsync(int runId, int runStageId, CancellationToken ct = default);
/// <summary>
/// <c>InProgress → Done</c> (FR-MFG-11). Records produced and scrapped quantities per
/// output plus the custom field values. A re-complete after a rework <b>overwrites</b>
/// the previous figures rather than adding to them.
/// </summary>
Task<RunStageDto> CompleteStageAsync(
int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default);
/// <summary>
/// <c>Done → Approved</c> (FR-MFG-12/13). Non-terminal: hands WIP to the children,
/// defaulting to the full available quantity. Terminal: posts the production receipt —
/// a finished-goods layer costed at <c>costPool / goodQty</c> — and completes the run.
/// </summary>
Task<ApproveStageResultDto> ApproveStageAsync(
int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default);
/// <summary>
/// Later partial transfer of a remainder held on an already-Approved stage (FR-MFG-12),
/// never exceeding produced scrapped already transferred.
/// </summary>
Task<TransferResultDto> TransferAsync(
int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default);
/// <summary>
/// Returns unconsumed material to stock before the receipt closes the pool (FR-MFG-14).
/// The inbound layer is created at the <b>weighted cost actually consumed</b> for that
/// input, so the move is cost-preserving and the pool reduces by exactly what leaves it.
/// <c>409 RUN_COST_CLOSED</c> once the run has completed.
/// </summary>
Task<ReturnLeftoverResultDto> ReturnLeftoverAsync(
int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default);
/// <summary>
/// Downstream reject (FR-MFG-15): this stage rejects the work it received, its delivering
/// parents revert <c>Approved → InProgress</c> with their transferred quantities pulled
/// back, and this stage returns to Waiting. Consumed stock stays consumed.
/// </summary>
Task<RejectIntakeResultDto> RejectIntakeAsync(
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
/// <summary>
/// Terminal reject (FR-MFG-16): resets the whole run to its starting stages, increments
/// <c>reworkCount</c> and snapshots the discarded figures into the event history.
/// Already-consumed material remains in the cost pool.
/// </summary>
Task<TerminalRejectResultDto> RejectTerminalAsync(
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
/// <summary>
/// Cancels an in-progress run (FR-MFG-17). Net consumed-and-not-returned stock goes back
/// at its consumed weighted cost; scrapped output quantities are written off on the event.
/// <c>409 RUN_NOT_CANCELLABLE</c> for a completed run.
/// </summary>
Task<CancelRunResultDto> CancelAsync(int runId, CancelRunRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,33 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Production template CRUD and graph validation (docs/30 §D.1, FR-MFG-01..07).
/// </summary>
public interface IProductionTemplateService
{
Task<PagedResponse<TemplateSummaryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default);
Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default);
/// <summary>
/// Replaces the whole graph. Requires the current row version (<c>If-Match</c>) and is
/// refused with <c>409 TEMPLATE_IN_USE</c> while any run of this template is
/// InProgress — edit-lock stands in for versioning (FR-MFG-06).
/// </summary>
Task<ETagged<TemplateGraphDto>> UpdateAsync(
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>
/// Activate/deactivate (FR-MFG-01). Deliberately <b>not</b> edit-locked: deactivating is
/// the "never delete a referenced master" path (FR-MD-08) and only stops <i>new</i> runs
/// being started, so it must stay available while runs are in flight.
/// </summary>
Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
/// </summary>
/// <remarks>
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
/// one place that bridges the two.</para>
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
/// mis-cost the run).</para>
/// </remarks>
public interface IUomConverter
{
/// <summary>
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
/// conversion is defined for the item from that UOM to its base.
/// </summary>
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
/// <summary>
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
/// production stage input declares a quantity; its cost comes from the FIFO layers it
/// consumes, not from the document).
/// </summary>
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
}
@@ -0,0 +1,211 @@
using ERPCore.Domain.Enums;
using ERPCore.System.Errors;
namespace ERPCore.Services.Production;
/// <summary>
/// Validates a template stage graph (FR-MFG-02, FR-MFG-04, FR-MFG-05) and throws the
/// matching <c>422 GRAPH_*</c> domain error on the first violation.
/// </summary>
/// <remarks>
/// <para>This is an <b>algorithm, not a service</b> — pure, synchronous, no database and
/// no DI, in the spirit of <c>FifoCostingService</c> being the one place FIFO lives. It
/// deliberately has no interface: <c>Services/Interfaces/</c> exists for things that get
/// injected, and registering this would buy nothing.</para>
/// <para>It works entirely in <b>keys</b>, never database ids, so the identical code path
/// runs for a POST (where nothing has an id yet) and a PUT (where most things do). All
/// database-dependent checks — does this item exist, is it Active, does this UOM exist —
/// stay in <c>ProductionTemplateService</c> so this stays free of I/O.</para>
/// <para>Checks run cheapest-first and fail fast, and every message names the offending
/// stage or edge so the canvas can focus it (docs/21 §2).</para>
/// </remarks>
public static class ProductionGraphValidator
{
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
public sealed record OutputDraft(string Key, string Name, int? ItemId);
public sealed record StageDraft(
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
public readonly record struct EdgeDraft(string ParentKey, string ChildKey);
/// <summary>
/// Throws on the first rule violation; returns silently for a valid graph.
/// </summary>
public static void Validate(IReadOnlyList<StageDraft> stages, IReadOnlyList<EdgeDraft> edges)
{
// 1 — structural hygiene. These are all blocked in the canvas at draw time
// (docs/21 §2); this is the server backstop for a hand-rolled request.
if (stages.Count == 0)
throw Invalid("A template must have at least one stage.");
var stageByKey = new Dictionary<string, StageDraft>(StringComparer.Ordinal);
foreach (var s in stages)
{
if (string.IsNullOrWhiteSpace(s.Key))
throw Invalid("Every stage requires a key.");
if (!stageByKey.TryAdd(s.Key, s))
throw Invalid($"Duplicate stage key '{s.Key}'.");
}
// Output keys are unique across the whole template, not just within a stage —
// an Upstream input names one by key alone, so a collision would be ambiguous.
var outputOwner = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var s in stages)
foreach (var o in s.Outputs)
{
if (string.IsNullOrWhiteSpace(o.Key))
throw Invalid($"Every output of stage '{s.Name}' requires a key.");
if (!outputOwner.TryAdd(o.Key, s.Key))
throw Invalid($"Duplicate output key '{o.Key}' (stage '{s.Name}').");
}
var edgeSet = new HashSet<(string, string)>();
foreach (var e in edges)
{
if (!stageByKey.ContainsKey(e.ParentKey) || !stageByKey.ContainsKey(e.ChildKey))
throw Invalid($"Edge '{e.ParentKey}' → '{e.ChildKey}' references a stage that is not in the payload.");
if (string.Equals(e.ParentKey, e.ChildKey, StringComparison.Ordinal))
throw Invalid($"Stage '{stageByKey[e.ParentKey].Name}' cannot connect to itself.");
if (!edgeSet.Add((e.ParentKey, e.ChildKey)))
throw Invalid($"Duplicate edge '{stageByKey[e.ParentKey].Name}' → '{stageByKey[e.ChildKey].Name}'.");
}
var children = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
var parents = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
foreach (var (parent, child) in edgeSet)
{
children[parent].Add(child);
parents[child].Add(parent);
}
// 2 — cycle detection by Kahn's algorithm. If the toposort can't reach every
// stage, the unreached set is exactly the stages trapped in (or downstream of)
// a cycle, which is what the client highlights.
var indegree = stages.ToDictionary(s => s.Key, s => parents[s.Key].Count, StringComparer.Ordinal);
var queue = new Queue<string>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
var sorted = 0;
while (queue.Count > 0)
{
var key = queue.Dequeue();
sorted++;
foreach (var child in children[key])
if (--indegree[child] == 0)
queue.Enqueue(child);
}
if (sorted != stages.Count)
{
var trapped = indegree.Where(kv => kv.Value > 0).Select(kv => stageByKey[kv.Key].Name);
throw new DomainException(ErrorCodes.GraphCycle,
$"The stage graph contains a cycle involving: {string.Join(", ", trapped)}.", 422);
}
// 3 — exactly one terminal stage (multiple starts may converge, but the line must
// end in one place, because that single stage is what receives the finished good).
var terminals = stages.Where(s => children[s.Key].Count == 0).ToList();
if (terminals.Count != 1)
throw new DomainException(ErrorCodes.GraphTerminalCount,
terminals.Count == 0
? "The stage graph has no final stage."
: $"The stage graph must converge to exactly one final stage, but {terminals.Count} have no outgoing connection: {string.Join(", ", terminals.Select(t => t.Name))}.",
422);
var terminal = terminals[0];
var entries = stages.Where(s => parents[s.Key].Count == 0).ToList();
// 4 — connectivity. One traversal each way subsumes all three of "no disconnected
// stages", "every stage reachable from an entry" and "every stage reaches the
// terminal": an isolated stage simply appears in neither set. A lone stage is both
// an entry and the terminal, so it falls out correctly with no special case.
//
// Kept as defence in depth, but note it is unreachable once checks 2 and 3 pass:
// in an acyclic graph every stage is reachable from some source, and if exactly one
// stage lacks an outbound edge then every stage necessarily reaches it. An isolated
// stage therefore surfaces as GRAPH_TERMINAL_COUNT (it is a second terminal), which
// is the better message anyway because it names both offenders. Verified by the M2
// smoke test, which asserts that behaviour explicitly.
var fromEntry = Reach(entries.Select(s => s.Key), children);
var toTerminal = Reach([terminal.Key], parents);
var stranded = stages.Where(s => !fromEntry.Contains(s.Key) || !toTerminal.Contains(s.Key)).ToList();
if (stranded.Count > 0)
throw new DomainException(ErrorCodes.GraphDisconnected,
$"Every stage must sit on a path from a starting stage to '{terminal.Name}', but these do not: {string.Join(", ", stranded.Select(s => s.Name))}.",
422);
// 5 — input sources (FR-MFG-04). An Upstream input may only draw from an output of
// a DIRECT parent: allowing a grandparent's output would mean WIP skipping a stage.
foreach (var s in stages)
{
var allowed = parents[s.Key]
.SelectMany(p => stageByKey[p].Outputs.Select(o => o.Key))
.ToHashSet(StringComparer.Ordinal);
foreach (var input in s.Inputs)
{
if (input.Source == StageInputSource.Upstream)
{
if (input.ItemId is not null)
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream and cannot also reference an item.", 422);
if (string.IsNullOrWhiteSpace(input.FromOutputKey))
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream but names no source output.", 422);
if (!allowed.Contains(input.FromOutputKey))
{
var owner = outputOwner.TryGetValue(input.FromOutputKey, out var ownerKey)
? $"'{stageByKey[ownerKey].Name}' is not a direct parent of '{s.Name}'"
: $"output '{input.FromOutputKey}' does not exist";
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' must draw from a direct parent's output — {owner}.", 422);
}
}
else
{
if (input.ItemId is null)
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and requires an item.", 422);
if (!string.IsNullOrWhiteSpace(input.FromOutputKey))
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
}
}
}
// 6 — outputs (FR-MFG-05). The terminal stage produces exactly one real Item (the
// finished good the receipt creates a layer for); every other output is internal
// WIP and must stay item-less, or it would imply stock that never exists.
if (terminal.Outputs.Count != 1)
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
$"The final stage '{terminal.Name}' must have exactly one output, but has {terminal.Outputs.Count}.", 422);
if (terminal.Outputs[0].ItemId is null)
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
$"The output of the final stage '{terminal.Name}' must reference the finished item.", 422);
foreach (var s in stages.Where(s => !ReferenceEquals(s, terminal)))
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
throw Invalid(
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
}
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
private static HashSet<string> Reach(IEnumerable<string> roots, Dictionary<string, List<string>> next)
{
var seen = new HashSet<string>(roots, StringComparer.Ordinal);
var queue = new Queue<string>(seen);
while (queue.Count > 0)
foreach (var n in next[queue.Dequeue()])
if (seen.Add(n))
queue.Enqueue(n);
return seen;
}
/// <summary>
/// Structural violations the client already prevents and docs/30 §D.4 assigns no
/// dedicated code to — still 422, still carrying a message that names the culprit.
/// </summary>
private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
}
@@ -0,0 +1,41 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ERPCore.Services.Production;
/// <summary>
/// The one serializer for every jsonb column in the manufacturing module —
/// <c>template_stages.field_defs</c>, <c>run_stages.field_defs</c>/<c>field_values</c>
/// and <c>run_stage_events.payload</c>.
/// </summary>
/// <remarks>
/// <para>Those columns hold a pre-serialized <c>string</c> rather than a mapped POCO,
/// following the <c>AuditLog.ChangeSet</c> precedent. Beyond consistency this keeps
/// <c>AuditScribe</c> honest: an owned/typed jsonb mapping would surface the nested
/// objects as their own change-tracker entries and scatter spurious audit rows across
/// the new tables.</para>
/// <para>Everything is written through here so a column can only ever contain canonical
/// JSON — the raw client string is never stored, which means a malformed
/// <c>fieldDefs</c> can't reach the database and can't later break a run created from
/// that template.</para>
/// </remarks>
public static class ProductionJson
{
/// <summary>Matches the API's own wire format (docs/11 §1.3): camelCase, enums as strings.</summary>
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
/// <summary>
/// Reads a stored column back. Returns <paramref name="fallback"/> for null/blank so a
/// caller never has to null-check; a parse failure is a genuine data-integrity problem
/// and is allowed to throw.
/// </summary>
public static T Deserialize<T>(string? json, T fallback)
=> string.IsNullOrWhiteSpace(json) ? fallback : JsonSerializer.Deserialize<T>(json, Options) ?? fallback;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,496 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Production;
/// <summary>
/// Production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07).
/// Graph rules live in <see cref="ProductionGraphValidator"/>; everything needing the
/// database (referential checks, the edit-lock, reconciliation) lives here.
/// </summary>
public sealed class ProductionTemplateService : IProductionTemplateService
{
private readonly IRepository<ProductionTemplate> _templates;
private readonly IRepository<TemplateStage> _stages;
private readonly IRepository<StageInput> _inputs;
private readonly IRepository<StageOutput> _outputs;
private readonly IRepository<StageEdge> _edges;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<ProductionRun> _runs;
private readonly IUnitOfWork _uow;
private readonly ICurrentUser _currentUser;
public ProductionTemplateService(
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
IUnitOfWork uow, ICurrentUser currentUser)
{
_templates = templates;
_stages = stages;
_inputs = inputs;
_outputs = outputs;
_edges = edges;
_items = items;
_uoms = uoms;
_runs = runs;
_uow = uow;
_currentUser = currentUser;
}
public async Task<PagedResponse<TemplateSummaryDto>> ListAsync(
PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _templates.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
var total = await q.CountAsync(ct);
// Projected to an anonymous type first, then mapped client-side: EF Core 10 cannot
// translate a record constructor sitting alongside aggregates and a collection
// projection (same limitation recorded for WarehouseValuationDto, 2026-07-28).
var rows = await q
.OrderBy(t => t.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new
{
t.TemplateId,
t.Code,
t.Name,
t.Status,
StageCount = t.Stages.Count,
Stages = t.Stages.Select(s => new { s.StageId, s.Name }).ToList(),
Edges = t.Edges.Select(e => new { e.ParentStageId, e.ChildStageId }).ToList(),
// Drives the canvas edit-lock banner (docs/21 §2) and mirrors the
// condition UpdateAsync enforces server-side.
ActiveRunCount = t.Runs.Count(r => r.Status == ProductionRunStatus.InProgress),
t.CreatedBy,
t.CreatedAt
})
.ToListAsync(ct);
var items = rows.Select(t => new TemplateSummaryDto(
t.TemplateId, t.Code, t.Name, t.Status, t.StageCount,
FlowOrderedNames(
t.Stages.Select(s => (s.StageId, s.Name)).ToList(),
t.Edges.Select(e => (e.ParentStageId, e.ChildStageId)).ToList()),
t.ActiveRunCount, t.CreatedBy, t.CreatedAt)).ToList();
return PagedResponse<TemplateSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
/// <summary>
/// Stage names in <b>flow order</b> — upstream first — for the overview canvas, which
/// draws each template as a line left to right (docs/21 §1).
/// </summary>
/// <remarks>
/// Ordering by <c>StageId</c> would be insertion order, which routinely puts the terminal
/// stage first and renders the line backwards. Kahn's algorithm over the edge set gives the
/// real sequence, tie-broken by stage id so parallel branches come out deterministically.
/// Falls back to id order if the graph somehow contains a cycle, so a listing can never
/// fail because of bad data.
/// </remarks>
private static List<string> FlowOrderedNames(
List<(int StageId, string Name)> stages, List<(int ParentStageId, int ChildStageId)> edges)
{
var nameById = stages.ToDictionary(s => s.StageId, s => s.Name);
var children = stages.ToDictionary(s => s.StageId, _ => new List<int>());
var indegree = stages.ToDictionary(s => s.StageId, _ => 0);
foreach (var (parent, child) in edges)
{
if (!children.ContainsKey(parent) || !indegree.ContainsKey(child)) continue;
children[parent].Add(child);
indegree[child]++;
}
// SortedSet keeps the frontier in id order, so the output is stable run to run.
var frontier = new SortedSet<int>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
var ordered = new List<string>(stages.Count);
while (frontier.Count > 0)
{
var next = frontier.Min;
frontier.Remove(next);
ordered.Add(nameById[next]);
foreach (var child in children[next])
if (--indegree[child] == 0)
frontier.Add(child);
}
return ordered.Count == stages.Count
? ordered
: stages.OrderBy(s => s.StageId).Select(s => s.Name).ToList();
}
public async Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default)
{
var template = await LoadGraphQuery().AsNoTracking()
.FirstOrDefaultAsync(t => t.TemplateId == templateId, ct);
if (template is null) return null;
// Counted separately rather than Included: the graph query already fans out over four
// collections, and adding Runs would multiply those rows again for a single integer.
var activeRuns = await _runs.Query().AsNoTracking()
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, ct);
return new ETagged<TemplateGraphDto>(ToGraphDto(template, activeRuns), template.RowVersion);
}
public async Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _templates.Query().AnyAsync(t => t.Code == code, ct))
throw new ConflictException($"A production template with code '{code}' already exists.");
var template = new ProductionTemplate
{
Code = code,
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
Status = EntityStatus.Active,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow
};
await ValidateAsync(request, ct);
await _uow.ExecuteInTransactionAsync(async token =>
{
await _templates.AddAsync(template, token);
BuildGraph(template, request);
}, ct);
return await RequireGraphAsync(template.TemplateId, ct);
}
public async Task<ETagged<TemplateGraphDto>> UpdateAsync(
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
await ValidateAsync(request, ct);
var code = request.Code.Trim();
if (await _templates.Query().AnyAsync(t => t.Code == code && t.TemplateId != templateId, ct))
throw new ConflictException($"A production template with code '{code}' already exists.");
await _uow.ExecuteInTransactionAsync(async token =>
{
var template = await LoadGraphQuery().FirstOrDefaultAsync(t => t.TemplateId == templateId, token)
?? throw new NotFoundException($"Production template {templateId} was not found.");
if (template.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict,
"The template was modified by another request.", 412);
// FR-MFG-06: edit-lock instead of versioning. Checked inside the transaction to
// keep the window small; the residual race is benign because a run copies
// everything it needs at creation and never reads the template again.
var activeRuns = await _runs.Query().AsNoTracking()
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, token);
if (activeRuns > 0)
throw new DomainException(ErrorCodes.TemplateInUse,
$"This template has {activeRuns} run(s) in progress and cannot be edited until they finish.", 409);
template.Code = code;
template.Name = request.Name.Trim();
template.Description = request.Description?.Trim();
// Two passes, deliberately. Tearing the old graph down and flushing before
// rebuilding removes any dependence on how EF happens to order a mixed batch of
// inserts and deletes — which matters because stage_inputs → stage_outputs is a
// Restrict FK and stage_edges carries a unique (parent, child) index.
var keptStages = TearDownGraph(template, request);
await _uow.SaveChangesAsync(token);
BuildGraph(template, request, keptStages);
}, ct);
return await RequireGraphAsync(templateId, ct);
}
public async Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default)
{
var template = await _templates.Query().FirstOrDefaultAsync(t => t.TemplateId == templateId, ct)
?? throw new NotFoundException($"Production template {templateId} was not found.");
template.Status = status;
await _uow.SaveChangesAsync(ct);
}
// --- graph reconciliation ------------------------------------------------
private IQueryable<ProductionTemplate> LoadGraphQuery() =>
_templates.Query()
.Include(t => t.Stages).ThenInclude(s => s.Inputs)
.Include(t => t.Stages).ThenInclude(s => s.Outputs)
.Include(t => t.Edges);
/// <summary>
/// Runs the pure graph rules then the referential ones. Item/UOM existence needs the
/// database, so it can't live in the validator — but it has to run before any write.
/// </summary>
private async Task ValidateAsync(SaveTemplateRequest request, CancellationToken ct)
{
ProductionGraphValidator.Validate(
request.Stages.Select(s => new ProductionGraphValidator.StageDraft(
s.Key, s.Name,
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(),
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(),
request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList());
var itemIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId)))
.OfType<int>().Distinct().ToList();
if (itemIds.Count > 0)
{
var found = await _items.Query().AsNoTracking()
.Where(i => itemIds.Contains(i.ItemId))
.Select(i => new { i.ItemId, i.Status })
.ToListAsync(ct);
var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Item(s) {string.Join(", ", missing)} do not exist.", 422);
var inactive = found.Where(f => f.Status != EntityStatus.Active).Select(f => f.ItemId).ToList();
if (inactive.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
}
var uomIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
.Distinct().ToList();
var knownUoms = await _uoms.Query().AsNoTracking()
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
var missingUoms = uomIds.Except(knownUoms).ToList();
if (missingUoms.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
// client renders off. Unknown kinds would round-trip fine but draw nothing.
var badKinds = request.Annotations
.Select(a => a.Kind)
.Where(k => k is not ("box" or "line"))
.Distinct().ToList();
if (badKinds.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Unknown canvas annotation kind(s): {string.Join(", ", badKinds)}. Expected 'box' or 'line'.", 422);
}
/// <summary>
/// Removes everything the incoming payload replaces and returns the surviving stages
/// by key.
/// </summary>
/// <remarks>
/// Inputs and outputs are always replaced wholesale — nothing outside the template
/// references them, because a run copies into its own <c>run_stage_input</c>/
/// <c>run_stage_output</c> rows. Stages are <b>diffed</b>, not replaced, because
/// <c>run_stages.TemplateStageId</c> points at them; a stage that disappears from the
/// payload is deleted and that FK is set null for any historical run (see
/// <c>RunStageConfiguration</c>).
/// </remarks>
private Dictionary<string, TemplateStage> TearDownGraph(ProductionTemplate template, SaveTemplateRequest request)
{
var payloadKeys = request.Stages.Select(s => s.Key).ToHashSet(StringComparer.Ordinal);
foreach (var stage in template.Stages)
{
foreach (var input in stage.Inputs.ToList()) _inputs.Remove(input);
foreach (var output in stage.Outputs.ToList()) _outputs.Remove(output);
}
var kept = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
var doomed = new List<TemplateStage>();
foreach (var stage in template.Stages.ToList())
{
var key = stage.StageId.ToString();
if (payloadKeys.Contains(key)) kept[key] = stage;
else doomed.Add(stage);
}
// Edges are diffed rather than replaced: stage_edges has a unique (parent, child)
// index, and dropping then re-adding an unchanged edge in the same round trip can
// trip it depending on statement order.
var wanted = request.Edges
.Select(e => (Parent: ResolveKeptId(kept, e.ParentKey), Child: ResolveKeptId(kept, e.ChildKey)))
.Where(e => e.Parent is not null && e.Child is not null)
.Select(e => (e.Parent!.Value, e.Child!.Value))
.ToHashSet();
// Edges must go before stages. StageEdge.ParentStage/ChildStage are required
// relationships, so deleting a stage that a still-live edge points at makes EF throw
// "the association ... has been severed". Any edge touching a doomed stage is
// necessarily absent from `wanted` — the payload cannot reference a stage it dropped
// — so this ordering never orphans an edge the caller wanted to keep.
foreach (var edge in template.Edges.ToList())
if (!wanted.Contains((edge.ParentStageId, edge.ChildStageId)))
_edges.Remove(edge);
foreach (var stage in doomed)
_stages.Remove(stage);
return kept;
}
private static int? ResolveKeptId(Dictionary<string, TemplateStage> kept, string key)
=> kept.TryGetValue(key, out var stage) ? stage.StageId : null;
/// <summary>
/// Materialises the payload onto the template.
/// </summary>
/// <remarks>
/// Everything is wired through <b>navigation properties</b> rather than foreign-key
/// ints, so EF resolves the ids itself during a single <c>SaveChanges</c>. That is what
/// lets a brand-new Upstream input point at a brand-new output without an intermediate
/// save to materialise generated keys — outputs are built in the first pass precisely
/// so the second pass has the entity instances to hand.
/// </remarks>
private void BuildGraph(
ProductionTemplate template, SaveTemplateRequest request,
Dictionary<string, TemplateStage>? keptStages = null)
{
// Replaced wholesale — annotations are opaque client state, not part of the graph, so
// there is nothing to diff and nothing else can reference them.
template.Annotations = request.Annotations.Count == 0
? null
: ProductionJson.Serialize(request.Annotations);
var stagesByKey = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
var outputsByKey = new Dictionary<string, StageOutput>(StringComparer.Ordinal);
// Pass 1 — stages and their outputs.
foreach (var s in request.Stages)
{
if (keptStages is null || !keptStages.TryGetValue(s.Key, out var stage))
{
stage = new TemplateStage { Template = template };
template.Stages.Add(stage);
}
stage.Name = s.Name.Trim();
stage.RoleLabel = string.IsNullOrWhiteSpace(s.RoleLabel) ? null : s.RoleLabel.Trim();
stage.EstimatedMinutes = s.EstimatedMinutes;
stage.PosX = s.PosX;
stage.PosY = s.PosY;
stage.FieldDefs = ProductionJson.Serialize(s.FieldDefs);
stagesByKey[s.Key] = stage;
foreach (var o in s.Outputs)
{
var output = new StageOutput
{
Stage = stage,
ItemId = o.ItemId,
Name = o.Name.Trim(),
UomId = o.UomId,
QtyPerBatch = o.QtyPerBatch
};
stage.Outputs.Add(output);
outputsByKey[o.Key] = output;
}
}
// Pass 2 — inputs, which may reference any output built above.
foreach (var s in request.Stages)
{
var stage = stagesByKey[s.Key];
foreach (var i in s.Inputs)
{
stage.Inputs.Add(new StageInput
{
Stage = stage,
Source = i.Source,
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
UomId = i.UomId,
QtyPerBatch = i.QtyPerBatch
});
}
}
// Pass 3 — edges not already present.
var existing = template.Edges
.Select(e => (e.ParentStageId, e.ChildStageId)).ToHashSet();
foreach (var e in request.Edges)
{
var parent = stagesByKey[e.ParentKey];
var child = stagesByKey[e.ChildKey];
if (parent.StageId != 0 && child.StageId != 0 && existing.Contains((parent.StageId, child.StageId)))
continue;
template.Edges.Add(new StageEdge
{
Template = template,
ParentStage = parent,
ChildStage = child
});
}
}
// --- mapping -------------------------------------------------------------
private async Task<ETagged<TemplateGraphDto>> RequireGraphAsync(int templateId, CancellationToken ct)
=> await GetAsync(templateId, ct)
?? throw new NotFoundException($"Production template {templateId} was not found.");
private static TemplateGraphDto ToGraphDto(ProductionTemplate t, int activeRunCount)
{
// The key a client sends back is just the id as a string; computing it here keeps
// that contract in exactly one place.
var outputKeyById = t.Stages
.SelectMany(s => s.Outputs)
.ToDictionary(o => o.OutputId, o => o.OutputId.ToString());
var stages = t.Stages
.OrderBy(s => s.StageId)
.Select(s => new TemplateStageDto(
s.StageId, s.StageId.ToString(), s.Name, s.RoleLabel, s.EstimatedMinutes, s.PosX, s.PosY,
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
i.InputId, i.Source, i.ItemId, i.FromOutputId,
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
i.UomId, i.QtyPerBatch)).ToList(),
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
.ToList();
var edges = t.Edges
.OrderBy(e => e.EdgeId)
.Select(e => new TemplateEdgeDto(
e.EdgeId, e.ParentStageId, e.ChildStageId,
e.ParentStageId.ToString(), e.ChildStageId.ToString()))
.ToList();
return new TemplateGraphDto(
t.TemplateId, t.Code, t.Name, t.Description, t.Status, stages, edges,
ProductionJson.Deserialize<List<CanvasAnnotationDto>>(t.Annotations, []),
activeRunCount, t.CreatedBy, t.CreatedAt);
}
}
@@ -121,7 +121,8 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default)
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
decimal? valueOverride = null)
{
var entry = new StockLedger
{
@@ -134,7 +135,9 @@ public sealed class FifoCostingService : IFifoCostingService
Direction = direction,
QtyBase = qtyBase,
UnitCost = unitCost,
Value = Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero),
Value = valueOverride is not null
? Math.Round(valueOverride.Value, 4, MidpointRounding.AwayFromZero)
: Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero),
RunningBalance = runningBalance,
SourceDocType = sourceDocType,
SourceDocId = sourceDocId,
@@ -0,0 +1,38 @@
using ERPCore.Domain.Entities;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// Shared UOM → base-UOM conversion (see <see cref="IUomConverter"/>). Behaviour is
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
/// receive path keeps costing exactly as before.
/// </summary>
public sealed class UomConverter : IUomConverter
{
private readonly IRepository<UomConversion> _conversions;
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
{
if (uomId == item.BaseUomId)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
// Quantity scales up by the factor, so the per-unit cost scales down by it —
// total value is preserved.
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
}
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
}
@@ -47,4 +47,23 @@ public static class ErrorCodes
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE";
public const string TemplateInactive = "TEMPLATE_INACTIVE";
public const string GraphCycle = "GRAPH_CYCLE";
public const string GraphTerminalCount = "GRAPH_TERMINAL_COUNT";
public const string GraphDisconnected = "GRAPH_DISCONNECTED";
public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID";
public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED";
public const string StageNotReady = "STAGE_NOT_READY";
public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS";
public const string StageNotDone = "STAGE_NOT_DONE";
public const string StageNotEditable = "STAGE_NOT_EDITABLE";
public const string StageRejectInvalid = "STAGE_REJECT_INVALID";
public const string RequiredFieldMissing = "REQUIRED_FIELD_MISSING";
public const string TransferExceedsAvailable = "TRANSFER_EXCEEDS_AVAILABLE";
public const string LeftoverExceedsConsumed = "LEFTOVER_EXCEEDS_CONSUMED";
public const string RunCostClosed = "RUN_COST_CLOSED";
public const string RunNotCancellable = "RUN_NOT_CANCELLABLE";
}
+1 -1
View File
@@ -16,7 +16,7 @@
"RequiredRoleCode": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
"BaseUrl": "http://localhost:5602"
},
"FileStorage": {
"RootPath": "App_Data/hr-documents",