Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d6e597389 |
@@ -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.2–D.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();
|
||||
}
|
||||
}
|
||||
@@ -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 >= 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"/> > 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";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Production;
|
||||
|
||||
// Production run contract (docs/30-BACKEND-PHASE2.md §D.2–D.3).
|
||||
//
|
||||
// Statuses are never client-settable (02-SECURITY §B.6): a run's status and every stage
|
||||
// status move only through the stage-action endpoints. Requests here carry quantities and
|
||||
// references, nothing else.
|
||||
|
||||
// --- responses ---------------------------------------------------------------
|
||||
|
||||
/// <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"/> > 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");
|
||||
|
||||
@@ -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 §4–5)
|
||||
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();
|
||||
|
||||
|
||||
@@ -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.2–D.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";
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"RequiredRoleCode": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
"BaseUrl": "http://localhost:5602"
|
||||
},
|
||||
"FileStorage": {
|
||||
"RootPath": "App_Data/hr-documents",
|
||||
|
||||
@@ -178,6 +178,98 @@ Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` (
|
||||
- [x] Employee salary history (`.../salary-history?employeeId=`) — full `EmployeeSalaryStructure` revision history, ordered newest first
|
||||
- [x] Leave balance report (`.../leave-balances?year=`), document expiry report (`.../document-expiry?withinDays=`)
|
||||
|
||||
---
|
||||
|
||||
# Manufacturing — Production Lines (Phase 2)
|
||||
|
||||
Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike Phase 1). Frontend consumption: `docs/21-FRONTEND-PHASE2.md`. Security: `docs/02-SECURITY.md §B.6` (narrow DTOs — statuses are never client-settable) + `§B.7` (FIFO row-locking inside the UoW txn).
|
||||
|
||||
> **§11–§16 code complete, migration applied, and live smoke-tested (2026-07-30).** `dotnet build` clean (0 errors; the only warnings are the two pre-existing `CS8981` from the badly-named `chages` migration). Migration `AddManufacturingPhase2` — 11 `CreateTable`, zero `AlterColumn`, applied and verified against `information_schema`. **312 smoke assertions, all green**, via re-runnable scripts in `Backend/smoke/` against local Postgres + a real AuthHex session.
|
||||
|
||||
## 11. Sub-phase 2.1 — Schema + enums
|
||||
- [x] 11 entities (`ProductionTemplate`, `TemplateStage`, `StageEdge`, `StageInput`, `StageOutput`, `ProductionRun`, `RunStage`, `RunEdge`, `RunStageInput`, `RunStageOutput`, `RunStageEvent`) + `ProductionConfiguration.cs` (all 11 configs in one file, per the `StockConfiguration.cs` precedent). snake_case tables, PascalCase columns, enums as `varchar(20)`, qty/value `(18,4)`, unit cost + scale factor `(18,6)`, `xmin` RowVersion on template/run/run-stage
|
||||
- [x] Enums `ProductionRunStatus`, `ProductionStageStatus`, `StageInputSource`, `RunStageEventType`, `CustomFieldType`; `ReasonContext` += `Production`; `DocumentTypes.Production = "PRD"`; new `Domain/LedgerSourceTypes.cs`
|
||||
- [x] 4 Production reason codes seeded idempotently (`PRD-SCRAP`, `PRD-LEFTOVER`, `PRD-CANCEL`, `PRD-REWORK-LOSS`) — verified live via `GET /reason-codes?context=Production`
|
||||
- [x] jsonb (`field_defs`, `field_values`, `payload`) as CLR `string` + `HasColumnType("jsonb")`, always written through `ProductionJson` so a column can only hold canonical JSON. Follows the `AuditLog.ChangeSet` precedent; a typed/owned mapping would make `AuditScribe` emit spurious audit rows for the nested entries
|
||||
|
||||
## 12. Sub-phase 2.2 — Templates + graph validation (FR-MFG-01..07)
|
||||
> **Smoke: 38/38** (`Backend/smoke/m2_templates.py`). Zero stock touched.
|
||||
- [x] `ProductionGraphValidator` — a `public static class`, deliberately not an injected service (pure, synchronous, no DI). Kahn toposort → `GRAPH_CYCLE`; terminal count → `GRAPH_TERMINAL_COUNT`; one combined bidirectional-reachability check → `GRAPH_DISCONNECTED`; direct-parent check → `GRAPH_INPUT_SOURCE_INVALID`; terminal output → `TERMINAL_OUTPUT_ITEM_REQUIRED`. Works in **keys**, not ids, so identical code serves POST and PUT
|
||||
- [x] `/production-templates` list/get/create/update/status, ETag + `If-Match` (428 missing, 412 stale), `409 TEMPLATE_IN_USE` on PUT while a run is InProgress
|
||||
- [x] Full-graph PUT reconciliation: stages **diffed** (a run references them), inputs/outputs **replaced**, edges **diffed** (unique `(parent, child)` index). Rebuilt through navigation properties so EF resolves generated keys in one `SaveChanges`
|
||||
|
||||
## 13. Sub-phase 2.3 — Run creation, board, detail, quantities (FR-MFG-08/09/18)
|
||||
> **Smoke: 54/54** (`m3_runs.py`). Zero stock touched.
|
||||
- [x] `POST /production-runs` — copies stages/inputs/outputs/edges in three passes, scales from the **unrounded** ratio (rounding each quantity once, so a repeating scale factor doesn't compound), `PRD-2026-0000N` from `NumberSequenceService` inside the transaction, entry stages `Ready`
|
||||
- [x] `GET /production-runs` with `stageSummary` computed server-side; projected to an anonymous type first then mapped client-side (EF Core 10 cannot translate a record ctor alongside aggregates — same failure as `WarehouseValuationDto`, 2026-07-28)
|
||||
- [x] `GET /production-runs/{id}` full graph incl. events, derived `isTerminal`/`isEntry`/`availableToTransfer`/`actualMinutes`/`costPool`
|
||||
- [x] `PUT .../stages/{sid}/quantities` — `409 STAGE_NOT_EDITABLE` once started, and re-evaluates readiness (raising an upstream planned qty demotes a Ready stage back to Waiting)
|
||||
|
||||
## 14. Sub-phase 2.4 — Stage execution (FR-MFG-10/11/12) · first stock-touching
|
||||
> **Smoke: 66/66** (`m4_stage_actions.py`) **+ 14/14** (`m4b_uom_conversion.py`). Isolated `SMOKE-PRD` warehouse.
|
||||
- [x] `…/start` — FIFO-consumes Stock inputs via `IFifoCostingService.ConsumeAsync`, `PRDI` ledger, `actualStartAt`. Consumes `max(0, plannedBase − consumedQty)` so a rework restart draws only the delta
|
||||
- [x] `…/complete` — produced/scrapped per output + custom field values; `400 REQUIRED_FIELD_MISSING`, `400 REASON_CODE_REQUIRED`, Production-context reason enforced. **Overwrites** on a re-complete
|
||||
- [x] `…/approve` (non-terminal) + `…/transfer` — default full transfer, optional partial, `422 TRANSFER_EXCEEDS_AVAILABLE`, child readiness recomputed. Routes by `fromRunOutputId`, not by edge
|
||||
- [x] **`IUomConverter` extracted** from `GrnService.ToBaseAsync` into `Services/Stock/UomConverter.cs`; `GrnService` delegates to it, behaviour unchanged. Verified: a stage input declared in a 12× UOM consumes **360** base units, not 30; the ledger records base; an undefined conversion is `422`, never assumed 1:1
|
||||
|
||||
## 15. Sub-phase 2.5 — Terminal receipt + cost pool (FR-MFG-13)
|
||||
> **Smoke: 36/36** (`m5_receipt.py`).
|
||||
- [x] Terminal approve creates the finished layer at `costPool / goodQty`, posts `PRDR`, completes the run and closes the pool (`409 RUN_COST_CLOSED`)
|
||||
- [x] **`decimal? valueOverride` added to `IFifoCostingService.PostLedgerAsync`** (default keeps `round(qty × unitCost, 4)`; every existing call site unaffected). Empirically necessary, not theoretical: at 300 units the 6 dp unit cost gives a naive value of `3405.5553` against a pool of `3405.5552` — a real 0.0001 drift. The smoke test asserts the naive product *would* have drifted, so the fixture cannot silently go blind
|
||||
- [x] Batch/serial-tracked finished goods refused with `422` (this phase defines no batch creation on receipt). **Untested** — no tracked item exists in the dev DB; noted in the script
|
||||
|
||||
## 16. Sub-phase 2.6 — Leftover return, rework, cancel (FR-MFG-14..17)
|
||||
> **Smoke: 104/104** (`m6_m7_leftover_rework_cancel.py`).
|
||||
- [x] `…/return-leftover` — inbound at the input's consumed weighted cost, `PRDL`, bin null (raw material, not the finished-goods bin). Value computed from the **unrounded** weighted cost and rounded once; a *full* return takes the exact residual so `returnedValue == consumedValue` precisely. `422 LEFTOVER_EXCEEDS_CONSUMED`, `409 RUN_COST_CLOSED`
|
||||
- [x] `…/reject-intake` — parent `transferredQty` **decremented** (not zeroed, so a parent that also fed another child stays consistent), parent `Approved → InProgress` with `ActualStartAt` preserved, rejecting stage → `Waiting`. Allowed from `Ready` **or** `Waiting` with delivered intake
|
||||
- [x] `…/reject` (terminal) — whole-run reset with one snapshot event per pass; `plannedQty` and `consumed*`/`returned*` **preserved**, stock untouched. Verified across two consecutive rework passes
|
||||
- [x] `POST .../cancel` — returns `consumed − returned` per input at the consumed weighted cost (`PRDC`), `balances` dictionary accumulated per item (layers created in-transaction are invisible to `GetOnHandAsync` until `SaveChanges`), scrapped output qty recorded as `scrappedWrittenOff`. `409 RUN_NOT_CANCELLABLE`
|
||||
- [x] Event history + estimated-vs-actual (FR-MFG-19) — every action writes one `RUN_STAGE_EVENT`; failed actions write none (the write rolls back with the change)
|
||||
|
||||
**Bugs found and fixed during this phase (not silently patched):**
|
||||
- **Template PUT 500** — deleting a `TemplateStage` while a `StageEdge` still referenced it severed a required EF relationship. Edges are now removed before stages; any edge touching a deleted stage is by construction absent from the payload, so nothing the caller wanted is orphaned.
|
||||
- **`receipt.layerId` returned 0** — the `ReceiptDto` was built inside the transaction, before `SaveChanges` generated the id. Now mapped after the commit (same fix as the `ledgerRefs:[0]` issue recorded 2026-07-13).
|
||||
- **Runtime messages carried U+2212** (typographic minus) and broke console/log encoding on Windows cp1252. Exception strings now use ASCII hyphens; comments keep the typographic form, matching the rest of the codebase.
|
||||
|
||||
**Deviations / decisions (recorded, not silently assumed) — all mirrored into `docs/30`:**
|
||||
- **§A.1 was a no-op.** `StockLayer.GrnLineId` was already nullable in entity, config, snapshot **and** database. Phase-1 schema was not altered at all; NFR-08 holds without exception.
|
||||
- **Ledger codes are `PRDI`/`PRDR`/`PRDL`/`PRDC`**, not the doc's 15–22-char names — `SourceDocType` is `varchar(10)` on both `stock_ledger` and `journal_entry_stubs`, and widening it would have been a second Phase-1 schema change.
|
||||
- **Three additions to Part C:** `RUN_EDGE` (a run must own its edges or a later template edit rewrites completed-run history), `RUN_STAGE.pos_x/pos_y` (the run canvas renders from them), `RUN_STAGE_EVENT.run_id` + nullable `run_stage_id` (run-level events, single-query timeline). `RUN_STAGE.template_stage_id` made nullable + `SET NULL` so a template stays editable after runs complete.
|
||||
- **UOM conversion is unspecified in docs/30** but essential. Contract consequence: `plannedQty` is in the input's declared UOM while `consumed*`/`returned*` are in the item's **base** UOM.
|
||||
- **`Idempotency-Key` accepted and ignored**, matching `GrnService.ConfirmAsync`. Status guards are the replay story; `RunStage.RowVersion` (xmin) prevents two concurrent terminal approves double-posting a receipt.
|
||||
- **FR-MFG-17's "− scrapped" is not computable** at the input level (scrap lives on outputs, in output UOM). Scrap never entered stock, so nothing is deducted; scrapped quantities are recorded on the cancel event instead.
|
||||
- **`GRAPH_DISCONNECTED` is unreachable** once cycle + terminal-count pass; kept as defence in depth. An isolated stage surfaces as `GRAPH_TERMINAL_COUNT`.
|
||||
- **Edit-lock TOCTOU accepted** — checked inside the transaction, but under READ COMMITTED a run could still be created against a template mid-edit. Benign: runs copy everything at creation and never re-read the template.
|
||||
|
||||
**Not done this pass (tracked, not silently skipped):**
|
||||
- [x] **Frontend wiring** (`docs/21-FRONTEND-PHASE2.md` §8) — done in the same session; see `Frontend/PROGRESS.md` §§11–13. Not browser-verified (same AuthHex blocker).
|
||||
- **Batch/serial-tracked finished goods** — guarded with a 422, and that guard is unexercised (no tracked item in the dev DB).
|
||||
- **`NAV:production` permission** is not seeded; the sidebar still relies on the `bypassCodes` stopgap (same as `procurement`/`hrm`).
|
||||
- **No automated test project** — verification is the `Backend/smoke/` scripts, per house practice.
|
||||
|
||||
> ### 2026-07-30 — Dev-database repair + a drift audit worth repeating
|
||||
> **`users."Email"` was missing from the database** while present in the entity and the model snapshot, so `ShadowUserClaimsTransformation`'s JIT insert failed with `42703` on **every authenticated request** — surfacing to callers as a confusing `InvalidOperationException: Sequence contains no elements`. `GET /items` and everything else 500'd. Fixed by the hand-written migration `RepairUserEmailColumn` (idempotent `ADD COLUMN IF NOT EXISTS` + the unique index, matching `UserConfiguration`'s `HasMaxLength(320)`).
|
||||
>
|
||||
> **Root cause — four migrations recorded as applied with zero operations:** `ini2`, `initial2`, `chages`, `chages1` each advanced the model snapshot without emitting any DDL. Anything added to the model in those windows exists in the snapshot but never reached the database.
|
||||
>
|
||||
> **Method note (this is the reusable part):** a scaffolded probe migration coming back **empty proves only `model == snapshot`, never `snapshot == database`** — which is exactly how this hid. The real audit was `dotnet ef dbcontext script` (which renders the *current model*) diffed against `information_schema.columns`.
|
||||
>
|
||||
> **Still outstanding — not fixed here, deliberately:** the same four empty migrations mean **all 25 HRM tables (`hr_*`) exist in the model and snapshot but not in this database**, so every HRM endpoint fails. Creating 25 tables of another module as a side effect of manufacturing work would be worse than reporting it; it needs its own repair migration and its own verification.
|
||||
>
|
||||
> **Also worth knowing:** `.gitignore:38` is `**/Migrations/`, so **no migration in this repo is version-controlled** — `AddManufacturingPhase2` and `RepairUserEmailColumn` exist only on the machine that created them. Anyone else must regenerate them.
|
||||
|
||||
> ### 2026-07-30 (later) — Three server-side additions the frontend wiring needed
|
||||
> All three are amended into `docs/30` as built. None changes an existing endpoint's behaviour.
|
||||
>
|
||||
> - **`TemplateGraphDto.activeRunCount`** — the builder derives its edit-locked state from it. Counted with its own scalar query rather than an `Include`, because the graph query already fans out over four collections and adding `Runs` would multiply those rows again for one integer.
|
||||
> - **`production_templates."Annotations"` (jsonb)** + `SaveTemplateRequest.Annotations`, migration **`AddTemplateCanvasAnnotations`** (exactly one `AddColumn`, applied and verified). The builder canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so **every save would have silently discarded the user's layout**. Stored through `ProductionJson` like every other jsonb column, so the column can only ever hold canonical JSON; `List<CanvasAnnotationDto>` capped at 200 by `[MaxLength]`, and `Kind` validated to `box`/`line` in `ValidateAsync` because nothing else constrains free-form client state going into jsonb. Deliberately invisible to `ProductionGraphValidator` — annotations carry no graph semantics.
|
||||
> - **Wholesale replacement is the flip side and is now pinned by an assertion:** a PUT that omits `annotations` clears them. `m2_templates.py` asserts preserve → clear → restore explicitly, because silent data loss is worse than an error.
|
||||
>
|
||||
> **Smoke suite: extended but NOT re-run.** `m2_templates.py` gained 10 assertions (annotation round-trip incl. geometry/label/rotation, `activeRunCount` on the graph, unknown-kind rejection, and the clear/restore pair). **These are unverified.** AuthHex cannot issue a token — its configured MySQL host `187.127.102.190:3306` is unreachable from this machine (`MySqlConnector … Connect Timeout expired` on `POST /api/user`), and the localhost alternative in its `appsettings.json` is commented out. `dotnet build` is clean and the migration applied cleanly, but the last full green run of the suite (312/312) predates these additions.
|
||||
>
|
||||
> **HRM schema gap CLOSED** (by the repo owner, not this work): migrations `production` (another empty one — the fifth) and **`AddHrmTables`** now exist, the latter creating all 25 `hr_*` tables. The "still outstanding" note in the entry above is resolved; the underlying lesson about empty migrations is not.
|
||||
|
||||
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,351 @@
|
||||
"""M2 smoke test — production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07).
|
||||
|
||||
Touches no stock: templates only. Run with the API and AuthHex up:
|
||||
|
||||
python Backend/smoke/m2_templates.py
|
||||
|
||||
Shape under test is a diamond, which exercises multiple entries converging on one
|
||||
terminal *and* a stage with two parents:
|
||||
|
||||
Cut ─┐
|
||||
├─▶ Assemble (terminal, real item)
|
||||
Prep ─┘
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap
|
||||
|
||||
TEMPLATE_CODE = "SMOKE-PT-M2"
|
||||
|
||||
|
||||
def pick_fixtures(c, chk):
|
||||
"""Grab two active items and a UOM to build a realistic template from."""
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
if len(items) < 2:
|
||||
sys.exit("FATAL: need at least 2 active items in the database.")
|
||||
uoms = c.get("/uoms?pageSize=5").body["items"]
|
||||
if not uoms:
|
||||
sys.exit("FATAL: need at least 1 UOM in the database.")
|
||||
return items[0]["itemId"], items[1]["itemId"], uoms[0]["uomId"]
|
||||
|
||||
|
||||
def diamond(raw_item, finished_item, uom):
|
||||
"""A valid graph: two entry stages feeding one terminal stage."""
|
||||
return {
|
||||
"code": TEMPLATE_CODE,
|
||||
"name": "Smoke chair line",
|
||||
"description": "Created by m2_templates.py",
|
||||
"stages": [
|
||||
{
|
||||
"key": "tmp-cut", "name": "Cut frame", "roleLabel": "Carpentry",
|
||||
"estimatedMinutes": 60, "posX": 80, "posY": 120,
|
||||
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||
"type": "Checkbox", "required": True}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 8}],
|
||||
"outputs": [{"key": "tmp-frame", "name": "Frame set", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
"key": "tmp-prep", "name": "Prep cushions", "roleLabel": "Upholstery",
|
||||
"estimatedMinutes": 30, "posX": 80, "posY": 320, "fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 2}],
|
||||
"outputs": [{"key": "tmp-cushion", "name": "Cushion set", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
"key": "tmp-asm", "name": "Assemble & QA", "roleLabel": "QA",
|
||||
"estimatedMinutes": 45, "posX": 560, "posY": 200, "fieldDefs": [],
|
||||
"inputs": [
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "uomId": uom, "qtyPerBatch": 1},
|
||||
],
|
||||
# Terminal output must name the finished item (FR-MFG-05).
|
||||
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item,
|
||||
"uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"parentKey": "tmp-cut", "childKey": "tmp-asm"},
|
||||
{"parentKey": "tmp-prep", "childKey": "tmp-asm"},
|
||||
],
|
||||
# Canvas-only decoration. No graph semantics at all — the validator never sees these,
|
||||
# which is exactly what the "annotations do not affect the graph" assertion checks.
|
||||
"annotations": [
|
||||
{"kind": "box", "posX": 40, "posY": 60, "width": 400, "height": 320,
|
||||
"label": "Sub-assembly", "rotation": None},
|
||||
{"kind": "line", "posX": 480, "posY": 40, "width": 220, "height": 4,
|
||||
"label": "Phase 2", "rotation": 90},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def cleanup(c):
|
||||
"""
|
||||
Find any template this script left behind, and clear the edit-lock if later scripts
|
||||
started runs against it.
|
||||
|
||||
Without this the script is single-use: FR-MFG-06 refuses a PUT while any run of the
|
||||
template is InProgress, so a second execution would fail its very first assertion with
|
||||
409 TEMPLATE_IN_USE. Cancelling those runs is safe — they belong to the smoke suite.
|
||||
"""
|
||||
existing = c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
template = next((t for t in existing if t["code"] == TEMPLATE_CODE), None)
|
||||
if template is None:
|
||||
return None
|
||||
|
||||
tid = template["templateId"]
|
||||
if template["activeRunCount"] > 0:
|
||||
reason = next((r["reasonCodeId"] for r in
|
||||
c.get("/reason-codes?context=Production&pageSize=50").body["items"]
|
||||
if r["code"] == "PRD-CANCEL"), None)
|
||||
blocking = [r for r in c.get(f"/production-runs?templateId={tid}&status=InProgress&pageSize=200").body["items"]]
|
||||
for run in blocking:
|
||||
c.post(f"/production-runs/{run['runId']}/cancel",
|
||||
{"reasonCodeId": reason, "note": "cancelled by m2_templates.py to clear the edit-lock"})
|
||||
print(f"cancelled {len(blocking)} in-progress run(s) to release the template edit-lock")
|
||||
return tid
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api} · AuthHex {args.auth}")
|
||||
|
||||
raw_item, finished_item, uom = pick_fixtures(c, chk)
|
||||
print(f"fixtures: rawItem={raw_item} finishedItem={finished_item} uom={uom}")
|
||||
|
||||
stale = cleanup(c)
|
||||
if stale:
|
||||
print(f"note: reusing/overwriting existing template {stale} ({TEMPLATE_CODE})")
|
||||
|
||||
payload = diamond(raw_item, finished_item, uom)
|
||||
|
||||
# ---------------------------------------------------------------- create
|
||||
chk.section("1. Create a valid diamond graph")
|
||||
if stale:
|
||||
head = c.get(f"/production-templates/{stale}")
|
||||
created = c.put(f"/production-templates/{stale}", payload, if_match=head.etag)
|
||||
chk.status("PUT existing template", created, 200)
|
||||
else:
|
||||
created = c.post("/production-templates", payload)
|
||||
chk.status("POST /production-templates", created, 201)
|
||||
|
||||
if created.status not in (200, 201):
|
||||
return chk.finish("M2")
|
||||
|
||||
tid = created.body["templateId"]
|
||||
chk.check("ETag header present", created.etag is not None, True)
|
||||
chk.check("3 stages persisted", len(created.body["stages"]), 3)
|
||||
chk.check("2 edges persisted", len(created.body["edges"]), 2)
|
||||
|
||||
# ------------------------------------------------------------------- get
|
||||
chk.section("2. GET round-trips ids, keys, positions and fieldDefs")
|
||||
got = c.get(f"/production-templates/{tid}")
|
||||
chk.status("GET /production-templates/{id}", got, 200)
|
||||
g = got.body
|
||||
|
||||
stages = {s["name"]: s for s in g["stages"]}
|
||||
chk.check("stage keys equal their ids",
|
||||
all(s["key"] == str(s["stageId"]) for s in g["stages"]), True)
|
||||
|
||||
cut = stages["Cut frame"]
|
||||
chk.check("posX survived the round trip", float(cut["posX"]), 80.0)
|
||||
chk.check("fieldDefs jsonb survived", cut["fieldDefs"][0]["key"], "moisture_ok")
|
||||
chk.check("fieldDef type survived as enum name", cut["fieldDefs"][0]["type"], "Checkbox")
|
||||
|
||||
asm = stages["Assemble & QA"]
|
||||
chk.check("terminal has 2 upstream inputs",
|
||||
sum(1 for i in asm["inputs"] if i["source"] == "Upstream"), 2)
|
||||
chk.check("upstream inputs resolved fromOutputId",
|
||||
all(i["fromOutputId"] for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||
chk.check("upstream inputs expose fromOutputKey",
|
||||
all(i["fromOutputKey"] for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||
chk.check("terminal output carries the finished item", asm["outputs"][0]["itemId"], finished_item)
|
||||
|
||||
# The builder reads activeRunCount straight off the graph to decide whether to disable
|
||||
# itself; without it on this response it would need a second request to the list endpoint.
|
||||
chk.check("graph exposes activeRunCount", g["activeRunCount"], 0)
|
||||
|
||||
# Canvas annotations. Not in docs/30 Part C — added so a save can't silently discard the
|
||||
# boxes and dividers the builder already draws.
|
||||
chk.section("2b. Canvas annotations round-trip")
|
||||
anns = {a["kind"]: a for a in g["annotations"]}
|
||||
chk.check("both annotations persisted", len(g["annotations"]), 2)
|
||||
chk.check("box label survived", anns.get("box", {}).get("label"), "Sub-assembly")
|
||||
chk.check("box geometry survived", (float(anns["box"]["posX"]), float(anns["box"]["width"])), (40.0, 400.0))
|
||||
chk.check("line rotation survived", float(anns.get("line", {}).get("rotation") or 0), 90.0)
|
||||
chk.check("annotations are not stages", len(g["stages"]), 3)
|
||||
|
||||
bad_kind = dict(rebuild_from_get(g))
|
||||
bad_kind["annotations"] = [{"kind": "circle", "posX": 0, "posY": 0, "width": 10,
|
||||
"height": 10, "label": None, "rotation": None}]
|
||||
chk.status("unknown annotation kind rejected",
|
||||
c.put(f"/production-templates/{tid}", bad_kind, if_match=got.etag), 422)
|
||||
|
||||
# The keys GET hands back must be directly reusable as a PUT payload.
|
||||
chk.section("3. Idempotent re-save using the server's own keys")
|
||||
echo = rebuild_from_get(g)
|
||||
resaved = c.put(f"/production-templates/{tid}", echo, if_match=got.etag)
|
||||
chk.status("PUT echoing server keys", resaved, 200)
|
||||
if resaved.status == 200:
|
||||
chk.check("stage ids preserved (diffed, not recreated)",
|
||||
sorted(s["stageId"] for s in resaved.body["stages"]),
|
||||
sorted(s["stageId"] for s in g["stages"]))
|
||||
chk.check("edge count still 2", len(resaved.body["edges"]), 2)
|
||||
chk.check("annotations survived the echo re-save", len(resaved.body["annotations"]), 2)
|
||||
etag = resaved.etag
|
||||
|
||||
# Wholesale replacement cuts both ways: a client that forgets to echo annotations back
|
||||
# wipes them. Pinned explicitly because that is a silent data loss, not an error.
|
||||
stripped = rebuild_from_get(g)
|
||||
stripped["annotations"] = []
|
||||
cleared = c.put(f"/production-templates/{tid}", stripped, if_match=etag)
|
||||
chk.status("PUT omitting annotations", cleared, 200)
|
||||
if cleared.status == 200:
|
||||
chk.check("omitted annotations are cleared", len(cleared.body["annotations"]), 0)
|
||||
restored = c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match=cleared.etag)
|
||||
chk.status("PUT restoring annotations", restored, 200)
|
||||
etag = restored.etag if restored.status == 200 else cleared.etag
|
||||
else:
|
||||
etag = got.etag
|
||||
|
||||
# ------------------------------------------------------- graph rejections
|
||||
chk.section("4. Graph validation rejections (FR-MFG-02, FR-MFG-04, FR-MFG-05)")
|
||||
|
||||
cycle = rebuild_from_get(g)
|
||||
cycle["edges"].append({"parentKey": key_of(g, "Assemble & QA"), "childKey": key_of(g, "Cut frame")})
|
||||
chk.status("cycle", c.put(f"/production-templates/{tid}", cycle, if_match=etag), 422, "GRAPH_CYCLE")
|
||||
|
||||
two_term = rebuild_from_get(g)
|
||||
two_term["edges"] = [e for e in two_term["edges"] if e["parentKey"] != key_of(g, "Prep cushions")]
|
||||
chk.status("two terminals", c.put(f"/production-templates/{tid}", two_term, if_match=etag),
|
||||
422, "GRAPH_TERMINAL_COUNT")
|
||||
|
||||
# An isolated stage has no outbound edge, so it is *also* a second terminal and the
|
||||
# cheaper terminal-count check catches it first. That is the more useful error anyway —
|
||||
# it names both offending stages. GRAPH_DISCONNECTED is unreachable in a valid-so-far
|
||||
# DAG (see the note in ProductionGraphValidator) and is kept only as defence in depth.
|
||||
orphan = rebuild_from_get(g)
|
||||
orphan["stages"].append({
|
||||
"key": "tmp-orphan", "name": "Orphan stage", "estimatedMinutes": 5,
|
||||
"posX": 900, "posY": 600, "fieldDefs": [], "inputs": [],
|
||||
"outputs": [{"key": "tmp-orphan-out", "name": "Nothing", "uomId": uom, "qtyPerBatch": 1}],
|
||||
})
|
||||
chk.status("isolated stage (reported as a second terminal)",
|
||||
c.put(f"/production-templates/{tid}", orphan, if_match=etag),
|
||||
422, "GRAPH_TERMINAL_COUNT")
|
||||
|
||||
# Grandparent reference: Cut -> Mid -> Asm, with Asm drawing from Cut's output.
|
||||
grandparent = rebuild_from_get(g)
|
||||
cut_key = key_of(g, "Cut frame")
|
||||
asm_key = key_of(g, "Assemble & QA")
|
||||
cut_output_key = output_key_of(g, "Cut frame", "Frame set")
|
||||
grandparent["stages"].append({
|
||||
"key": "tmp-mid", "name": "Middle", "estimatedMinutes": 5, "posX": 320, "posY": 120,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "uomId": uom, "qtyPerBatch": 1}],
|
||||
"outputs": [{"key": "tmp-mid-out", "name": "Mid part", "uomId": uom, "qtyPerBatch": 1}],
|
||||
})
|
||||
grandparent["edges"] = [e for e in grandparent["edges"] if e["parentKey"] != cut_key]
|
||||
grandparent["edges"] += [{"parentKey": cut_key, "childKey": "tmp-mid"},
|
||||
{"parentKey": "tmp-mid", "childKey": asm_key}]
|
||||
# Assemble still reads Cut's output, but Cut is now a grandparent -> invalid.
|
||||
chk.status("upstream input from a grandparent",
|
||||
c.put(f"/production-templates/{tid}", grandparent, if_match=etag),
|
||||
422, "GRAPH_INPUT_SOURCE_INVALID")
|
||||
|
||||
no_item = rebuild_from_get(g)
|
||||
for s in no_item["stages"]:
|
||||
if s["name"] == "Assemble & QA":
|
||||
s["outputs"][0]["itemId"] = None
|
||||
chk.status("terminal output without an item",
|
||||
c.put(f"/production-templates/{tid}", no_item, if_match=etag),
|
||||
422, "TERMINAL_OUTPUT_ITEM_REQUIRED")
|
||||
|
||||
wip_item = rebuild_from_get(g)
|
||||
for s in wip_item["stages"]:
|
||||
if s["name"] == "Cut frame":
|
||||
s["outputs"][0]["itemId"] = finished_item
|
||||
chk.status("intermediate output claiming an item",
|
||||
c.put(f"/production-templates/{tid}", wip_item, if_match=etag), 422)
|
||||
|
||||
bad_item = rebuild_from_get(g)
|
||||
for s in bad_item["stages"]:
|
||||
if s["name"] == "Cut frame":
|
||||
s["inputs"][0]["itemId"] = 999_999
|
||||
chk.status("stock input naming a nonexistent item",
|
||||
c.put(f"/production-templates/{tid}", bad_item, if_match=etag), 422)
|
||||
|
||||
chk.section("5. Concurrency + status")
|
||||
chk.status("PUT with no If-Match", c.request("PUT", f"/production-templates/{tid}", rebuild_from_get(g)),
|
||||
428, "PRECONDITION_REQUIRED")
|
||||
# Malformed vs stale are different failures. "AQAAAA==" is a well-formed 4-byte token
|
||||
# (xmin = 1) that no live row will ever carry, so it reaches the service's version
|
||||
# comparison and yields 412 rather than the 428 a garbage token would.
|
||||
chk.status("PUT with a malformed If-Match",
|
||||
c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"not-base64"'),
|
||||
428, "PRECONDITION_REQUIRED")
|
||||
chk.status("PUT with a stale If-Match",
|
||||
c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"AQAAAA=="'),
|
||||
412, "CONCURRENCY_CONFLICT")
|
||||
|
||||
chk.status("PATCH status -> Inactive",
|
||||
c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204)
|
||||
listed = c.get(f"/production-templates?q={TEMPLATE_CODE}")
|
||||
row = next((t for t in listed.body["items"] if t["templateId"] == tid), None)
|
||||
chk.check("list reports Inactive", row and row["status"], "Inactive")
|
||||
chk.check("list reports stageCount 3", row and row["stageCount"], 3)
|
||||
chk.check("list reports activeRunCount 0", row and row["activeRunCount"], 0)
|
||||
|
||||
# Leave it Active so the M3 run-creation smoke can start runs from it.
|
||||
c.patch(f"/production-templates/{tid}/status", {"status": "Active"})
|
||||
print(f"\nleft template {tid} ({TEMPLATE_CODE}) Active for the M3 smoke test")
|
||||
|
||||
return chk.finish("M2")
|
||||
|
||||
|
||||
# --- helpers: turn a GET response back into a save payload -------------------
|
||||
|
||||
def rebuild_from_get(g: dict) -> dict:
|
||||
"""Echo a fetched graph back as a save payload, reusing the server's keys."""
|
||||
return {
|
||||
"code": g["code"],
|
||||
"name": g["name"],
|
||||
"description": g.get("description"),
|
||||
"stages": [
|
||||
{
|
||||
"key": s["key"], "name": s["name"], "roleLabel": s.get("roleLabel"),
|
||||
"estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"],
|
||||
"fieldDefs": s["fieldDefs"],
|
||||
"inputs": [
|
||||
{"source": i["source"], "itemId": i.get("itemId"),
|
||||
"fromOutputKey": i.get("fromOutputKey"),
|
||||
"uomId": i["uomId"], "qtyPerBatch": i["qtyPerBatch"]}
|
||||
for i in s["inputs"]
|
||||
],
|
||||
"outputs": [
|
||||
{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
|
||||
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]}
|
||||
for o in s["outputs"]
|
||||
],
|
||||
}
|
||||
for s in g["stages"]
|
||||
],
|
||||
"edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in g["edges"]],
|
||||
# Echoed back deliberately: annotations are replaced wholesale, so omitting them here
|
||||
# would make every re-save silently clear the canvas layout notes.
|
||||
"annotations": g["annotations"],
|
||||
}
|
||||
|
||||
|
||||
def key_of(g: dict, stage_name: str) -> str:
|
||||
return next(s["key"] for s in g["stages"] if s["name"] == stage_name)
|
||||
|
||||
|
||||
def output_key_of(g: dict, stage_name: str, output_name: str) -> str:
|
||||
stage = next(s for s in g["stages"] if s["name"] == stage_name)
|
||||
return next(o["key"] for o in stage["outputs"] if o["name"] == output_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
"""M3 smoke test — run creation, list/detail, quantity override (docs/30 §D.2, FR-MFG-08/09/18).
|
||||
|
||||
Still touches no stock: creating a run only copies and scales the template. Depends on the
|
||||
template m2_templates.py leaves behind, so run that first:
|
||||
|
||||
python Backend/smoke/m2_templates.py
|
||||
python Backend/smoke/m3_runs.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap
|
||||
|
||||
TEMPLATE_CODE = "SMOKE-PT-M2"
|
||||
TARGET_QTY = 50
|
||||
|
||||
|
||||
def find_template(c):
|
||||
listed = c.get(f"/production-templates?q={TEMPLATE_CODE}")
|
||||
row = next((t for t in listed.body["items"] if t["code"] == TEMPLATE_CODE), None)
|
||||
if not row:
|
||||
sys.exit(f"FATAL: template {TEMPLATE_CODE} not found — run m2_templates.py first.")
|
||||
return row["templateId"]
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
tid = find_template(c)
|
||||
tpl = c.get(f"/production-templates/{tid}").body
|
||||
warehouse = c.get("/warehouses?pageSize=1").body["items"][0]["warehouseId"]
|
||||
# Captured before creating so the script stays re-runnable — a previous run of this
|
||||
# script leaves its own InProgress run behind for M4.
|
||||
runs_before = next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["templateId"] == tid)["activeRunCount"]
|
||||
print(f"template={tid} warehouse={warehouse} targetQty={TARGET_QTY} activeRunsBefore={runs_before}")
|
||||
|
||||
terminal_stage = next(s for s in tpl["stages"] if s["name"] == "Assemble & QA")
|
||||
terminal_qpb = terminal_stage["outputs"][0]["qtyPerBatch"]
|
||||
expected_scale = TARGET_QTY / terminal_qpb
|
||||
|
||||
# ------------------------------------------------------------------ create
|
||||
chk.section("1. Create a run (FR-MFG-08: scale, copy, number)")
|
||||
created = c.post("/production-runs", {
|
||||
"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": warehouse,
|
||||
})
|
||||
if not chk.status("POST /production-runs", created, 201):
|
||||
return chk.finish("M3")
|
||||
|
||||
run = created.body
|
||||
rid = run["runId"]
|
||||
chk.check("ETag header present", created.etag is not None, True)
|
||||
chk.check("docNo uses the PRD sequence", run["docNo"].startswith("PRD-"), True)
|
||||
chk.check("run starts InProgress", run["status"], "InProgress")
|
||||
chk.check("reworkCount starts at 0", run["reworkCount"], 0)
|
||||
chk.check("scaleFactor computed", float(run["scaleFactor"]), float(expected_scale))
|
||||
chk.check("3 stages copied", len(run["stages"]), 3)
|
||||
chk.check("2 edges copied", len(run["edges"]), 2)
|
||||
|
||||
# ------------------------------------------------------- copy-on-create
|
||||
chk.section("2. Copy-on-create carries display fields and fieldDefs (FR-MFG-06)")
|
||||
stages = {s["name"]: s for s in run["stages"]}
|
||||
chk.check("stage names copied", sorted(stages), ["Assemble & QA", "Cut frame", "Prep cushions"])
|
||||
|
||||
cut = stages["Cut frame"]
|
||||
chk.check("roleLabel copied", cut["roleLabel"], "Carpentry")
|
||||
chk.check("estimatedMinutes copied", cut["estimatedMinutes"], 60)
|
||||
chk.check("posX copied", float(cut["posX"]), 80.0)
|
||||
chk.check("fieldDefs copied", cut["fieldDefs"][0]["key"], "moisture_ok")
|
||||
chk.check("fieldValues empty before complete", cut["fieldValues"], None)
|
||||
chk.check("templateStageId links back", cut["templateStageId"] is not None, True)
|
||||
|
||||
# -------------------------------------------------------------- readiness
|
||||
chk.section("3. Entry stages Ready, others Waiting (FR-MFG-09)")
|
||||
chk.check("Cut frame is an entry", cut["isEntry"], True)
|
||||
chk.check("Cut frame Ready", cut["status"], "Ready")
|
||||
chk.check("Prep cushions Ready", stages["Prep cushions"]["status"], "Ready")
|
||||
|
||||
asm = stages["Assemble & QA"]
|
||||
chk.check("Assemble is terminal", asm["isTerminal"], True)
|
||||
chk.check("Assemble is not an entry", asm["isEntry"], False)
|
||||
chk.check("Assemble Waiting", asm["status"], "Waiting")
|
||||
chk.check("no stage is terminal but Cut/Prep",
|
||||
[s["name"] for s in run["stages"] if s["isTerminal"]], ["Assemble & QA"])
|
||||
|
||||
# ----------------------------------------------------------------- scaling
|
||||
chk.section("4. Every quantity scaled by the factor (FR-MFG-08)")
|
||||
tpl_stages = {s["name"]: s for s in tpl["stages"]}
|
||||
ok = True
|
||||
for name, rs in stages.items():
|
||||
ts = tpl_stages[name]
|
||||
for ti, ri in zip(ts["inputs"], rs["inputs"]):
|
||||
want = round(float(ti["qtyPerBatch"]) * expected_scale, 4)
|
||||
if float(ri["plannedQty"]) != want:
|
||||
ok = False
|
||||
print(f" input mismatch on {name}: {ri['plannedQty']} != {want}")
|
||||
for to, ro in zip(ts["outputs"], rs["outputs"]):
|
||||
want = round(float(to["qtyPerBatch"]) * expected_scale, 4)
|
||||
if float(ro["plannedQty"]) != want:
|
||||
ok = False
|
||||
print(f" output mismatch on {name}: {ro['plannedQty']} != {want}")
|
||||
chk.check("all planned quantities == qtyPerBatch x scaleFactor", ok, True)
|
||||
chk.check("Cut frame input scaled (8 x 50)", float(cut["inputs"][0]["plannedQty"]), 400.0)
|
||||
chk.check("terminal output scaled to the target", float(asm["outputs"][0]["plannedQty"]), float(TARGET_QTY))
|
||||
|
||||
chk.check("consumed/delivered start at zero",
|
||||
all(float(i["consumedQty"]) == 0 and float(i["deliveredQty"]) == 0
|
||||
for s in run["stages"] for i in s["inputs"]), True)
|
||||
chk.check("cost pool starts empty", float(run["costPool"]["net"]), 0.0)
|
||||
|
||||
# Upstream inputs must point at the run's own copied outputs, not the template's.
|
||||
run_output_ids = {o["runOutputId"] for s in run["stages"] for o in s["outputs"]}
|
||||
chk.check("upstream inputs rewired to run outputs",
|
||||
all(i["fromRunOutputId"] in run_output_ids
|
||||
for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||
|
||||
# -------------------------------------------------------------------- list
|
||||
chk.section("5. Run board projection (FR-MFG-18)")
|
||||
listed = c.get(f"/production-runs?q={run['docNo']}")
|
||||
chk.status("GET /production-runs", listed, 200)
|
||||
row = next((r for r in listed.body["items"] if r["runId"] == rid), None)
|
||||
chk.check("run appears on the board", row is not None, True)
|
||||
if row:
|
||||
chk.check("stageSummary counts match",
|
||||
row["stageSummary"], {"waiting": 1, "ready": 2, "inProgress": 0, "done": 0, "approved": 0})
|
||||
chk.check("templateName joined", row["templateName"], tpl["name"])
|
||||
chk.check("finished item surfaced", row["finishedItemId"], asm["outputs"][0]["itemId"])
|
||||
chk.check("finished item name joined", row["finishedItemName"] is not None, True)
|
||||
|
||||
chk.check("filter by status=InProgress finds it",
|
||||
any(r["runId"] == rid for r in c.get("/production-runs?status=InProgress&pageSize=200").body["items"]), True)
|
||||
chk.check("filter by status=Completed excludes it",
|
||||
any(r["runId"] == rid for r in c.get("/production-runs?status=Completed&pageSize=200").body["items"]), False)
|
||||
chk.check("filter by templateId finds it",
|
||||
any(r["runId"] == rid for r in c.get(f"/production-runs?templateId={tid}&pageSize=200").body["items"]), True)
|
||||
|
||||
# -------------------------------------------------------------- edit-lock
|
||||
chk.section("6. Template edit-lock now that a run is InProgress (FR-MFG-06)")
|
||||
head = c.get(f"/production-templates/{tid}")
|
||||
locked = c.put(f"/production-templates/{tid}", {
|
||||
"code": tpl["code"], "name": tpl["name"], "description": tpl.get("description"),
|
||||
"stages": [
|
||||
{"key": s["key"], "name": s["name"], "roleLabel": s.get("roleLabel"),
|
||||
"estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"],
|
||||
"fieldDefs": s["fieldDefs"],
|
||||
"inputs": [{"source": i["source"], "itemId": i.get("itemId"),
|
||||
"fromOutputKey": i.get("fromOutputKey"), "uomId": i["uomId"],
|
||||
"qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"]],
|
||||
"outputs": [{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
|
||||
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} for o in s["outputs"]]}
|
||||
for s in tpl["stages"]
|
||||
],
|
||||
"edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in tpl["edges"]],
|
||||
}, if_match=head.etag)
|
||||
chk.status("PUT template while a run is InProgress", locked, 409, "TEMPLATE_IN_USE")
|
||||
|
||||
# Deactivating must stay allowed — it only blocks NEW runs (FR-MFG-01).
|
||||
chk.status("PATCH status while a run is InProgress is still allowed",
|
||||
c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204)
|
||||
chk.status("run creation from an Inactive template",
|
||||
c.post("/production-runs", {"templateId": tid, "targetQty": 5, "warehouseId": warehouse}),
|
||||
422, "TEMPLATE_INACTIVE")
|
||||
c.patch(f"/production-templates/{tid}/status", {"status": "Active"})
|
||||
|
||||
chk.check("activeRunCount incremented by the new run",
|
||||
next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["templateId"] == tid)["activeRunCount"], runs_before + 1)
|
||||
|
||||
# ------------------------------------------------------------- quantities
|
||||
chk.section("7. Per-run quantity override (FR-MFG-08)")
|
||||
cut_input = cut["inputs"][0]
|
||||
edited = c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities",
|
||||
{"inputs": [{"id": cut_input["runInputId"], "plannedQty": 420}], "outputs": []})
|
||||
chk.status("PUT quantities on a Ready stage", edited, 200)
|
||||
if edited.status == 200:
|
||||
chk.check("plannedQty updated", float(edited.body["inputs"][0]["plannedQty"]), 420.0)
|
||||
chk.check("stage still Ready (no upstream inputs)", edited.body["status"], "Ready")
|
||||
|
||||
chk.status("PUT quantities naming another stage's input",
|
||||
c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities",
|
||||
{"inputs": [{"id": asm["inputs"][0]["runInputId"], "plannedQty": 9}], "outputs": []}),
|
||||
422)
|
||||
|
||||
# Raising a Waiting stage's upstream input must keep it Waiting, and the edit must be
|
||||
# rejected outright once a stage has started (covered in M4 once we can start one).
|
||||
asm_up = next(i for i in asm["inputs"] if i["source"] == "Upstream")
|
||||
bumped = c.put(f"/production-runs/{rid}/stages/{asm['runStageId']}/quantities",
|
||||
{"inputs": [{"id": asm_up["runInputId"], "plannedQty": 60}], "outputs": []})
|
||||
chk.status("PUT quantities on a Waiting stage", bumped, 200)
|
||||
if bumped.status == 200:
|
||||
chk.check("stage stays Waiting (nothing delivered)", bumped.body["status"], "Waiting")
|
||||
|
||||
chk.status("PUT quantities on a nonexistent stage",
|
||||
c.put(f"/production-runs/{rid}/stages/999999/quantities", {"inputs": [], "outputs": []}), 404)
|
||||
|
||||
# ------------------------------------------------------------------ events
|
||||
chk.section("8. Event history records the edits")
|
||||
detail = c.get(f"/production-runs/{rid}")
|
||||
chk.status("GET /production-runs/{id}", detail, 200)
|
||||
events = detail.body["events"]
|
||||
# Two, not four: only the two successful edits are recorded. The 422 (wrong stage) and
|
||||
# the 404 both throw inside ExecuteInTransactionAsync, so their event write rolls back
|
||||
# with the rest of the change — history never shows an edit that did not happen.
|
||||
chk.check("only successful edits are logged",
|
||||
sum(1 for e in events if e["eventType"] == "QuantityEdit"), 2)
|
||||
chk.check("event payload captured",
|
||||
events[0]["payload"] is not None if events else False, True)
|
||||
chk.check("event carries an actor", events[0]["userId"] > 0 if events else False, True)
|
||||
|
||||
print(f"\nleft run {rid} ({run['docNo']}) InProgress for the M4 smoke test")
|
||||
return chk.finish("M3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,329 @@
|
||||
"""M4 smoke test — stage start/complete/approve/transfer (docs/30 §D.3, FR-MFG-09..12).
|
||||
|
||||
FIRST STOCK-TOUCHING MILESTONE. Everything happens in a dedicated `SMOKE-PRD` warehouse so
|
||||
the effects are isolated from real data and easy to inspect or clean up:
|
||||
|
||||
DELETE FROM stock_ledger WHERE "WarehouseId" = (SELECT "WarehouseId" FROM warehouses WHERE "Code"='SMOKE-PRD');
|
||||
|
||||
Self-contained — builds its own template and run, so it does not depend on M2/M3 leftovers:
|
||||
|
||||
python Backend/smoke/m4_stage_actions.py
|
||||
|
||||
Leaves the terminal stage InProgress for m5_receipt.py to complete and approve.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M4"
|
||||
TARGET_QTY = 50
|
||||
SEED_RAW = 1000 # base units of the raw item
|
||||
SEED_PACK = 500 # base units of the packaging item
|
||||
# Deliberately awkward unit costs: the raw item is seeded in two layers at different costs
|
||||
# so FIFO consumption produces a genuinely weighted value rather than a round number.
|
||||
RAW_COST_1 = 2.5
|
||||
RAW_COST_2 = 4.75
|
||||
PACK_COST = 1.25
|
||||
STATE_FILE = "m4_state.json"
|
||||
|
||||
|
||||
def ensure_warehouse(c):
|
||||
for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]:
|
||||
if w["code"] == WAREHOUSE_CODE:
|
||||
return w["warehouseId"]
|
||||
created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Production smoke warehouse"})
|
||||
if created.status != 201:
|
||||
sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}")
|
||||
return created.body["warehouseId"]
|
||||
|
||||
|
||||
def adjustment_reason(c):
|
||||
codes = c.get("/reason-codes?context=Adjustment&pageSize=50").body["items"]
|
||||
if not codes:
|
||||
sys.exit("FATAL: no Adjustment reason codes seeded.")
|
||||
return codes[0]["reasonCodeId"]
|
||||
|
||||
|
||||
def production_reason(c, code):
|
||||
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
|
||||
if r["code"] == code:
|
||||
return r["reasonCodeId"]
|
||||
sys.exit(f"FATAL: Production reason {code} not seeded.")
|
||||
|
||||
|
||||
def on_hand(c, item, wh):
|
||||
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||
|
||||
|
||||
def ledger_rows(c, run_id, source):
|
||||
rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||
return rows
|
||||
|
||||
|
||||
def seed_stock(c, wh, raw, pack, uom):
|
||||
"""
|
||||
Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO
|
||||
consumption at start has to weight them, so `consumedValue` is a real number the
|
||||
assertions can check rather than the 0.00 a positive adjustment would produce.
|
||||
"""
|
||||
seed_costed_stock(c, wh, [
|
||||
(raw, uom, SEED_RAW * 0.4, RAW_COST_1),
|
||||
(raw, uom, SEED_RAW * 0.6, RAW_COST_2),
|
||||
(pack, uom, SEED_PACK, PACK_COST),
|
||||
])
|
||||
|
||||
|
||||
def build_template(c, raw, pack, finished, uom):
|
||||
"""Cut (entry, stock input) → Assemble (terminal, upstream + a late stock input)."""
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE,
|
||||
"name": "Smoke M4 line",
|
||||
"description": "Created by m4_stage_actions.py",
|
||||
"stages": [
|
||||
{
|
||||
"key": "tmp-cut", "name": "Cut", "roleLabel": "Carpentry",
|
||||
"estimatedMinutes": 60, "posX": 80, "posY": 100,
|
||||
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||
"type": "Checkbox", "required": True}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": 8}],
|
||||
"outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
# A Stock input on a non-entry stage — FR-MFG-04 allows material to join late
|
||||
# (packaging), which is exactly what this covers.
|
||||
"key": "tmp-asm", "name": "Assemble", "roleLabel": "QA",
|
||||
"estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [],
|
||||
"inputs": [
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||
{"source": "Stock", "itemId": pack, "uomId": uom, "qtyPerBatch": 2},
|
||||
],
|
||||
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished,
|
||||
"uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
],
|
||||
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||
}
|
||||
|
||||
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_CODE), None)
|
||||
if existing:
|
||||
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||
if res.status == 409:
|
||||
# An earlier smoke run is still InProgress; reuse the template as-is.
|
||||
return existing["templateId"]
|
||||
if res.status != 200:
|
||||
sys.exit(f"FATAL: could not update the M4 template: {res.status} {res.body}")
|
||||
return res.body["templateId"]
|
||||
|
||||
res = c.post("/production-templates", payload)
|
||||
if res.status != 201:
|
||||
sys.exit(f"FATAL: could not create the M4 template: {res.status} {res.body}")
|
||||
return res.body["templateId"]
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
if len(items) < 3:
|
||||
sys.exit("FATAL: need at least 3 active items.")
|
||||
raw, pack, finished = items[0]["itemId"], items[1]["itemId"], items[2]["itemId"]
|
||||
uom = items[0]["baseUomId"]
|
||||
|
||||
wh = ensure_warehouse(c)
|
||||
drained = drain_stock(c, wh)
|
||||
if drained:
|
||||
print(f"drained {len(drained)} leftover item(s) from a previous execution")
|
||||
seed_stock(c, wh, raw, pack, uom)
|
||||
raw_before = on_hand(c, raw, wh)
|
||||
pack_before = on_hand(c, pack, wh)
|
||||
print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}")
|
||||
|
||||
tid = build_template(c, raw, pack, finished, uom)
|
||||
created = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
|
||||
if not chk.status("create the run", created, 201):
|
||||
return chk.finish("M4")
|
||||
|
||||
run = created.body
|
||||
rid = run["runId"]
|
||||
cut = next(s for s in run["stages"] if s["name"] == "Cut")
|
||||
asm = next(s for s in run["stages"] if s["name"] == "Assemble")
|
||||
cut_id, asm_id = cut["runStageId"], asm["runStageId"]
|
||||
print(f"run={rid} {run['docNo']} cut={cut_id} assemble={asm_id}")
|
||||
|
||||
# ------------------------------------------------------------------- start
|
||||
chk.section("1. Stage start FIFO-consumes its stock inputs (FR-MFG-10)")
|
||||
started = c.post(f"/production-runs/{rid}/stages/{cut_id}/start", idempotency_key="m4-start-cut")
|
||||
if not chk.status("POST .../start", started, 200):
|
||||
return chk.finish("M4")
|
||||
|
||||
chk.check("stage now InProgress", started.body["status"], "InProgress")
|
||||
chk.check("actualStartAt stamped", started.body["actualStartAt"] is not None, True)
|
||||
chk.check("one input consumed", len(started.body["consumed"]), 1)
|
||||
|
||||
con = started.body["consumed"][0]
|
||||
chk.check("consumed the scaled quantity (8 x 50)", float(con["qty"]), 400.0)
|
||||
chk.check("consumed layers reported", len(con["consumedLayers"]) >= 1, True)
|
||||
chk.check("consumed value = qty x layer cost",
|
||||
round(float(con["value"]), 4),
|
||||
round(sum(float(l["qty"]) * float(l["unitCost"]) for l in con["consumedLayers"]), 4))
|
||||
# The whole point of seeding two layers at different costs: prove the value is genuinely
|
||||
# FIFO-weighted rather than zero or a single flat rate.
|
||||
chk.check("consumed value is non-zero", float(con["value"]) > 0, True)
|
||||
layer_costs = {float(l["unitCost"]) for l in con["consumedLayers"]}
|
||||
chk.check("consumption drew from the cheaper layer first (FR-STK-03)",
|
||||
min(layer_costs), RAW_COST_1)
|
||||
chk.check("ledgerRefs returned", len(started.body["ledgerRefs"]), 1)
|
||||
|
||||
chk.check("on-hand fell by exactly the consumed qty", on_hand(c, raw, wh), raw_before - 400.0)
|
||||
|
||||
prdi = ledger_rows(c, rid, "PRDI")
|
||||
chk.check("one PRDI ledger row", len(prdi), 1)
|
||||
if prdi:
|
||||
chk.check("PRDI direction is Out", prdi[0]["direction"], "Out")
|
||||
chk.check("PRDI qty is base-UOM 400", float(prdi[0]["qtyBase"]), 400.0)
|
||||
|
||||
detail = c.get(f"/production-runs/{rid}").body
|
||||
cut_now = next(s for s in detail["stages"] if s["runStageId"] == cut_id)
|
||||
chk.check("consumedQty recorded on the input", float(cut_now["inputs"][0]["consumedQty"]), 400.0)
|
||||
chk.check("cost pool now reflects the consumption",
|
||||
float(detail["costPool"]["net"]), round(float(con["value"]), 4))
|
||||
|
||||
chk.section("2. Guards after starting")
|
||||
chk.status("start again", c.post(f"/production-runs/{rid}/stages/{cut_id}/start"),
|
||||
409, "STAGE_NOT_READY")
|
||||
chk.status("start a Waiting stage", c.post(f"/production-runs/{rid}/stages/{asm_id}/start"),
|
||||
409, "STAGE_NOT_READY")
|
||||
chk.status("edit quantities on a started stage",
|
||||
c.put(f"/production-runs/{rid}/stages/{cut_id}/quantities",
|
||||
{"inputs": [{"id": cut_now["inputs"][0]["runInputId"], "plannedQty": 500}], "outputs": []}),
|
||||
409, "STAGE_NOT_EDITABLE")
|
||||
|
||||
# ---------------------------------------------------------------- complete
|
||||
chk.section("3. Stage complete records produced/scrap/fields (FR-MFG-11)")
|
||||
cut_out = cut_now["outputs"][0]["runOutputId"]
|
||||
|
||||
chk.status("complete without the required custom field",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}]}),
|
||||
400, "REQUIRED_FIELD_MISSING")
|
||||
|
||||
chk.status("complete with scrap but no reason code",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2}],
|
||||
"fieldValues": {"moisture_ok": True}}),
|
||||
400, "REASON_CODE_REQUIRED")
|
||||
|
||||
chk.status("complete with an Adjustment-context reason",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2,
|
||||
"scrapReasonCodeId": adjustment_reason(c)}],
|
||||
"fieldValues": {"moisture_ok": True}}),
|
||||
422)
|
||||
|
||||
chk.status("complete with scrapped > produced",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 5, "scrappedQty": 9,
|
||||
"scrapReasonCodeId": production_reason(c, "PRD-SCRAP")}],
|
||||
"fieldValues": {"moisture_ok": True}}),
|
||||
422)
|
||||
|
||||
done = c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}],
|
||||
"fieldValues": {"moisture_ok": True}})
|
||||
chk.status("complete properly", done, 200)
|
||||
if done.status == 200:
|
||||
chk.check("stage now Done", done.body["status"], "Done")
|
||||
chk.check("actualEndAt stamped", done.body["actualEndAt"] is not None, True)
|
||||
chk.check("actualMinutes computed", done.body["actualMinutes"] is not None, True)
|
||||
chk.check("producedQty recorded", float(done.body["outputs"][0]["producedQty"]), 50.0)
|
||||
chk.check("availableToTransfer = produced - scrapped - transferred",
|
||||
float(done.body["outputs"][0]["availableToTransfer"]), 50.0)
|
||||
chk.check("fieldValues persisted", done.body["fieldValues"], {"moisture_ok": True})
|
||||
|
||||
# ----------------------------------------------------------------- approve
|
||||
chk.section("4. Approve with a partial transfer (FR-MFG-12)")
|
||||
approved = c.post(f"/production-runs/{rid}/stages/{cut_id}/approve",
|
||||
{"transfers": [{"runOutputId": cut_out, "qty": 30}]})
|
||||
chk.status("approve transferring 30 of 50", approved, 200)
|
||||
if approved.status == 200:
|
||||
chk.check("stage now Approved", approved.body["status"], "Approved")
|
||||
chk.check("run still InProgress (non-terminal)", approved.body["runStatus"], "InProgress")
|
||||
chk.check("no receipt on a non-terminal approve", approved.body["receipt"], None)
|
||||
chk.check("one transfer reported", len(approved.body["transfers"]), 1)
|
||||
t = approved.body["transfers"][0]
|
||||
chk.check("transferred 30", float(t["qty"]), 30.0)
|
||||
chk.check("child delivered 30", float(t["childDeliveredQty"]), 30.0)
|
||||
chk.check("child still Waiting (30 < 50 planned)", t["childStatus"], "Waiting")
|
||||
chk.check("remainder held on the stage",
|
||||
float(approved.body["stage"]["outputs"][0]["availableToTransfer"]), 20.0)
|
||||
|
||||
chk.section("5. Transfer the remainder, then over-transfer (FR-MFG-12)")
|
||||
chk.status("transfer 21 (more than the 20 remaining)",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||
{"runOutputId": cut_out, "qty": 21}),
|
||||
422, "TRANSFER_EXCEEDS_AVAILABLE")
|
||||
|
||||
moved = c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||
{"runOutputId": cut_out, "qty": 20})
|
||||
chk.status("transfer the remaining 20", moved, 200)
|
||||
if moved.status == 200:
|
||||
chk.check("child delivered 50", float(moved.body["transfers"][0]["childDeliveredQty"]), 50.0)
|
||||
chk.check("child flipped to Ready (FR-MFG-09)", moved.body["transfers"][0]["childStatus"], "Ready")
|
||||
chk.check("nothing left to transfer",
|
||||
float(moved.body["stage"]["outputs"][0]["availableToTransfer"]), 0.0)
|
||||
|
||||
chk.status("transfer once everything is gone",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||
{"runOutputId": cut_out, "qty": 1}),
|
||||
422, "TRANSFER_EXCEEDS_AVAILABLE")
|
||||
|
||||
# -------------------------------------------------- late stock input start
|
||||
chk.section("6. Start the terminal stage — a late Stock input (FR-MFG-04)")
|
||||
started2 = c.post(f"/production-runs/{rid}/stages/{asm_id}/start")
|
||||
chk.status("start Assemble", started2, 200)
|
||||
if started2.status == 200:
|
||||
chk.check("only the Stock input consumed (upstream is WIP)", len(started2.body["consumed"]), 1)
|
||||
chk.check("packaging consumed 2 x 50", float(started2.body["consumed"][0]["qty"]), 100.0)
|
||||
chk.check("packaging on-hand fell by 100", on_hand(c, pack, wh), pack_before - 100.0)
|
||||
chk.check("no ledger row for the upstream WIP input", len(ledger_rows(c, rid, "PRDI")), 2)
|
||||
|
||||
chk.section("7. Insufficient stock is refused (FR-MFG-10)")
|
||||
big = c.post("/production-runs", {"templateId": tid, "targetQty": 100000, "warehouseId": wh})
|
||||
if big.status == 201:
|
||||
big_cut = next(s for s in big.body["stages"] if s["name"] == "Cut")["runStageId"]
|
||||
chk.status("start a stage needing more than on-hand",
|
||||
c.post(f"/production-runs/{big.body['runId']}/stages/{big_cut}/start"),
|
||||
409, "STOCK_NEGATIVE_BLOCKED")
|
||||
chk.check("on-hand untouched by the failed start", on_hand(c, raw, wh), raw_before - 400.0)
|
||||
else:
|
||||
chk.check("could create the oversized run", big.status, 201)
|
||||
|
||||
chk.section("8. Event history")
|
||||
events = c.get(f"/production-runs/{rid}").body["events"]
|
||||
kinds = [e["eventType"] for e in events]
|
||||
chk.check("Start logged twice", kinds.count("Start"), 2)
|
||||
chk.check("Complete logged once", kinds.count("Complete"), 1)
|
||||
chk.check("Approve logged once", kinds.count("Approve"), 1)
|
||||
chk.check("Transfer logged once", kinds.count("Transfer"), 1)
|
||||
chk.check("no event for the rejected actions", kinds.count("QuantityEdit"), 0)
|
||||
|
||||
# Hand off to M5.
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump({"runId": rid, "templateId": tid, "warehouseId": wh,
|
||||
"assembleStageId": asm_id, "finishedItemId": finished,
|
||||
"rawItemId": raw, "packItemId": pack}, f)
|
||||
print(f"\nwrote {STATE_FILE}; run {rid} has Assemble InProgress for m5_receipt.py")
|
||||
|
||||
return chk.finish("M4")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
|
||||
@@ -0,0 +1,170 @@
|
||||
"""M4b smoke test — UOM conversion on production stock inputs.
|
||||
|
||||
This covers the single highest-risk correctness gap in the manufacturing phase.
|
||||
`IFifoCostingService.ConsumeAsync` works exclusively in an item's BASE UOM, while
|
||||
`STAGE_INPUT.uom_id` is a free FK — docs/30 never mentions conversion at all. Without the
|
||||
shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`), a stage input declared in
|
||||
"box of 12" would consume 1 base unit instead of 12 and silently mis-cost the whole run.
|
||||
|
||||
The dev database has no `uom_conversions` rows at all, so the non-base path was previously
|
||||
unexercised by any data. This script creates a real conversion and proves:
|
||||
|
||||
* a stage input in a non-base UOM consumes qtyPerBatch x scaleFactor x factor base units
|
||||
* the ledger records the BASE quantity, not the declared one
|
||||
* an input in a UOM with no conversion defined is refused with 422 rather than mis-consumed
|
||||
|
||||
python Backend/smoke/m4b_uom_conversion.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M4B"
|
||||
FACTOR = 12 # 1 case = 12 base units
|
||||
QTY_PER_BATCH = 3 # cases per batch
|
||||
TARGET_QTY = 10 # -> scale 10 -> 30 cases -> 360 base units
|
||||
SEED = 5000
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
# --- fixtures ---------------------------------------------------------
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
raw, finished = items[0], items[1]
|
||||
base_uom = raw["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
case_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if case_uom is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
|
||||
print(f"item={raw['itemId']} baseUom={base_uom} caseUom={case_uom} factor={FACTOR}")
|
||||
|
||||
# --- define the conversion -------------------------------------------
|
||||
chk.section("1. Define a non-base UOM conversion for the item")
|
||||
conv = c.put(f"/items/{raw['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": case_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
chk.status("PUT /items/{id}/uom-conversions", conv, 200)
|
||||
if conv.status != 200:
|
||||
return chk.finish("M4b")
|
||||
chk.check("conversion stored", any(float(x["factor"]) == FACTOR for x in conv.body["conversions"]), True)
|
||||
|
||||
reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
|
||||
c.post("/stock-adjustments", {"warehouseId": wh, "reasonCodeId": reason,
|
||||
"lines": [{"itemId": raw["itemId"], "qtyDelta": SEED}]})
|
||||
before = float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
print(f"on-hand before: {before}")
|
||||
|
||||
# --- template whose stock input is declared in CASES ------------------
|
||||
chk.section("2. A stage input declared in the non-base UOM")
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE, "name": "Smoke M4b conversion line",
|
||||
"stages": [{
|
||||
"key": "tmp-only", "name": "Pack", "estimatedMinutes": 10,
|
||||
"posX": 0, "posY": 0, "fieldDefs": [],
|
||||
# Declared in cases, not base units.
|
||||
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||
"uomId": case_uom, "qtyPerBatch": QTY_PER_BATCH}],
|
||||
"outputs": [{"key": "tmp-out", "name": "Packed", "itemId": finished["itemId"],
|
||||
"uomId": base_uom, "qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_CODE), None)
|
||||
if existing:
|
||||
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||
tid = existing["templateId"] if res.status in (200, 409) else None
|
||||
chk.check("template ready", tid is not None, True)
|
||||
else:
|
||||
res = c.post("/production-templates", payload)
|
||||
chk.status("create single-stage template", res, 201)
|
||||
tid = res.body["templateId"] if res.status == 201 else None
|
||||
|
||||
if tid is None:
|
||||
return chk.finish("M4b")
|
||||
|
||||
# A lone stage is both the entry and the terminal — worth asserting explicitly.
|
||||
run = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
|
||||
chk.status("create the run", run, 201)
|
||||
if run.status != 201:
|
||||
return chk.finish("M4b")
|
||||
|
||||
stage = run.body["stages"][0]
|
||||
chk.check("single stage is both entry and terminal",
|
||||
(stage["isEntry"], stage["isTerminal"]), (True, True))
|
||||
chk.check("single stage starts Ready", stage["status"], "Ready")
|
||||
chk.check("plannedQty stays in the DECLARED uom (3 x 10 cases)",
|
||||
float(stage["inputs"][0]["plannedQty"]), float(QTY_PER_BATCH * TARGET_QTY))
|
||||
|
||||
# --- the actual conversion assertion ---------------------------------
|
||||
chk.section("3. Consumption converts cases to base units")
|
||||
expected_base = QTY_PER_BATCH * TARGET_QTY * FACTOR # 3 x 10 x 12 = 360
|
||||
started = c.post(f"/production-runs/{run.body['runId']}/stages/{stage['runStageId']}/start")
|
||||
chk.status("start the stage", started, 200)
|
||||
if started.status != 200:
|
||||
return chk.finish("M4b")
|
||||
|
||||
con = started.body["consumed"][0]
|
||||
chk.check(f"consumed {expected_base} BASE units, not {QTY_PER_BATCH * TARGET_QTY}",
|
||||
float(con["qty"]), float(expected_base))
|
||||
chk.check("on-hand fell by the base quantity",
|
||||
float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before - expected_base)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run.body['runId']}&pageSize=50").body["items"]
|
||||
chk.check("one PRDI row", len(rows), 1)
|
||||
if rows:
|
||||
chk.check("ledger qtyBase is the converted quantity", float(rows[0]["qtyBase"]), float(expected_base))
|
||||
|
||||
detail = c.get(f"/production-runs/{run.body['runId']}").body
|
||||
chk.check("consumedQty stored in base units",
|
||||
float(detail["stages"][0]["inputs"][0]["consumedQty"]), float(expected_base))
|
||||
|
||||
# --- missing conversion is refused, not silently mis-consumed --------
|
||||
chk.section("4. An undefined conversion is refused (422), never assumed 1:1")
|
||||
third_uom = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
|
||||
if u["uomId"] not in (base_uom, case_uom)), None)
|
||||
if third_uom is None:
|
||||
chk.check("skipped: need a third UOM", True, True)
|
||||
else:
|
||||
bad = dict(payload)
|
||||
bad["code"] = TEMPLATE_CODE + "-BAD"
|
||||
bad["stages"] = [dict(payload["stages"][0])]
|
||||
bad["stages"][0] = {**payload["stages"][0],
|
||||
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||
"uomId": third_uom, "qtyPerBatch": 1}]}
|
||||
made = c.post("/production-templates", bad)
|
||||
if made.status != 201:
|
||||
head = c.get(f"/production-templates?q={TEMPLATE_CODE}-BAD")
|
||||
tid2 = next((t["templateId"] for t in head.body["items"]
|
||||
if t["code"] == TEMPLATE_CODE + "-BAD"), None)
|
||||
else:
|
||||
tid2 = made.body["templateId"]
|
||||
|
||||
if tid2:
|
||||
run2 = c.post("/production-runs", {"templateId": tid2, "targetQty": 1, "warehouseId": wh})
|
||||
if run2.status == 201:
|
||||
s2 = run2.body["stages"][0]["runStageId"]
|
||||
chk.status("start a stage whose input UOM has no conversion",
|
||||
c.post(f"/production-runs/{run2.body['runId']}/stages/{s2}/start"), 422)
|
||||
else:
|
||||
chk.check("could create the second run", run2.status, 201)
|
||||
|
||||
return chk.finish("M4b")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,269 @@
|
||||
"""M5 smoke test — terminal approve = production receipt + cost pool (FR-MFG-13).
|
||||
|
||||
Three things are proven here, the last two being the ones most likely to be silently wrong:
|
||||
|
||||
A. A terminal approve creates a finished-goods layer costed at costPool / goodQty,
|
||||
posts a PRDR inbound ledger entry, and completes the run.
|
||||
B. Sum-of-ledger reconciliation: PRDR.value == sum(PRDI.value) - sum(PRDL.value) EXACTLY.
|
||||
This is what the `valueOverride` parameter added to PostLedgerAsync exists for — the
|
||||
6 dp unit cost, multiplied out over 100+ units, drifts past the ledger's 4 dp tick.
|
||||
C. A batch/serial-tracked finished item is refused rather than silently receiving
|
||||
untracked stock (docs/30 defines no batch creation on receipt).
|
||||
|
||||
Depends on the run m4_stage_actions.py leaves with its terminal stage InProgress:
|
||||
|
||||
python Backend/smoke/m4_stage_actions.py
|
||||
python Backend/smoke/m5_receipt.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
STATE_FILE = "m4_state.json"
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
SCRAP = 2
|
||||
|
||||
# Part B — the rounding case. The drift only appears when costPool / goodQty needs more than
|
||||
# 6 decimal places, so the numbers are chosen so it does:
|
||||
# consumed = 3.5 x 300 = 1050 base units at 1.234567 -> pool = 1296.29535
|
||||
# unitCost = round(1296.29535 / 300, 6) = 4.320985 (from 4.3209845, away from zero)
|
||||
# naive qty x unitCost = 300 x 4.320985 = 1296.2955 != round(pool, 4) = 1296.2954
|
||||
# Without valueOverride the ledger would carry 1296.2955 and stop reconciling to the pool.
|
||||
BIG_TEMPLATE_CODE = "SMOKE-PT-M5B"
|
||||
BIG_TARGET = 300
|
||||
BIG_QTY_PER_BATCH = 3.5
|
||||
BIG_UNIT_COST = 1.234567
|
||||
|
||||
|
||||
def on_hand(c, item, wh):
|
||||
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||
|
||||
|
||||
def ledger_sum(c, run_id, source):
|
||||
rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||
return sum(float(r["value"]) for r in rows), rows
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
if not os.path.exists(STATE_FILE):
|
||||
sys.exit(f"FATAL: {STATE_FILE} missing — run m4_stage_actions.py first.")
|
||||
state = json.load(open(STATE_FILE))
|
||||
rid, wh = state["runId"], state["warehouseId"]
|
||||
asm_id, finished = state["assembleStageId"], state["finishedItemId"]
|
||||
|
||||
detail = c.get(f"/production-runs/{rid}").body
|
||||
asm = next((s for s in detail["stages"] if s["runStageId"] == asm_id), None)
|
||||
if asm is None:
|
||||
sys.exit(f"FATAL: stage {asm_id} not on run {rid}.")
|
||||
if asm["status"] != "InProgress":
|
||||
sys.exit(f"FATAL: expected the terminal stage InProgress, found {asm['status']} — re-run m4.")
|
||||
|
||||
pool_before = float(detail["costPool"]["net"])
|
||||
fin_before = on_hand(c, finished, wh)
|
||||
print(f"run={rid} costPool={pool_before} finishedOnHand={fin_before}")
|
||||
|
||||
# ------------------------------------------------- complete the terminal
|
||||
chk.section("A1. Complete the terminal stage with scrap (FR-MFG-11)")
|
||||
out_id = asm["outputs"][0]["runOutputId"]
|
||||
scrap_reason = next(r["reasonCodeId"] for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]
|
||||
if r["code"] == "PRD-SCRAP")
|
||||
|
||||
done = c.post(f"/production-runs/{rid}/stages/{asm_id}/complete", {
|
||||
"outputs": [{"runOutputId": out_id, "producedQty": 50, "scrappedQty": SCRAP,
|
||||
"scrapReasonCodeId": scrap_reason}],
|
||||
})
|
||||
chk.status("complete the terminal stage", done, 200)
|
||||
if done.status != 200:
|
||||
return chk.finish("M5")
|
||||
chk.check("stage Done", done.body["status"], "Done")
|
||||
chk.check("scrap recorded", float(done.body["outputs"][0]["scrappedQty"]), float(SCRAP))
|
||||
chk.check("scrap reason recorded", done.body["outputs"][0]["scrapReasonCodeId"], scrap_reason)
|
||||
|
||||
# Scrap is absorbed into the pool, not written off (FR-MFG-11): no extra ledger row.
|
||||
chk.check("scrap posts no ledger entry",
|
||||
float(c.get(f"/production-runs/{rid}").body["costPool"]["net"]), pool_before)
|
||||
|
||||
# ------------------------------------------------------------- the receipt
|
||||
chk.section("A2. Terminal approve posts the receipt and completes the run (FR-MFG-13)")
|
||||
good = 50 - SCRAP
|
||||
approved = c.post(f"/production-runs/{rid}/stages/{asm_id}/approve")
|
||||
chk.status("approve the terminal stage", approved, 200)
|
||||
if approved.status != 200:
|
||||
return chk.finish("M5")
|
||||
|
||||
chk.check("stage Approved", approved.body["status"], "Approved")
|
||||
chk.check("run Completed", approved.body["runStatus"], "Completed")
|
||||
chk.check("no WIP transfers from a terminal stage", approved.body["transfers"], [])
|
||||
|
||||
receipt = approved.body["receipt"]
|
||||
chk.check("receipt returned", receipt is not None, True)
|
||||
if receipt:
|
||||
chk.check("receipt is for the finished item", receipt["itemId"], finished)
|
||||
chk.check("received the good quantity (produced - scrapped)",
|
||||
float(receipt["qtyReceived"]), float(good))
|
||||
chk.check("receipt warehouse is the run warehouse", receipt["warehouseId"], wh)
|
||||
chk.check("layer created", receipt["layerId"] > 0, True)
|
||||
|
||||
pool = approved.body["costPool"]
|
||||
chk.check("cost pool reported", pool is not None, True)
|
||||
if pool and receipt:
|
||||
chk.check("pool net = consumed - returned",
|
||||
round(float(pool["net"]), 4),
|
||||
round(float(pool["consumed"]) - float(pool["returned"]), 4))
|
||||
chk.check("pool net matches the pool before approval", float(pool["net"]), pool_before)
|
||||
expected_unit = round(float(pool["net"]) / float(good), 6)
|
||||
chk.check("unitCost = costPool / goodQty", float(receipt["unitCost"]), expected_unit)
|
||||
chk.check("receipt value = the cost pool exactly",
|
||||
float(receipt["value"]), round(float(pool["net"]), 4))
|
||||
|
||||
chk.check("finished on-hand rose by the good quantity", on_hand(c, finished, wh), fin_before + good)
|
||||
|
||||
completed = c.get(f"/production-runs/{rid}").body
|
||||
chk.check("completedAt stamped", completed["completedAt"] is not None, True)
|
||||
chk.check("run status persisted as Completed", completed["status"], "Completed")
|
||||
|
||||
# ------------------------------------------------- ledger reconciliation
|
||||
chk.section("A3. Ledger reconciles to the cost pool")
|
||||
issued, prdi = ledger_sum(c, rid, "PRDI")
|
||||
returned, _ = ledger_sum(c, rid, "PRDL")
|
||||
received, prdr = ledger_sum(c, rid, "PRDR")
|
||||
chk.check("one PRDR row", len(prdr), 1)
|
||||
if prdr:
|
||||
chk.check("PRDR direction is In", prdr[0]["direction"], "In")
|
||||
chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) [the core invariant]",
|
||||
round(issued - returned, 4), round(received, 4))
|
||||
print(f" issued={issued} returned={returned} received={received}")
|
||||
|
||||
# --------------------------------------------------- closed-run guards
|
||||
chk.section("A4. A completed run is closed to further action")
|
||||
chk.status("approve again", c.post(f"/production-runs/{rid}/stages/{asm_id}/approve"), 409)
|
||||
chk.status("start a stage on a completed run",
|
||||
c.post(f"/production-runs/{rid}/stages/{state['assembleStageId']}/start"), 409)
|
||||
chk.status("edit quantities on a completed run",
|
||||
c.put(f"/production-runs/{rid}/stages/{asm_id}/quantities", {"inputs": [], "outputs": []}), 409)
|
||||
|
||||
# ------------------------------------ the rounding case (>= 100 units)
|
||||
chk.section("B. Rounding: 300 units, where a 6 dp unit cost drifts past the 4 dp ledger tick")
|
||||
raw = state["rawItemId"]
|
||||
raw_uom = next(i["baseUomId"] for i in c.get("/items?pageSize=200").body["items"]
|
||||
if i["itemId"] == raw)
|
||||
finished_b = state["finishedItemId"]
|
||||
|
||||
# A fresh single-stage template: base-UOM input so no conversion muddies the arithmetic,
|
||||
# qtyPerBatch 3.5 so the consumed quantity is NOT a multiple of the target (which is what
|
||||
# forces pool / goodQty to repeat).
|
||||
payload_b = {
|
||||
"code": BIG_TEMPLATE_CODE, "name": "Smoke M5 rounding line",
|
||||
"stages": [{
|
||||
"key": "tmp-b", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": raw_uom,
|
||||
"qtyPerBatch": BIG_QTY_PER_BATCH}],
|
||||
"outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b,
|
||||
"uomId": raw_uom, "qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
existing_b = next((t for t in c.get(f"/production-templates?q={BIG_TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == BIG_TEMPLATE_CODE), None)
|
||||
if existing_b:
|
||||
hb = c.get(f"/production-templates/{existing_b['templateId']}")
|
||||
rb = c.put(f"/production-templates/{existing_b['templateId']}", payload_b, if_match=hb.etag)
|
||||
tid = existing_b["templateId"] if rb.status in (200, 409) else None
|
||||
else:
|
||||
rb = c.post("/production-templates", payload_b)
|
||||
tid = rb.body["templateId"] if rb.status == 201 else None
|
||||
|
||||
if tid is None:
|
||||
chk.check("create the rounding template", False, True)
|
||||
else:
|
||||
# Drain first, then seed at the exact unit cost the arithmetic above assumes. Without
|
||||
# the drain, FIFO would consume whatever earlier scripts left behind (at their costs)
|
||||
# and the pool would not match the figures this section reasons about — the assertions
|
||||
# would still "pass" while testing something else entirely.
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(raw, raw_uom, 5000, BIG_UNIT_COST)])
|
||||
|
||||
big = c.post("/production-runs", {"templateId": tid, "targetQty": BIG_TARGET, "warehouseId": wh})
|
||||
if big.status != 201:
|
||||
chk.check(f"create the {BIG_TARGET}-unit run", big.status, 201)
|
||||
else:
|
||||
brid = big.body["runId"]
|
||||
bstage = big.body["stages"][0]
|
||||
bout = bstage["outputs"][0]["runOutputId"]
|
||||
|
||||
s = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/start")
|
||||
chk.status("start", s, 200)
|
||||
d = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": bout, "producedQty": BIG_TARGET, "scrappedQty": 0}]})
|
||||
chk.status("complete", d, 200)
|
||||
a = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/approve")
|
||||
chk.status("approve (single stage is the terminal)", a, 200)
|
||||
|
||||
if a.status == 200:
|
||||
bpool = float(a.body["costPool"]["net"])
|
||||
brec = a.body["receipt"]
|
||||
unit = float(brec["unitCost"])
|
||||
naive = round(unit * BIG_TARGET, 4)
|
||||
chk.check("cost pool is non-zero", bpool > 0, True)
|
||||
chk.check("receipt value == cost pool exactly", float(brec["value"]), round(bpool, 4))
|
||||
print(f" pool={bpool} unitCost={unit} naive qty*unit={naive}")
|
||||
# This is the assertion that justifies the valueOverride parameter existing.
|
||||
# If the naive product ever equals the pool, the fixture stopped exercising
|
||||
# the rounding path and the test has quietly gone blind.
|
||||
chk.check("naive qty x unitCost really would have drifted (fixture still valid)",
|
||||
naive != round(bpool, 4), True)
|
||||
|
||||
bissued, _ = ledger_sum(c, brid, "PRDI")
|
||||
breturned, _ = ledger_sum(c, brid, "PRDL")
|
||||
breceived, _ = ledger_sum(c, brid, "PRDR")
|
||||
chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) at 300 units",
|
||||
round(bissued - breturned, 4), round(breceived, 4))
|
||||
|
||||
# ------------------------------------------- tracked finished goods
|
||||
chk.section("C. A batch/serial-tracked finished item is refused")
|
||||
tracked = next((i for i in c.get("/items?pageSize=200&status=Active").body["items"]
|
||||
if i.get("trackingMode") in ("Batch", "Serial")), None)
|
||||
if tracked is None:
|
||||
chk.check("skipped: no batch/serial-tracked item in the database", True, True)
|
||||
print(" NOTE: the TrackingMode guard in PostReceiptAsync is unexercised here.")
|
||||
else:
|
||||
print(f" using tracked item {tracked['itemId']} ({tracked['trackingMode']})")
|
||||
payload = {
|
||||
"code": "SMOKE-PT-TRACKED", "name": "Tracked finished good",
|
||||
"stages": [{
|
||||
"key": "tmp-one", "name": "Make", "estimatedMinutes": 1, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [], "inputs": [],
|
||||
"outputs": [{"key": "tmp-o", "name": "Tracked", "itemId": tracked["itemId"],
|
||||
"uomId": tracked["baseUomId"], "qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
made = c.post("/production-templates", payload)
|
||||
ttid = made.body["templateId"] if made.status == 201 else next(
|
||||
(t["templateId"] for t in c.get("/production-templates?q=SMOKE-PT-TRACKED").body["items"]
|
||||
if t["code"] == "SMOKE-PT-TRACKED"), None)
|
||||
if ttid:
|
||||
tr = c.post("/production-runs", {"templateId": ttid, "targetQty": 1, "warehouseId": wh})
|
||||
if tr.status == 201:
|
||||
st = tr.body["stages"][0]["runStageId"]
|
||||
to = tr.body["stages"][0]["outputs"][0]["runOutputId"]
|
||||
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/start")
|
||||
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/complete",
|
||||
{"outputs": [{"runOutputId": to, "producedQty": 1, "scrappedQty": 0}]})
|
||||
chk.status("approve a tracked finished good",
|
||||
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/approve"), 422)
|
||||
|
||||
return chk.finish("M5")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,422 @@
|
||||
"""M6 + M7 smoke test — leftover return, reject-intake, terminal reject, run cancel.
|
||||
|
||||
Covers FR-MFG-14 (leftover return at the consumed weighted cost), FR-MFG-15 (downstream
|
||||
reject pulls work back to the parent), FR-MFG-16 (terminal reject resets the run for a
|
||||
rework pass) and FR-MFG-17 (cancel returns net consumed stock).
|
||||
|
||||
Self-contained: builds its own two-stage template and three separate runs, and drains the
|
||||
warehouse first so FIFO costs are known. Run after m4 so the warehouse exists:
|
||||
|
||||
python Backend/smoke/m4_stage_actions.py
|
||||
python Backend/smoke/m6_m7_leftover_rework_cancel.py
|
||||
|
||||
The assertions that matter most and are easiest to get silently wrong:
|
||||
* a FULL leftover return must leave returnedValue == consumedValue EXACTLY (no crumb)
|
||||
* reject-intake must DECREMENT the parent's transferredQty, not zero it
|
||||
* a terminal reject must PRESERVE consumedQty/consumedValue and plannedQty
|
||||
* a re-complete after rework must OVERWRITE producedQty, not add to it
|
||||
* a rework restart with an unchanged planned qty must consume NOTHING
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M67"
|
||||
TARGET = 10
|
||||
RAW_QPB = 5 # 5 raw per batch -> 50 base units at target 10
|
||||
UNIT_COST = 3.0
|
||||
SEED = 4000
|
||||
|
||||
|
||||
def on_hand(c, item, wh):
|
||||
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||
|
||||
|
||||
def ledger(c, run_id, source):
|
||||
return c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||
|
||||
|
||||
def prod_reason(c, code):
|
||||
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
|
||||
if r["code"] == code:
|
||||
return r["reasonCodeId"]
|
||||
sys.exit(f"FATAL: Production reason {code} not seeded.")
|
||||
|
||||
|
||||
def ensure_template(c, raw, finished, uom):
|
||||
"""Cut (entry) -> Assemble (terminal). Cut has the stock input we return leftovers from."""
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE, "name": "Smoke M6/M7 line",
|
||||
"stages": [
|
||||
{"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": RAW_QPB}],
|
||||
"outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]},
|
||||
{"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "uomId": uom, "qtyPerBatch": 1}],
|
||||
"outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished,
|
||||
"uomId": uom, "qtyPerBatch": 1}]},
|
||||
],
|
||||
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||
}
|
||||
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_CODE), None)
|
||||
if existing:
|
||||
h = c.get(f"/production-templates/{existing['templateId']}")
|
||||
r = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=h.etag)
|
||||
if r.status in (200, 409):
|
||||
return existing["templateId"]
|
||||
sys.exit(f"FATAL: could not update the template: {r.status} {r.body}")
|
||||
r = c.post("/production-templates", payload)
|
||||
if r.status != 201:
|
||||
sys.exit(f"FATAL: could not create the template: {r.status} {r.body}")
|
||||
return r.body["templateId"]
|
||||
|
||||
|
||||
def new_run(c, tid, wh):
|
||||
r = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET, "warehouseId": wh})
|
||||
if r.status != 201:
|
||||
sys.exit(f"FATAL: could not create a run: {r.status} {r.body}")
|
||||
cut = next(s for s in r.body["stages"] if s["name"] == "Cut")
|
||||
asm = next(s for s in r.body["stages"] if s["name"] == "Assemble")
|
||||
return r.body, cut, asm
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
raw, finished = items[0]["itemId"], items[1]["itemId"]
|
||||
uom = items[0]["baseUomId"]
|
||||
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(raw, uom, SEED, UNIT_COST)])
|
||||
tid = ensure_template(c, raw, finished, uom)
|
||||
consumed_units = RAW_QPB * TARGET # 50
|
||||
consumed_value = consumed_units * UNIT_COST # 150.00
|
||||
print(f"warehouse={wh} raw={raw} unitCost={UNIT_COST} consumesPerRun={consumed_units}")
|
||||
|
||||
# =====================================================================
|
||||
# M6 — leftover return
|
||||
# =====================================================================
|
||||
chk.section("M6-1. Partial leftover return at the consumed weighted cost (FR-MFG-14)")
|
||||
run, cut, asm = new_run(c, tid, wh)
|
||||
rid = run["runId"]
|
||||
cut_in = cut["inputs"][0]["runInputId"]
|
||||
|
||||
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/start")
|
||||
before = on_hand(c, raw, wh)
|
||||
pool0 = float(c.get(f"/production-runs/{rid}").body["costPool"]["net"])
|
||||
chk.check("cost pool after start", pool0, consumed_value)
|
||||
|
||||
ret = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 5, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
|
||||
chk.status("return 5 of 50 consumed", ret, 200)
|
||||
if ret.status == 200:
|
||||
chk.check("returnedQty echoed", float(ret.body["returnedQty"]), 5.0)
|
||||
chk.check("returned at the consumed weighted cost",
|
||||
float(ret.body["createdLayer"]["unitCost"]), UNIT_COST)
|
||||
chk.check("returnedValue = qty x weighted cost", float(ret.body["returnedValue"]), 15.0)
|
||||
chk.check("layer id populated", ret.body["createdLayer"]["layerId"] > 0, True)
|
||||
chk.check("pool reduced by the returned value",
|
||||
float(ret.body["costPool"]["net"]), consumed_value - 15.0)
|
||||
chk.check("on-hand rose by the returned qty", on_hand(c, raw, wh), before + 5.0)
|
||||
|
||||
prdl = ledger(c, rid, "PRDL")
|
||||
chk.check("one PRDL row", len(prdl), 1)
|
||||
if prdl:
|
||||
chk.check("PRDL direction is In", prdl[0]["direction"], "In")
|
||||
chk.check("PRDL has no bin (raw material, not the output bin)", prdl[0]["binId"], None)
|
||||
|
||||
chk.section("M6-2. Guards")
|
||||
chk.status("return more than remains",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 46, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||
422, "LEFTOVER_EXCEEDS_CONSUMED")
|
||||
chk.status("return with no reason code",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", {"qty": 1}),
|
||||
400, "REASON_CODE_REQUIRED")
|
||||
adj_reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
|
||||
chk.status("return with an Adjustment-context reason",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 1, "reasonCodeId": adj_reason}),
|
||||
422)
|
||||
chk.status("return against an upstream (WIP) input",
|
||||
c.post(f"/production-runs/{rid}/inputs/{asm['inputs'][0]['runInputId']}/return-leftover",
|
||||
{"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||
422)
|
||||
|
||||
chk.section("M6-3. A FULL return must net the input to exactly zero")
|
||||
rest = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 45, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
|
||||
chk.status("return the remaining 45", rest, 200)
|
||||
if rest.status == 200:
|
||||
chk.check("pool is exactly zero after a full return", float(rest.body["costPool"]["net"]), 0.0)
|
||||
detail = c.get(f"/production-runs/{rid}").body
|
||||
ci = detail["stages"][0]["inputs"][0]
|
||||
chk.check("returnedQty == consumedQty exactly",
|
||||
float(ci["returnedQty"]), float(ci["consumedQty"]))
|
||||
chk.check("returnedValue == consumedValue exactly",
|
||||
float(ci["returnedValue"]), float(ci["consumedValue"]))
|
||||
|
||||
# Close this run out so the RUN_COST_CLOSED guard can be checked. Each step is asserted:
|
||||
# letting an intermediate call fail silently here previously made the *next* assertion
|
||||
# look like the bug.
|
||||
cut_out = next(s for s in detail["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
|
||||
chk.status("close-out: complete Cut",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
|
||||
chk.status("close-out: approve Cut",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/approve"), 200)
|
||||
asm_out = next(s for s in c.get(f"/production-runs/{rid}").body["stages"]
|
||||
if s["name"] == "Assemble")["outputs"][0]["runOutputId"]
|
||||
chk.status("close-out: start Assemble",
|
||||
c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/start"), 200)
|
||||
chk.status("close-out: complete Assemble",
|
||||
c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": asm_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
|
||||
fin = c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/approve")
|
||||
chk.status("close-out: approve the terminal", fin, 200)
|
||||
if fin.status == 200:
|
||||
chk.check("run completed even though the pool was fully returned (cost 0)",
|
||||
fin.body["runStatus"], "Completed")
|
||||
chk.check("zero-cost receipt still creates a layer", fin.body["receipt"]["layerId"] > 0, True)
|
||||
chk.status("return a leftover after the receipt closed the pool",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||
409, "RUN_COST_CLOSED")
|
||||
|
||||
# =====================================================================
|
||||
# M7a — reject-intake
|
||||
# =====================================================================
|
||||
chk.section("M7-1. Reject-intake pulls work back to the parent (FR-MFG-15)")
|
||||
run2, cut2, asm2 = new_run(c, tid, wh)
|
||||
rid2 = run2["runId"]
|
||||
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
|
||||
d2 = c.get(f"/production-runs/{rid2}").body
|
||||
cut2_out = next(s for s in d2["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
|
||||
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||
start_at = next(s for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||
if s["name"] == "Cut")["actualStartAt"]
|
||||
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")
|
||||
|
||||
after_approve = c.get(f"/production-runs/{rid2}").body
|
||||
a_cut = next(s for s in after_approve["stages"] if s["name"] == "Cut")
|
||||
a_asm = next(s for s in after_approve["stages"] if s["name"] == "Assemble")
|
||||
chk.check("child Ready before the reject", a_asm["status"], "Ready")
|
||||
chk.check("parent transferred the full quantity",
|
||||
float(a_cut["outputs"][0]["transferredQty"]), float(TARGET))
|
||||
|
||||
rej = c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake",
|
||||
{"note": "Frames warped"})
|
||||
chk.status("reject-intake on the Ready child", rej, 200)
|
||||
if rej.status == 200:
|
||||
chk.check("rejecting stage back to Waiting", rej.body["status"], "Waiting")
|
||||
chk.check("one parent pulled back", len(rej.body["pulledBack"]), 1)
|
||||
pb = rej.body["pulledBack"][0]
|
||||
chk.check("pulled-back qty", float(pb["qty"]), float(TARGET))
|
||||
chk.check("parent was Approved", pb["priorParentStatus"], "Approved")
|
||||
chk.check("parent reverted to InProgress", pb["parentStatus"], "InProgress")
|
||||
|
||||
r_cut = next(s for s in rej.body["run"]["stages"] if s["name"] == "Cut")
|
||||
r_asm = next(s for s in rej.body["run"]["stages"] if s["name"] == "Assemble")
|
||||
chk.check("parent transferredQty decremented to 0",
|
||||
float(r_cut["outputs"][0]["transferredQty"]), 0.0)
|
||||
chk.check("parent available to transfer restored",
|
||||
float(r_cut["outputs"][0]["availableToTransfer"]), float(TARGET))
|
||||
chk.check("child deliveredQty cleared", float(r_asm["inputs"][0]["deliveredQty"]), 0.0)
|
||||
chk.check("parent ActualStartAt PRESERVED (FR-MFG-19)", r_cut["actualStartAt"], start_at)
|
||||
chk.check("parent ActualEndAt cleared for rework", r_cut["actualEndAt"], None)
|
||||
chk.check("consumed stock stays consumed",
|
||||
float(r_cut["inputs"][0]["consumedQty"]), float(consumed_units))
|
||||
|
||||
chk.status("reject-intake again with nothing delivered",
|
||||
c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake", {}),
|
||||
409, "STAGE_REJECT_INVALID")
|
||||
|
||||
chk.section("M7-2. Re-complete OVERWRITES rather than accumulating")
|
||||
re_done = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||
chk.status("re-complete the parent", re_done, 200)
|
||||
if re_done.status == 200:
|
||||
chk.check("producedQty overwritten, not doubled",
|
||||
float(re_done.body["outputs"][0]["producedQty"]), float(TARGET))
|
||||
re_app = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")
|
||||
chk.status("re-approve the parent", re_app, 200)
|
||||
if re_app.status == 200:
|
||||
chk.check("child Ready again",
|
||||
next(s for s in re_app.body["stage"]["outputs"]
|
||||
for _ in [0])["transferredQty"] is not None, True)
|
||||
|
||||
# =====================================================================
|
||||
# M7b — terminal reject
|
||||
# =====================================================================
|
||||
chk.section("M7-3. Terminal reject resets the run for a rework pass (FR-MFG-16)")
|
||||
d3 = c.get(f"/production-runs/{rid2}").body
|
||||
asm3 = next(s for s in d3["stages"] if s["name"] == "Assemble")
|
||||
asm3_out = asm3["outputs"][0]["runOutputId"]
|
||||
pool_before = float(d3["costPool"]["net"])
|
||||
|
||||
c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/start")
|
||||
c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": asm3_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||
|
||||
trj = c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/reject",
|
||||
{"note": "Final QA failed batch"})
|
||||
chk.status("terminal reject", trj, 200)
|
||||
if trj.status == 200:
|
||||
chk.check("reworkCount incremented", trj.body["reworkCount"], 1)
|
||||
run_after = trj.body["run"]
|
||||
chk.check("run still InProgress", run_after["status"], "InProgress")
|
||||
chk.check("completedAt still null", run_after["completedAt"], None)
|
||||
|
||||
t_cut = next(s for s in run_after["stages"] if s["name"] == "Cut")
|
||||
t_asm = next(s for s in run_after["stages"] if s["name"] == "Assemble")
|
||||
chk.check("entry stage reset to Ready", t_cut["status"], "Ready")
|
||||
chk.check("non-entry stage reset to Waiting", t_asm["status"], "Waiting")
|
||||
chk.check("timings cleared", (t_cut["actualStartAt"], t_cut["actualEndAt"]), (None, None))
|
||||
chk.check("fieldValues cleared", t_cut["fieldValues"], None)
|
||||
chk.check("producedQty cleared", float(t_cut["outputs"][0]["producedQty"]), 0.0)
|
||||
chk.check("transferredQty cleared", float(t_cut["outputs"][0]["transferredQty"]), 0.0)
|
||||
chk.check("deliveredQty cleared", float(t_asm["inputs"][0]["deliveredQty"]), 0.0)
|
||||
|
||||
# The load-bearing half of FR-MFG-16.
|
||||
chk.check("plannedQty PRESERVED", float(t_cut["inputs"][0]["plannedQty"]), float(consumed_units))
|
||||
chk.check("consumedQty PRESERVED", float(t_cut["inputs"][0]["consumedQty"]), float(consumed_units))
|
||||
chk.check("cost pool PRESERVED across the rework", float(run_after["costPool"]["net"]), pool_before)
|
||||
|
||||
snap = [e for e in run_after["events"] if e["eventType"] == "TerminalReject"]
|
||||
chk.check("exactly one snapshot event", len(snap), 1)
|
||||
if snap:
|
||||
chk.check("snapshot records the rework number", snap[0]["payload"]["reworkNumber"], 1)
|
||||
chk.check("snapshot captured every stage", len(snap[0]["payload"]["stages"]), 2)
|
||||
chk.check("snapshot kept the pre-reset produced figure",
|
||||
any(float(o["producedQty"]) == TARGET
|
||||
for s in snap[0]["payload"]["stages"] for o in s["outputs"]), True)
|
||||
chk.check("reject note recorded", snap[0]["note"], "Final QA failed batch")
|
||||
|
||||
# This is the single behavioural rule that makes FR-MFG-16 work: a start always consumes
|
||||
# max(0, plannedBase - consumedQty), never the full planned figure. After a rework the
|
||||
# material is still in the pool, so re-consuming it would double-charge the run.
|
||||
chk.section("M7-4. Rework restart after RAISING the planned qty consumes only the delta")
|
||||
cut2_in = next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||
for i in s["inputs"] if s["name"] == "Cut")["runInputId"]
|
||||
raised = consumed_units + 10
|
||||
chk.status("raise the planned qty on the reset (Ready) stage",
|
||||
c.put(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/quantities",
|
||||
{"inputs": [{"id": cut2_in, "plannedQty": raised}], "outputs": []}), 200)
|
||||
|
||||
oh_before = on_hand(c, raw, wh)
|
||||
pool_pre = float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"])
|
||||
restart = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
|
||||
chk.status("restart after the raise", restart, 200)
|
||||
if restart.status == 200:
|
||||
chk.check("consumed ONLY the 10-unit delta, not the full 60",
|
||||
float(restart.body["consumed"][0]["qty"]), 10.0)
|
||||
chk.check("on-hand fell by only the delta", on_hand(c, raw, wh), oh_before - 10.0)
|
||||
chk.check("consumedQty accumulated to the new planned total",
|
||||
float(next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||
for i in s["inputs"] if i["runInputId"] == cut2_in)["consumedQty"]),
|
||||
float(raised))
|
||||
chk.check("pool grew by only the delta's value",
|
||||
float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"]),
|
||||
pool_pre + 10.0 * UNIT_COST)
|
||||
|
||||
chk.section("M7-5. A second rework, restarted UNCHANGED, consumes nothing at all")
|
||||
# Drive the run round again to get the entry stage back to Ready with consumed == planned.
|
||||
d5 = c.get(f"/production-runs/{rid2}").body
|
||||
c5 = next(s for s in d5["stages"] if s["name"] == "Cut")
|
||||
a5 = next(s for s in d5["stages"] if s["name"] == "Assemble")
|
||||
c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": c5["outputs"][0]["runOutputId"],
|
||||
"producedQty": TARGET, "scrappedQty": 0}]})
|
||||
c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/approve")
|
||||
c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/start")
|
||||
c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": a5["outputs"][0]["runOutputId"],
|
||||
"producedQty": TARGET, "scrappedQty": 0}]})
|
||||
second = c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/reject", {"note": "again"})
|
||||
chk.status("second terminal reject", second, 200)
|
||||
if second.status == 200:
|
||||
chk.check("reworkCount now 2", second.body["reworkCount"], 2)
|
||||
chk.check("two snapshot events retained",
|
||||
sum(1 for e in second.body["run"]["events"] if e["eventType"] == "TerminalReject"), 2)
|
||||
|
||||
oh2 = on_hand(c, raw, wh)
|
||||
prdi2 = len(ledger(c, rid2, "PRDI"))
|
||||
again = c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/start")
|
||||
chk.status("restart with planned unchanged", again, 200)
|
||||
if again.status == 200:
|
||||
chk.check("nothing consumed (delta is zero)", len(again.body["consumed"]), 0)
|
||||
chk.check("on-hand unchanged", on_hand(c, raw, wh), oh2)
|
||||
chk.check("no new PRDI ledger row", len(ledger(c, rid2, "PRDI")), prdi2)
|
||||
|
||||
chk.section("M7-6. Cancel returns net consumed stock (FR-MFG-17)")
|
||||
run4, cut4, _ = new_run(c, tid, wh)
|
||||
rid4 = run4["runId"]
|
||||
in4 = cut4["inputs"][0]["runInputId"]
|
||||
c.post(f"/production-runs/{rid4}/stages/{cut4['runStageId']}/start")
|
||||
first = float(next(i for s in c.get(f"/production-runs/{rid4}").body["stages"]
|
||||
for i in s["inputs"] if i["runInputId"] == in4)["consumedQty"])
|
||||
chk.check("first start consumed the full planned qty", first, float(consumed_units))
|
||||
|
||||
# =====================================================================
|
||||
# M7c — cancel
|
||||
# =====================================================================
|
||||
oh_pre_cancel = on_hand(c, raw, wh)
|
||||
cancelled = c.post(f"/production-runs/{rid4}/cancel",
|
||||
{"reasonCodeId": prod_reason(c, "PRD-CANCEL"), "note": "Order cancelled"})
|
||||
chk.status("cancel the run", cancelled, 200)
|
||||
if cancelled.status == 200:
|
||||
chk.check("run Cancelled", cancelled.body["status"], "Cancelled")
|
||||
chk.check("one return posted", len(cancelled.body["returns"]), 1)
|
||||
r0 = cancelled.body["returns"][0]
|
||||
chk.check("returned the net consumed qty", float(r0["qty"]), float(consumed_units))
|
||||
chk.check("returned at the consumed weighted cost", float(r0["unitCost"]), UNIT_COST)
|
||||
chk.check("layer id populated", r0["layerId"] > 0, True)
|
||||
chk.check("ledgerRefs populated", len(cancelled.body["ledgerRefs"]), 1)
|
||||
chk.check("on-hand restored", on_hand(c, raw, wh), oh_pre_cancel + consumed_units)
|
||||
|
||||
prdc = ledger(c, rid4, "PRDC")
|
||||
chk.check("one PRDC row", len(prdc), 1)
|
||||
if prdc:
|
||||
chk.check("PRDC direction is In", prdc[0]["direction"], "In")
|
||||
chk.check("PRDC value = the exact consumed residual",
|
||||
float(prdc[0]["value"]), consumed_units * UNIT_COST)
|
||||
|
||||
after_cancel = c.get(f"/production-runs/{rid4}").body
|
||||
chk.check("cancel reason recorded", after_cancel["cancelReasonCodeId"] is not None, True)
|
||||
chk.check("completedAt stays null on a cancel", after_cancel["completedAt"], None)
|
||||
chk.check("pool nets to zero after the cancel return",
|
||||
float(after_cancel["costPool"]["net"]), 0.0)
|
||||
chk.check("cancel event logged",
|
||||
any(e["eventType"] == "Cancel" for e in after_cancel["events"]), True)
|
||||
|
||||
chk.section("M7-7. Cancel guards")
|
||||
chk.status("cancel an already-cancelled run",
|
||||
c.post(f"/production-runs/{rid4}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
|
||||
409, "RUN_NOT_CANCELLABLE")
|
||||
chk.status("cancel a COMPLETED run",
|
||||
c.post(f"/production-runs/{rid}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
|
||||
409, "RUN_NOT_CANCELLABLE")
|
||||
run5, _, _ = new_run(c, tid, wh)
|
||||
chk.status("cancel with no reason code",
|
||||
c.post(f"/production-runs/{run5['runId']}/cancel", {}), 400, "REASON_CODE_REQUIRED")
|
||||
|
||||
return chk.finish("M6+M7")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Run the whole manufacturing smoke suite in dependency order.
|
||||
|
||||
python Backend/smoke/run_all.py
|
||||
|
||||
Order matters: m4 creates the isolated SMOKE-PRD warehouse and drains it, m5 consumes the
|
||||
run m4 leaves with its terminal stage InProgress. Each script is individually re-runnable,
|
||||
but m5 deliberately refuses to run twice against an already-approved terminal stage — that
|
||||
guard is what stops it silently asserting against the wrong state.
|
||||
|
||||
Prerequisites: ERPCore on :5224 and AuthHex on :5602 (override with ERP_SMOKE_API /
|
||||
ERP_SMOKE_AUTH / ERP_SMOKE_USER / ERP_SMOKE_PASSWORD).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = [
|
||||
("M2 templates + graph validation", "m2_templates.py"),
|
||||
("M3 run creation / board / quantities", "m3_runs.py"),
|
||||
("M4 stage start / complete / approve / transfer", "m4_stage_actions.py"),
|
||||
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
||||
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
|
||||
]
|
||||
|
||||
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
here = Path(__file__).parent
|
||||
results = []
|
||||
failed = False
|
||||
|
||||
for label, script in SCRIPTS:
|
||||
print(f"\n{'=' * 70}\n{label} ({script})\n{'=' * 70}", flush=True)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, script, *sys.argv[1:]],
|
||||
cwd=here, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
sys.stdout.write(proc.stdout)
|
||||
if proc.stderr.strip():
|
||||
sys.stderr.write(proc.stderr)
|
||||
|
||||
passed = total = 0
|
||||
for line in proc.stdout.splitlines():
|
||||
m = SUMMARY.match(line.strip())
|
||||
if m:
|
||||
passed, total = int(m.group(2)), int(m.group(3))
|
||||
results.append((label, passed, total, proc.returncode))
|
||||
if proc.returncode != 0:
|
||||
failed = True
|
||||
|
||||
print(f"\n{'=' * 70}\nSUITE SUMMARY\n{'=' * 70}")
|
||||
grand_passed = grand_total = 0
|
||||
for label, passed, total, rc in results:
|
||||
grand_passed += passed
|
||||
grand_total += total
|
||||
state = "OK " if rc == 0 else "FAIL"
|
||||
print(f" [{state}] {label:<48} {passed}/{total}")
|
||||
print(f"\n TOTAL: {grand_passed}/{grand_total} assertions"
|
||||
+ (" — ALL GREEN" if not failed else " (SUITE FAILED)"))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Shared harness for the manufacturing smoke tests (docs/30-BACKEND-PHASE2.md).
|
||||
|
||||
The repo has no test project; verification is a live smoke test against local Postgres
|
||||
with a real AuthHex session, with the assertion count recorded in Backend/PROGRESS.md.
|
||||
These scripts make that repeatable instead of ad-hoc curl.
|
||||
|
||||
Auth note: ERPCore validates AuthHex's RS256 tokens offline against a statically
|
||||
configured public key, so we log in to AuthHex *directly* and send the access token as a
|
||||
Bearer header. That deliberately bypasses ERPCore's own /auth/login proxy, which would
|
||||
otherwise need AuthHex:BaseUrl to match the port AuthHex actually listens on.
|
||||
|
||||
Usage:
|
||||
python m2_templates.py [--api URL] [--auth URL] [--user EMAIL] [--password PW]
|
||||
|
||||
Environment variables (ERP_SMOKE_API, ERP_SMOKE_AUTH, ERP_SMOKE_USER,
|
||||
ERP_SMOKE_PASSWORD) override the defaults; command-line flags override those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# Server messages and box-drawing output contain non-cp1252 characters, and the default
|
||||
# Windows console codepage would raise UnicodeEncodeError mid-report — losing exactly the
|
||||
# diagnostic text a failing assertion needs to show.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
DEFAULTS = {
|
||||
"api": "http://localhost:5224/api/v1",
|
||||
"auth": "http://localhost:5602",
|
||||
"user": "admin@gmail.com",
|
||||
"password": "Naveen@99",
|
||||
}
|
||||
|
||||
|
||||
def parse_args(description: str) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=description)
|
||||
for key, default in DEFAULTS.items():
|
||||
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
class Response:
|
||||
__slots__ = ("status", "body", "headers")
|
||||
|
||||
def __init__(self, status: int, body, headers: dict):
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.headers = headers
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
"""The RFC 7807 domain error code, when the body carries one (docs/11 §1.8)."""
|
||||
return self.body.get("code") if isinstance(self.body, dict) else None
|
||||
|
||||
@property
|
||||
def etag(self):
|
||||
return self.headers.get("ETag")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.status} code={self.code}>"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, api: str, token: str):
|
||||
self.api = api.rstrip("/")
|
||||
self.token = token
|
||||
|
||||
def request(self, method: str, path: str, body=None, if_match: str | None = None,
|
||||
idempotency_key: str | None = None) -> Response:
|
||||
url = f"{self.api}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("Authorization", f"Bearer {self.token}")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if if_match:
|
||||
req.add_header("If-Match", if_match)
|
||||
if idempotency_key:
|
||||
req.add_header("Idempotency-Key", idempotency_key)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
raw = r.read()
|
||||
return Response(r.status, _decode(raw), dict(r.headers))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
return Response(e.code, _decode(raw), dict(e.headers))
|
||||
|
||||
def get(self, path):
|
||||
return self.request("GET", path)
|
||||
|
||||
def post(self, path, body=None, **kw):
|
||||
return self.request("POST", path, body, **kw)
|
||||
|
||||
def put(self, path, body=None, **kw):
|
||||
return self.request("PUT", path, body, **kw)
|
||||
|
||||
def patch(self, path, body=None, **kw):
|
||||
return self.request("PATCH", path, body, **kw)
|
||||
|
||||
|
||||
def _decode(raw: bytes):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw.decode(errors="replace")
|
||||
|
||||
|
||||
def login(auth_url: str, identifier: str, password: str) -> str:
|
||||
"""Obtain an AuthHex access token via its `{functionName, payload}` envelope."""
|
||||
payload = {
|
||||
"functionName": "loginUser",
|
||||
"payload": {"identifier": identifier, "password": password, "deviceName": "erp-smoke"},
|
||||
"reference": "",
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{auth_url.rstrip('/')}/api/user",
|
||||
data=json.dumps(payload).encode(),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
envelope = json.loads(r.read())
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit(f"FATAL: cannot reach AuthHex at {auth_url} ({e}). Start ERP_Auth_Service first.")
|
||||
|
||||
if not envelope.get("success") or not envelope.get("data"):
|
||||
sys.exit(f"FATAL: AuthHex login failed: {envelope.get('message')!r}")
|
||||
return envelope["data"]["accessToken"]
|
||||
|
||||
|
||||
class Checker:
|
||||
"""Counts assertions so the pass total can be recorded in PROGRESS.md."""
|
||||
|
||||
def __init__(self):
|
||||
self.passed = 0
|
||||
self.failed = 0
|
||||
|
||||
def check(self, label: str, actual, expected) -> bool:
|
||||
ok = actual == expected
|
||||
if ok:
|
||||
self.passed += 1
|
||||
print(f" PASS {label}")
|
||||
else:
|
||||
self.failed += 1
|
||||
print(f" FAIL {label}\n expected: {expected!r}\n actual: {actual!r}")
|
||||
return ok
|
||||
|
||||
def status(self, label: str, response: Response, expected_status: int, expected_code: str | None = None):
|
||||
ok = self.check(f"{label} -> {expected_status}", response.status, expected_status)
|
||||
if expected_code is not None:
|
||||
ok = self.check(f"{label} -> code {expected_code}", response.code, expected_code) and ok
|
||||
if not ok and response.status >= 400:
|
||||
detail = response.body.get("detail") if isinstance(response.body, dict) else response.body
|
||||
print(f" server said: {detail}")
|
||||
return ok
|
||||
|
||||
def section(self, title: str):
|
||||
print(f"\n--- {title} ---")
|
||||
|
||||
def finish(self, name: str) -> int:
|
||||
total = self.passed + self.failed
|
||||
print(f"\n{'=' * 60}\n{name}: {self.passed}/{total} assertions passed"
|
||||
+ (f" ({self.failed} FAILED)" if self.failed else " — ALL GREEN")
|
||||
+ f"\n{'=' * 60}")
|
||||
return 1 if self.failed else 0
|
||||
|
||||
|
||||
def bootstrap(description: str):
|
||||
"""Standard entry point: parse args, log in, return (client, checker, args)."""
|
||||
args = parse_args(description)
|
||||
token = login(args.auth, args.user, args.password)
|
||||
return Client(args.api, token), Checker(), args
|
||||
|
||||
|
||||
# --- stock fixtures ----------------------------------------------------------
|
||||
#
|
||||
# Seeding matters more than it looks. A positive stock ADJUSTMENT is the obvious way to
|
||||
# create on-hand, but StockMutator's inbound path costs it at *last cost*, which is 0.00
|
||||
# when the item has no prior layers. Stock seeded that way makes every cost-pool assertion
|
||||
# pass trivially against zeros and proves nothing. A direct (no-PO) GRN lets us state the
|
||||
# unit cost explicitly, so consumption produces a real, checkable value.
|
||||
|
||||
|
||||
def drain_stock(c, warehouse_id: int) -> list:
|
||||
"""
|
||||
Zero out every item's on-hand in a warehouse via one negative adjustment.
|
||||
|
||||
Needed because these scripts are re-runnable and FIFO is oldest-first: stock left behind
|
||||
by a previous execution is consumed *before* anything seeded now. If an earlier run left
|
||||
zero-cost layers (as an adjustment-based seed does), a later run's cost assertions would
|
||||
silently read 0.00 and pass against nothing. Draining first makes each execution start
|
||||
from a known-empty warehouse.
|
||||
"""
|
||||
rows = c.get(f"/stock/on-hand/list?warehouseId={warehouse_id}&pageSize=200").body["items"]
|
||||
lines = [{"itemId": r["itemId"], "qtyDelta": -float(r["onHand"])}
|
||||
for r in rows if float(r["onHand"]) > 0]
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
reasons = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"]
|
||||
if not reasons:
|
||||
sys.exit("FATAL: no Adjustment reason codes seeded.")
|
||||
|
||||
res = c.post("/stock-adjustments", {
|
||||
"warehouseId": warehouse_id,
|
||||
"reasonCodeId": reasons[0]["reasonCodeId"],
|
||||
"lines": lines,
|
||||
})
|
||||
if res.status != 201:
|
||||
sys.exit(f"FATAL: could not drain the smoke warehouse: {res.status} {res.body}")
|
||||
return lines
|
||||
|
||||
|
||||
def ensure_vendor(c) -> int:
|
||||
existing = c.get("/vendors?pageSize=1").body["items"]
|
||||
if existing:
|
||||
return existing[0]["vendorId"]
|
||||
created = c.post("/vendors", {"code": "SMOKE-V", "name": "Smoke vendor"})
|
||||
if created.status != 201:
|
||||
sys.exit(f"FATAL: could not create a vendor: {created.status} {created.body}")
|
||||
return created.body["vendorId"]
|
||||
|
||||
|
||||
def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None) -> None:
|
||||
"""
|
||||
Create on-hand at explicit unit costs via a direct GRN + confirm.
|
||||
|
||||
`lines` is an iterable of (item_id, uom_id, qty, unit_cost).
|
||||
"""
|
||||
vendor_id = vendor_id or ensure_vendor(c)
|
||||
grn = c.post("/grns", {
|
||||
"vendorId": vendor_id,
|
||||
"warehouseId": warehouse_id,
|
||||
"lines": [
|
||||
{"itemId": i, "uomId": u, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
|
||||
for (i, u, q, cost) in lines
|
||||
],
|
||||
})
|
||||
if grn.status != 201:
|
||||
sys.exit(f"FATAL: could not create the seeding GRN: {grn.status} {grn.body}")
|
||||
|
||||
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
|
||||
if confirmed.status != 200:
|
||||
sys.exit(f"FATAL: could not confirm the seeding GRN: {confirmed.status} {confirmed.body}")
|
||||
@@ -115,6 +115,60 @@ Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md`
|
||||
- **Leave reject uses a native `window.prompt`** instead of a dialog — functionally correct, but a lower-fidelity UX than the rest of the app's dialog-based patterns.
|
||||
- **A pre-existing, unrelated syntax error in `app/dashboard/receiving/grn/new/page.tsx`** (unclosed JSX, last touched 2026-07-23 before this HRM pass started) blocks a clean whole-project `tsc --noEmit` run. Not introduced by this work and not fixed by it — confirmed via `git status`/`git log` that this file was untouched this session; scoped `eslint`/`tsc` checks against every HRM file individually (and the fact this is the *only* file `tsc` reports) confirm the HRM additions themselves are clean.
|
||||
|
||||
---
|
||||
|
||||
# Manufacturing — Production Lines (Phase 2)
|
||||
|
||||
Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND-PHASE2.md` (§D.1–D.3). Validation posture: `docs/20-FRONTEND.md §3` — client checks are UX only.
|
||||
|
||||
> **Every production screen now runs on the real API. Both mock modules are deleted.** `npx tsc --noEmit` reports 0 errors in this module (the only 4 project errors are pre-existing HRM ones — see the note at the end of §13), and `npx next build` reports **"Compiled successfully"** before failing type-check on those same HRM files. **Nothing has been driven in a browser** — see the honesty note at the end.
|
||||
|
||||
## 11. Contract layer (F1) — DONE
|
||||
- [x] `types/production.ts` **fully rewritten** against docs/30 Part D — every request/response DTO, all six enums, and the stage-action result shapes. Replaces the frontend-only placeholder shapes entirely
|
||||
- [x] Three contract corrections carried through: templates are keyed by **`code`** (not `docNo` — only runs get a document number); quantities use **`itemId`/`uomId`/`qtyPerBatch`** numeric FKs (not free-text uom/qty); stages carry **`posX`/`posY`**, so canvas layout round-trips through the server
|
||||
- [x] `lib/api/production-templates.ts` — list/get/create/update/updateStatus with ETag + `If-Match`
|
||||
- [x] `lib/api/production-runs.ts` — the full §D.3 surface (start, complete, approve, transfer, reject-intake, reject, return-leftover, cancel, quantities), every action taking an `idempotencyKey`; plus `isStaleStageError()` for the docs/21 §6 "409 on a stage-status code → refetch silently" rule
|
||||
- [x] `lib/error-map.ts` — all 17 docs/30 §D.4 codes. **Also fixed a real mechanism gap:** `errorMessage()` let any mapped domain code override the server's `detail`, which would have thrown away the specifics the user needs — the graph validator names the offending stages, and the transfer/leftover guards quote the actual figures. Added `DETAIL_PREFERRED_CODES` so those eight codes let `detail` win and keep their map entry as a fallback
|
||||
|
||||
## 12. Screens (F2–F5) — DONE
|
||||
- [x] **Template overview** (`app/dashboard/production/templates/page.tsx`) — real `productionTemplatesApi.list` with a 300 ms debounced search, status filter, pagination and real `activeRunCount`. One-row-per-template canvas labelled from live data
|
||||
- [x] **Template builder** (`templates/[id]/page.tsx`) — **fully rewired.** GETs the graph, holds the ETag, and the former `handleSave()` toast stub is now a real create/update. Node ids **are** the server's stage keys (`tmp-<uuid>` for stages drawn this session), so a PUT diffs stages in place and keeps historical runs linked; `node.position` persists as `posX`/`posY`; real `itemsApi`/`uomsApi` pickers replaced `MOCK_ITEMS`; `/templates/new` renders an unsaved draft seeded from the overview dialog's query params and swaps its URL on first save. Also gained a Deactivate/Activate control — `productionTemplatesApi.updateStatus` previously had no UI path at all
|
||||
- [x] **Run board** (`runs/page.tsx`) — real list with debounced doc-no search, template/warehouse/status filters and pagination. Start dialog posts `productionRunsApi.create` and **navigates to the run**
|
||||
- [x] **Run detail** (`runs/[id]/page.tsx`) — canvas built from the run's own `posX`/`posY` and run edges, with per-stage intake (`delivered/planned`) and available-to-transfer badges, live cost pool, and a `stageSummary` computed from real stage statuses
|
||||
- [x] **Stage drawer** (`runs/[id]/StageDrawer.tsx`) — the whole of docs/21 §5: per-status bodies (Waiting → explanation · Ready → editable planned quantities + Start · InProgress → produced/scrapped per output with a required Production reason + custom fields + Complete · Done non-terminal → approve with optional partial transfer · Done terminal → receipt preview + Approve & receive + Reject for rework · Approved → transfer remainder), reject-intake from Ready **or** Waiting-with-deliveries, and the per-stage event timeline
|
||||
- [x] **Runtime custom-field renderer** (`runs/[id]/CustomFieldForm.tsx`) — `fieldDefs` → typed inputs for all five types, plus `missingRequiredFields()` which **mirrors the server's rule exactly**, including the part that surprises people: an unchecked Checkbox counts as *provided* (`false`), so a required checkbox does not force a tick
|
||||
- [x] **Run-level actions** (`runs/[id]/RunActions.tsx`) — Return leftover (per consumed Stock input, showing consumed/returned/weighted cost, in base UOM and capped at the unreturned remainder) and Cancel run (previewing what goes back to stock). Both hidden once the run leaves InProgress, because `RUN_COST_CLOSED`/`RUN_NOT_CANCELLABLE` mean offering them could only produce an error
|
||||
- [x] `lib/production-status-colors.ts` kept untouched — it already matches docs/21 §3 exactly and is the single source for status colour everywhere
|
||||
- [x] **Deleted `lib/production-mock-runs.ts` and `lib/production-mock-templates.ts`**, including `buildStagePlan()`
|
||||
|
||||
**Deviations / decisions (recorded):**
|
||||
- **The drawer is one file, not the seven the plan sketched.** Each per-status panel is ~30 lines and they all share the same lookup helpers, `submit()` wrapper and error handling; splitting them would mean threading that shared context through seven prop lists for no isolation benefit. `CustomFieldForm` and `RunActions` *are* separate, because both stand alone and neither needs the drawer's form state.
|
||||
- **The board shows per-status counts, not named stages.** The list projection carries `stageSummary` only, so naming stages there would mean guessing which stage holds which count — exactly what the deleted `buildStagePlan()` did. Named per-stage state lives on the run detail, where the server actually returns it.
|
||||
- **Non-terminal outputs have their `itemId` stripped on save, not rejected.** A stage that *was* terminal and then gained a child keeps its picked item in local state with the field no longer rendered; an issue-list message about an invisible field would be unactionable, so the builder drops it silently (FR-MFG-05 forbids it on a WIP output anyway).
|
||||
- **The terminal receipt preview is computed client-side.** There is no preview endpoint and every input (cost pool, produced, scrapped) is already on the page, so the drawer mirrors the server's arithmetic to show the layer *before* creating it. Preview only — the server recomputes.
|
||||
- **A status toggle re-reads the ETag.** `PATCH /status` bumps the row's `xmin`, invalidating the token the builder holds. It re-GETs and takes *only* the etag and status, deliberately not reloading the canvas, because a full reload there would silently discard unsaved edits.
|
||||
- **"New Template" opens an unsaved draft rather than creating immediately.** A template cannot exist without a valid graph — the server requires ≥1 stage and a terminal output naming a real item (FR-MFG-02/05) — so there is nothing sensible to POST from a name alone.
|
||||
- **`templateGraphToSaveRequest()` was deleted from `lib/api/production-templates.ts`.** It converted a fetched graph into a save payload, but the builder's canvas — not the last GET — is the source of truth for what gets saved, so it had no caller and would have drifted.
|
||||
|
||||
**Backend additions made for these screens** (all amended into docs/30 as built):
|
||||
- **`TemplateSummaryDto.stageNames`, in flow order.** The overview draws each template as a line left-to-right and needs the names for every row; without the field the client would fetch every template's full graph just to label boxes. Ordering by stage id turned out to be insertion order, which put the *terminal* stage first and drew lines backwards — so the server toposorts (Kahn, tie-broken by id for stability, falling back to id order if the graph is ever cyclic so a listing can't fail on bad data).
|
||||
- **`TemplateGraphDto.activeRunCount`.** The builder reads its edit-locked state straight off the graph; without it, it would need a second request to the list endpoint purely to know whether to disable itself.
|
||||
- **`production_templates.Annotations` (jsonb) + `SaveTemplateRequest.annotations`.** The canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so every save would have silently discarded the user's layout notes. Round-tripped verbatim, capped at 200 entries, `kind` validated to `box`/`line`, and invisible to the graph validator. Migration `AddTemplateCanvasAnnotations`. Note the flip side, pinned by a smoke assertion: replacement is wholesale, so a client that forgets to echo `annotations` back on a PUT clears them.
|
||||
|
||||
## 13. Validation posture (F6) — DONE
|
||||
- [x] Domain-code → message map complete (§11), with `detail` preferred where the server is more specific
|
||||
- [x] `412 CONCURRENCY_CONFLICT` amber conflict banner + Reload on the builder (the `app/dashboard/vendors/[id]/page.tsx` pattern)
|
||||
- [x] `409 TEMPLATE_IN_USE` edit-lock banner driven by the server. Two distinct messages: locked on load (`activeRunCount > 0`) versus locked *while editing* — the FR-MFG-06 TOCTOU, where a run starts between the GET and the PUT. The second locks the canvas rather than reloading, so nothing the user just drew disappears without an explanation
|
||||
- [x] `422 GRAPH_*` focuses the offending stage — a best-effort substring match of the server's `detail` against stage names, which is why the validator quotes them. Advisory by design: the full message is always in the banner too, so an ambiguous name costs a highlight, never the explanation
|
||||
- [x] `Idempotency-Key` per action (`useRef(crypto.randomUUID())`, re-minted after each success and whenever the drawer switches stage)
|
||||
- [x] Silent refetch on stage-status 409s — `submit()` in the drawer routes every action through `isStaleStageError()`
|
||||
|
||||
**Not done, flagged rather than silently skipped:**
|
||||
- **No browser verification of any production screen, and no live end-to-end run.** The contract layer is written against a backend whose every endpoint is smoke-verified, the tree type-checks and Turbopack compiles it, but **nothing has been clicked.** Blocked on AuthHex: its configured MySQL host (`187.127.102.190:3306`) is unreachable from this machine, so no token can be issued — which also means the backend smoke suite could not be re-run after this pass's backend additions.
|
||||
- **`components/Layouts/AppSidebar.tsx:351` still lists `"production"` in `bypassCodes`.** Correct for now — no role is seeded with a `NAV:production` permission, so removing the bypass would hide the section from everyone. Seeding that permission is the real fix (same outstanding item as `procurement`/`hrm`).
|
||||
- **4 pre-existing `tsc` errors, unrelated to this work — and they block `next build` for the whole app:** `hrm/employees/[id]/page.tsx` (`UpdateEmployeeRequest` missing `hireDate`) and three `hrm/settings/*` pages (an `Api<T>` generic expecting `{value}` where `ApiResult<T>` is returned). None of these files import anything added or changed by this pass, so they were left alone rather than fixed as a side effect of manufacturing work.
|
||||
- **10 `react-hooks/set-state-in-effect` lint errors across the five production files.** Same rule fires 42 times repo-wide (`app/dashboard/receiving/grn/page.tsx` included); these are the load effects, the hydration-mismatch guards and the builder's stale-upstream repair. No other rule fires in this module.
|
||||
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -269,6 +269,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{po.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -221,6 +221,9 @@ function NewPurchaseOrderContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1>
|
||||
<p className="text-base text-muted-foreground">Auto-approved on creation; freely editable while open (FR-PROC-03..05).</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {} from "lucide-react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
@@ -156,6 +156,9 @@ function NewPurchaseReturnContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Return</h1>
|
||||
<p className="text-base text-muted-foreground">Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { FileText, Send, ShoppingCart } from "lucide-react"
|
||||
import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -74,6 +74,9 @@ export default function RequisitionDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{requisition.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -103,6 +103,9 @@ export default function NewRequisitionPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Requisition</h1>
|
||||
<p className="text-base text-muted-foreground">Request items for procurement; submit once the lines are ready (FR-PROC-01).</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ShoppingCart } from "lucide-react"
|
||||
import { ArrowLeft, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -158,6 +158,9 @@ export default function RfqDetailPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -149,6 +149,9 @@ function NewRfqContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New RFQ</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { FieldDef } from "@/types/production"
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
/**
|
||||
* Runtime renderer for a stage's `fieldDefs` (FR-MFG-07) — the counterpart to the builder's
|
||||
* field *designer*. The template author picks the types; this turns them into real inputs at
|
||||
* complete-time and hands back the `fieldValues` object the server stores as jsonb.
|
||||
*
|
||||
* Values are keyed by `def.key`, exactly as the server expects, and are never coerced to a
|
||||
* different shape than the type implies: Number stays a number, Checkbox a boolean, everything
|
||||
* else a string. That matters because the stored jsonb is later read back verbatim.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Labels of required fields with no value yet.
|
||||
*
|
||||
* Mirrors the server's `ValidateRequiredFields` exactly, including the part that surprises
|
||||
* people: an unchecked Checkbox **is** a provided value (`false`), so a required checkbox does
|
||||
* not force a tick. Only absent, null and whitespace-only strings count as missing — diverging
|
||||
* here would either block a save the server would accept or let one through it rejects.
|
||||
*/
|
||||
export function missingRequiredFields(defs: FieldDef[], values: Record<string, unknown>): string[] {
|
||||
return defs
|
||||
.filter((def) => {
|
||||
if (!def.required) return false
|
||||
const value = values[def.key]
|
||||
if (value === undefined || value === null) return true
|
||||
return typeof value === "string" && value.trim() === ""
|
||||
})
|
||||
.map((def) => def.label)
|
||||
}
|
||||
|
||||
export function CustomFieldForm({
|
||||
defs,
|
||||
values,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
defs: FieldDef[]
|
||||
values: Record<string, unknown>
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
if (defs.length === 0) return null
|
||||
|
||||
function set(key: string, value: unknown) {
|
||||
onChange({ ...values, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{defs.map((def) => {
|
||||
const value = values[def.key]
|
||||
const label = (
|
||||
<FieldLabel htmlFor={`cf-${def.key}`}>
|
||||
{def.label}
|
||||
{def.required && <span className="ml-0.5 text-destructive">*</span>}
|
||||
</FieldLabel>
|
||||
)
|
||||
|
||||
if (def.type === "Checkbox") {
|
||||
return (
|
||||
<div key={def.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`cf-${def.key}`}
|
||||
checked={value === true}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => set(def.key, checked === true)}
|
||||
/>
|
||||
<label htmlFor={`cf-${def.key}`} className="text-sm text-foreground">
|
||||
{def.label}
|
||||
{def.required && <span className="ml-0.5 text-destructive">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (def.type === "Select") {
|
||||
return (
|
||||
<Field key={def.key}>
|
||||
{label}
|
||||
<Select<string>
|
||||
value={typeof value === "string" ? value : null}
|
||||
onValueChange={(v) => set(def.key, v ?? null)}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" disabled={disabled} id={`cf-${def.key}`}>
|
||||
<SelectValue placeholder="Choose…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(def.options ?? []).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-sm">
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={def.key}>
|
||||
{label}
|
||||
<Input
|
||||
id={`cf-${def.key}`}
|
||||
type={def.type === "Number" ? "number" : def.type === "Date" ? "date" : "text"}
|
||||
step={def.type === "Number" ? "any" : undefined}
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (def.type !== "Number") return set(def.key, raw)
|
||||
// An empty number box means "not answered", not zero — sending 0 would satisfy a
|
||||
// required check the operator never actually answered.
|
||||
set(def.key, raw === "" ? null : Number(raw))
|
||||
}}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { AlertTriangle, Ban, Undo2 } from "lucide-react"
|
||||
|
||||
import { productionRunsApi } from "@/lib/api/production-runs"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProductionRunGraph } from "@/types/production"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
/**
|
||||
* Run-level actions (docs/21-FRONTEND-PHASE2.md §5): returning leftover raw material to stock,
|
||||
* and cancelling the run. Both are hidden once the run leaves InProgress — a completed run's
|
||||
* costs are closed (`409 RUN_COST_CLOSED`) and a completed run cannot be cancelled
|
||||
* (`409 RUN_NOT_CANCELLABLE`), so offering either would only produce an error.
|
||||
*/
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number(n.toFixed(4)).toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
function money(n: number): string {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
interface Returnable {
|
||||
runInputId: number
|
||||
stageName: string
|
||||
itemId: number
|
||||
/** Base UOM, matching what the return endpoint expects. */
|
||||
remaining: number
|
||||
consumedQty: number
|
||||
consumedValue: number
|
||||
returnedQty: number
|
||||
returnedValue: number
|
||||
}
|
||||
|
||||
export function RunActions({
|
||||
run,
|
||||
items,
|
||||
reasonCodes,
|
||||
onActed,
|
||||
}: {
|
||||
run: ProductionRunGraph
|
||||
items: ItemListItem[]
|
||||
reasonCodes: ReasonCode[]
|
||||
onActed: () => void
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<null | "leftover" | "cancel">(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [selectedInputId, setSelectedInputId] = useState<number | null>(null)
|
||||
const [returnQty, setReturnQty] = useState("")
|
||||
const [returnReasonId, setReturnReasonId] = useState<number | null>(null)
|
||||
|
||||
const [cancelReasonId, setCancelReasonId] = useState<number | null>(null)
|
||||
const [cancelNote, setCancelNote] = useState("")
|
||||
|
||||
const itemName = useMemo(() => {
|
||||
const byId = new Map(items.map((i) => [i.itemId, i.name]))
|
||||
return (id: number) => byId.get(id) ?? `Item #${id}`
|
||||
}, [items])
|
||||
|
||||
/**
|
||||
* Stock inputs with material still unreturned. Upstream inputs are excluded: they carry work
|
||||
* in progress that never entered stock, so there is nothing to return.
|
||||
*/
|
||||
const returnable = useMemo<Returnable[]>(
|
||||
() =>
|
||||
run.stages.flatMap((stage) =>
|
||||
stage.inputs
|
||||
.filter((i) => i.source === "Stock" && i.itemId !== null && i.consumedQty - i.returnedQty > 0)
|
||||
.map((i) => ({
|
||||
runInputId: i.runInputId,
|
||||
stageName: stage.name,
|
||||
itemId: i.itemId!,
|
||||
remaining: i.consumedQty - i.returnedQty,
|
||||
consumedQty: i.consumedQty,
|
||||
consumedValue: i.consumedValue,
|
||||
returnedQty: i.returnedQty,
|
||||
returnedValue: i.returnedValue,
|
||||
}))
|
||||
),
|
||||
[run.stages]
|
||||
)
|
||||
|
||||
const selected = returnable.find((r) => r.runInputId === selectedInputId) ?? null
|
||||
const productionReasons = reasonCodes.filter((r) => r.context === "Production")
|
||||
|
||||
// Weighted average cost of what this input actually consumed — the rate the returned stock
|
||||
// goes back in at, and the same figure the server derives from consumedValue/consumedQty.
|
||||
const weightedCost = selected && selected.consumedQty > 0 ? selected.consumedValue / selected.consumedQty : null
|
||||
|
||||
function openLeftover() {
|
||||
setSelectedInputId(returnable[0]?.runInputId ?? null)
|
||||
setReturnQty("")
|
||||
setReturnReasonId(null)
|
||||
setError(null)
|
||||
setDialog("leftover")
|
||||
}
|
||||
|
||||
function openCancel() {
|
||||
setCancelReasonId(null)
|
||||
setCancelNote("")
|
||||
setError(null)
|
||||
setDialog("cancel")
|
||||
}
|
||||
|
||||
async function submit(action: () => Promise<unknown>) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await action()
|
||||
setDialog(null)
|
||||
onActed()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (run.status !== "InProgress") return null
|
||||
|
||||
const returnQtyNum = Number(returnQty)
|
||||
const returnValid =
|
||||
selected !== null &&
|
||||
returnReasonId !== null &&
|
||||
Number.isFinite(returnQtyNum) &&
|
||||
returnQtyNum > 0 &&
|
||||
returnQtyNum <= selected.remaining
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
|
||||
<Undo2 className="size-4" />
|
||||
Return leftover
|
||||
</Button>
|
||||
<Button variant="outline" className="text-destructive" onClick={openCancel}>
|
||||
<Ban className="size-4" />
|
||||
Cancel run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------- leftover */}
|
||||
<Dialog open={dialog === "leftover"} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Return leftover material</DialogTitle>
|
||||
<DialogDescription>
|
||||
Puts unused raw material back into stock at the cost it was consumed at, and takes it out of this
|
||||
run's cost pool.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Consumed material</FieldLabel>
|
||||
<Select<number> value={selectedInputId} onValueChange={(v) => { setSelectedInputId(v); setReturnQty("") }}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Pick a material" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{returnable.map((r) => (
|
||||
<SelectItem key={r.runInputId} value={r.runInputId} className="text-base">
|
||||
{itemName(r.itemId)} · {r.stageName} · {fmt(r.remaining)} left
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{selected && (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-border bg-muted/40 p-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Consumed</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{fmt(selected.consumedQty)} · {money(selected.consumedValue)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Already returned</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{fmt(selected.returnedQty)} · {money(selected.returnedValue)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Weighted cost</span>
|
||||
<span className="font-medium text-foreground">{weightedCost === null ? "—" : money(weightedCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="return-qty">
|
||||
Quantity to return
|
||||
{selected && <span className="font-normal text-muted-foreground"> (base UOM, max {fmt(selected.remaining)})</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="return-qty"
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
max={selected?.remaining}
|
||||
value={returnQty}
|
||||
onChange={(e) => setReturnQty(e.target.value)}
|
||||
placeholder={selected ? String(selected.remaining) : ""}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Reason</FieldLabel>
|
||||
<Select<number> value={returnReasonId} onValueChange={setReturnReasonId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Pick a reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{productionReasons.map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
|
||||
{r.code} — {r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setDialog(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !returnValid}
|
||||
onClick={() =>
|
||||
submit(() =>
|
||||
productionRunsApi.returnLeftover(run.runId, selected!.runInputId, {
|
||||
qty: returnQtyNum,
|
||||
reasonCodeId: returnReasonId!,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? "Returning…" : "Return to stock"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* --------------------------------------------------------------- cancel */}
|
||||
<Dialog open={dialog === "cancel"} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel this run?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Everything consumed and not yet returned goes back into stock at its consumed cost. Scrapped output is
|
||||
written off — it never entered stock. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{returnable.length > 0 && (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-border bg-muted/40 p-3 text-sm">
|
||||
<p className="font-medium text-foreground">Will be returned to stock</p>
|
||||
{returnable.map((r) => (
|
||||
<div key={r.runInputId} className="flex justify-between">
|
||||
<span className="min-w-0 truncate text-muted-foreground">{itemName(r.itemId)}</span>
|
||||
<span className="shrink-0 font-medium text-foreground">{fmt(r.remaining)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Reason</FieldLabel>
|
||||
<Select<number> value={cancelReasonId} onValueChange={setCancelReasonId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Pick a reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{productionReasons.map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
|
||||
{r.code} — {r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cancel-note">Note (optional)</FieldLabel>
|
||||
<Input id="cancel-note" value={cancelNote} onChange={(e) => setCancelNote(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setDialog(null)} disabled={busy}>
|
||||
Keep run
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={busy || cancelReasonId === null}
|
||||
onClick={() =>
|
||||
submit(() =>
|
||||
productionRunsApi.cancel(run.runId, {
|
||||
reasonCodeId: cancelReasonId!,
|
||||
note: cancelNote.trim() || null,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? "Cancelling…" : "Cancel run"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { AlertTriangle, Ban, CheckCircle2, PackageCheck, Play, Send, Undo2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isStaleStageError, productionRunsApi } from "@/lib/api/production-runs"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { CustomFieldForm, missingRequiredFields } from "./CustomFieldForm"
|
||||
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors"
|
||||
import {
|
||||
CompleteOutputLine,
|
||||
ProductionRunGraph,
|
||||
RunStage,
|
||||
RunStageInput,
|
||||
RunStageOutput,
|
||||
StageQuantityLine,
|
||||
TransferLine,
|
||||
} from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
|
||||
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
/**
|
||||
* The stage drawer (docs/21-FRONTEND-PHASE2.md §5) — the only place a run is actually driven.
|
||||
*
|
||||
* One component rather than the seven the plan sketched: each per-status panel is ~30 lines and
|
||||
* they all share the same lookup helpers, submit wrapper, and error handling, so splitting them
|
||||
* would mean threading that shared context through seven prop lists for no isolation benefit.
|
||||
*
|
||||
* The body is switched on `stage.status`, which is deliberately the *server's* status and never
|
||||
* a local guess — every action re-fetches the whole run through `onActed`, so what is rendered
|
||||
* is always what the server last said. Two consequences worth knowing:
|
||||
*
|
||||
* * A 409 carrying a stage-status code means someone else moved first. `submit` refreshes
|
||||
* silently instead of showing an error (docs/21 §6); that is also what makes the server's
|
||||
* accept-and-ignore `Idempotency-Key` posture feel right — a double-click just refreshes.
|
||||
* * Local form state is keyed off `stage.runStageId` and reset whenever the stage changes, so
|
||||
* a refresh mid-edit can never post figures from a stage the user is no longer looking at.
|
||||
*/
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number(n.toFixed(4)).toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
function money(n: number): string {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
/** Minutes between two instants, floored — the same unit the server reports `actualMinutes` in. */
|
||||
function minutesSince(iso: string, now: number): number {
|
||||
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: StageStatus }) {
|
||||
const color = STAGE_STATUS_COLOR[status]
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
style={{ backgroundColor: `${color}1a`, color }}
|
||||
>
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
{STAGE_STATUS_LABEL[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-right font-medium text-foreground">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StageDrawer({
|
||||
run,
|
||||
stage,
|
||||
items,
|
||||
uoms,
|
||||
reasonCodes,
|
||||
onClose,
|
||||
onActed,
|
||||
}: {
|
||||
run: ProductionRunGraph
|
||||
stage: RunStage | null
|
||||
items: ItemListItem[]
|
||||
uoms: Uom[]
|
||||
reasonCodes: ReasonCode[]
|
||||
onClose: () => void
|
||||
onActed: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Editable planned quantities (Ready only) keyed by run input/output id.
|
||||
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
|
||||
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
|
||||
|
||||
// Complete-form state.
|
||||
const [produced, setProduced] = useState<Record<number, string>>({})
|
||||
const [scrapped, setScrapped] = useState<Record<number, string>>({})
|
||||
const [scrapReason, setScrapReason] = useState<Record<number, number | null>>({})
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, unknown>>({})
|
||||
|
||||
// Transfer state — the amount to push per output, defaulted to everything available.
|
||||
const [transferQty, setTransferQty] = useState<Record<number, string>>({})
|
||||
|
||||
const [confirm, setConfirm] = useState<null | "rejectIntake" | "rejectTerminal">(null)
|
||||
|
||||
// A per-action Idempotency-Key, minted fresh for each stage the drawer opens on. Same shape as
|
||||
// app/dashboard/receiving/grn/[id]/page.tsx.
|
||||
const idempotencyKey = useRef(crypto.randomUUID())
|
||||
|
||||
const stageId = stage?.runStageId ?? null
|
||||
|
||||
// Reseed every form whenever the drawer switches stage *or* the server sends new figures for
|
||||
// the one it is on. Without the second half, a refresh after an action would leave the inputs
|
||||
// showing pre-action numbers.
|
||||
useEffect(() => {
|
||||
if (!stage) return
|
||||
setPlannedInputs(Object.fromEntries(stage.inputs.map((i) => [i.runInputId, String(i.plannedQty)])))
|
||||
setPlannedOutputs(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.plannedQty)])))
|
||||
setProduced(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.producedQty || o.plannedQty)])))
|
||||
setScrapped(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.scrappedQty || 0)])))
|
||||
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
|
||||
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
|
||||
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
|
||||
setError(null)
|
||||
idempotencyKey.current = crypto.randomUUID()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stageId, stage?.status, stage?.outputs, stage?.inputs])
|
||||
|
||||
// Live elapsed while the stage is running (FR-MFG-19). One tick a minute is enough — the
|
||||
// figure is rendered in whole minutes, so a faster interval would just re-render for nothing.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const running = stage?.actualStartAt != null && stage?.actualEndAt == null
|
||||
useEffect(() => {
|
||||
if (!running) return
|
||||
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [running])
|
||||
|
||||
const itemName = useMemo(() => {
|
||||
const byId = new Map(items.map((i) => [i.itemId, i.name]))
|
||||
return (id: number | null) => (id === null ? "—" : byId.get(id) ?? `Item #${id}`)
|
||||
}, [items])
|
||||
|
||||
const uomName = useMemo(() => {
|
||||
const byId = new Map(uoms.map((u) => [u.uomId, u.name]))
|
||||
return (id: number) => byId.get(id) ?? `#${id}`
|
||||
}, [uoms])
|
||||
|
||||
const stageName = useMemo(() => {
|
||||
const byId = new Map(run.stages.map((s) => [s.runStageId, s.name]))
|
||||
return (id: number) => byId.get(id) ?? `Stage #${id}`
|
||||
}, [run.stages])
|
||||
|
||||
if (!stage) return null
|
||||
|
||||
const isEditable = stage.status === "Ready"
|
||||
const upstreamInputs = stage.inputs.filter((i) => i.source === "Upstream")
|
||||
const deliveredSoFar = upstreamInputs.reduce((sum, i) => sum + i.deliveredQty, 0)
|
||||
const canRejectIntake = (stage.status === "Ready" || stage.status === "Waiting") && deliveredSoFar > 0
|
||||
const availableTotal = stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0)
|
||||
const stageEvents = run.events.filter((e) => e.runStageId === stage.runStageId)
|
||||
|
||||
/**
|
||||
* One wrapper for every action: busy flag, error surfacing, and the docs/21 §6 rule that a
|
||||
* stage-status 409 refreshes silently rather than shouting at the user.
|
||||
*/
|
||||
async function submit(action: () => Promise<unknown>) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await action()
|
||||
idempotencyKey.current = crypto.randomUUID()
|
||||
onActed()
|
||||
} catch (err) {
|
||||
if (isStaleStageError(err)) {
|
||||
onActed()
|
||||
return
|
||||
}
|
||||
setError(errorMessage(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function saveQuantities() {
|
||||
const inputs: StageQuantityLine[] = stage!.inputs
|
||||
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
|
||||
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
|
||||
const outputs: StageQuantityLine[] = stage!.outputs
|
||||
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
|
||||
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
|
||||
|
||||
return submit(() => productionRunsApi.updateStageQuantities(run.runId, stage!.runStageId, { inputs, outputs }))
|
||||
}
|
||||
|
||||
function completeStage() {
|
||||
const outputs: CompleteOutputLine[] = stage!.outputs.map((o) => ({
|
||||
runOutputId: o.runOutputId,
|
||||
producedQty: Number(produced[o.runOutputId] ?? 0) || 0,
|
||||
scrappedQty: Number(scrapped[o.runOutputId] ?? 0) || 0,
|
||||
scrapReasonCodeId: Number(scrapped[o.runOutputId] ?? 0) > 0 ? scrapReason[o.runOutputId] : null,
|
||||
}))
|
||||
|
||||
return submit(() =>
|
||||
productionRunsApi.complete(
|
||||
run.runId,
|
||||
stage!.runStageId,
|
||||
{ outputs, fieldValues: Object.keys(fieldValues).length > 0 ? fieldValues : null },
|
||||
idempotencyKey.current
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve. An empty `transfers` array tells the server to push every output in full, which is
|
||||
* the common case; a line is only sent when the user has dialled it down below what's
|
||||
* available. On the terminal stage the server ignores transfers entirely and posts the receipt.
|
||||
*/
|
||||
function approveStage() {
|
||||
const transfers: TransferLine[] = stage!.outputs
|
||||
.filter((o) => {
|
||||
const wanted = Number(transferQty[o.runOutputId] ?? o.availableToTransfer)
|
||||
return Number.isFinite(wanted) && wanted !== o.availableToTransfer
|
||||
})
|
||||
.map((o) => ({ runOutputId: o.runOutputId, qty: Number(transferQty[o.runOutputId]) }))
|
||||
|
||||
return submit(() =>
|
||||
productionRunsApi.approve(
|
||||
run.runId,
|
||||
stage!.runStageId,
|
||||
transfers.length > 0 ? { transfers } : {},
|
||||
idempotencyKey.current
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function transferRemainder(output: RunStageOutput) {
|
||||
const qty = Number(transferQty[output.runOutputId] ?? 0)
|
||||
return submit(() =>
|
||||
productionRunsApi.transfer(
|
||||
run.runId,
|
||||
stage!.runStageId,
|
||||
{ runOutputId: output.runOutputId, qty },
|
||||
idempotencyKey.current
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// --- terminal receipt preview ---------------------------------------------
|
||||
// Mirrors the server's arithmetic (docs/30 §D.3 terminal approve) so the operator sees the
|
||||
// layer they are about to create *before* creating it. Deliberately recomputed here rather
|
||||
// than requested: there is no preview endpoint, and every input is already on this page.
|
||||
const terminalOutput = stage.isTerminal ? stage.outputs[0] : undefined
|
||||
const goodQty = terminalOutput ? terminalOutput.producedQty - terminalOutput.scrappedQty : 0
|
||||
const previewUnitCost = goodQty > 0 ? run.costPool.net / goodQty : null
|
||||
|
||||
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
|
||||
|
||||
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
|
||||
const missingScrapReasons = stage.outputs
|
||||
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
|
||||
.map((o) => o.name)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-lg!">
|
||||
<SheetHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SheetTitle className="text-base">{stage.name}</SheetTitle>
|
||||
<StatusPill status={stage.status} />
|
||||
{stage.roleLabel && (
|
||||
<Badge variant="outline" className="border-transparent bg-primary/10 text-primary">
|
||||
{stage.roleLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{stage.isTerminal && (
|
||||
<Badge variant="outline" className="border-transparent bg-muted text-muted-foreground">
|
||||
Final stage
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<SheetDescription>
|
||||
Estimated {stage.estimatedMinutes} min
|
||||
{stage.actualMinutes !== null
|
||||
? ` · actual ${stage.actualMinutes} min`
|
||||
: running && stage.actualStartAt
|
||||
? ` · running for ${minutesSince(stage.actualStartAt, now)} min`
|
||||
: ""}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------- inputs */}
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-bold text-foreground">Inputs</h3>
|
||||
{stage.inputs.length === 0 && <p className="text-sm text-muted-foreground">This stage consumes nothing.</p>}
|
||||
{stage.inputs.map((input) => (
|
||||
<InputCard
|
||||
key={input.runInputId}
|
||||
input={input}
|
||||
editable={isEditable}
|
||||
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
|
||||
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||
itemName={itemName}
|
||||
uomName={uomName}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* --------------------------------------------------------- outputs */}
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-bold text-foreground">Outputs</h3>
|
||||
{stage.outputs.map((output) => (
|
||||
<div key={output.runOutputId} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{output.name}</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{uomName(output.uomId)}</span>
|
||||
</div>
|
||||
{output.itemId !== null && (
|
||||
<p className="text-xs text-muted-foreground">Finished good: {itemName(output.itemId)}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{isEditable ? (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={plannedOutputs[output.runOutputId] ?? String(output.plannedQty)}
|
||||
onChange={(e) => setPlannedOutputs((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<Row label="Planned">{fmt(output.plannedQty)}</Row>
|
||||
)}
|
||||
{output.producedQty > 0 && <Row label="Produced">{fmt(output.producedQty)}</Row>}
|
||||
{output.scrappedQty > 0 && (
|
||||
<Row label="Scrapped">
|
||||
<span className="text-destructive">{fmt(output.scrappedQty)}</span>
|
||||
</Row>
|
||||
)}
|
||||
{output.transferredQty > 0 && <Row label="Transferred">{fmt(output.transferredQty)}</Row>}
|
||||
{output.availableToTransfer > 0 && (
|
||||
<Row label="Available to transfer">
|
||||
<span className="text-info">{fmt(output.availableToTransfer)}</span>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ------------------------------------------------ status-specific */}
|
||||
{stage.status === "Waiting" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Waiting on upstream deliveries. This stage becomes Ready once every upstream input has received its
|
||||
planned quantity.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{stage.status === "Ready" && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Adjust planned quantities if needed, then start. Starting consumes the stock inputs above FIFO —
|
||||
quantities are locked from that point on.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={saveQuantities} disabled={busy}>
|
||||
Save quantities
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
submit(() => productionRunsApi.start(run.runId, stage.runStageId, idempotencyKey.current))
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Start stage
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "InProgress" && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Record output</h3>
|
||||
{stage.outputs.map((output) => {
|
||||
const scrapQty = Number(scrapped[output.runOutputId] ?? 0)
|
||||
return (
|
||||
<div key={output.runOutputId} className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<p className="text-sm font-medium text-foreground">{output.name}</p>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Produced</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={produced[output.runOutputId] ?? ""}
|
||||
onChange={(e) => setProduced((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Scrapped</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={scrapped[output.runOutputId] ?? ""}
|
||||
onChange={(e) => setScrapped((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{scrapQty > 0 && (
|
||||
<Select<number>
|
||||
value={scrapReason[output.runOutputId] ?? null}
|
||||
onValueChange={(v) => setScrapReason((prev) => ({ ...prev, [output.runOutputId]: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9! w-full text-sm">
|
||||
<SelectValue placeholder="Scrap reason (required)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scrapReasons.map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
|
||||
{r.code} — {r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<CustomFields stage={stage} values={fieldValues} onChange={setFieldValues} />
|
||||
|
||||
{/*
|
||||
Blocked client-side as well as server-side. The server's 400
|
||||
REQUIRED_FIELD_MISSING rolls the whole transaction back, so letting it through
|
||||
would cost a round trip and — worse — look to the operator like the completion
|
||||
half-applied. `missingRequiredFields` deliberately mirrors the server's rule.
|
||||
*/}
|
||||
{missingScrapReasons.length > 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Pick a scrap reason for: {missingScrapReasons.join(", ")}.
|
||||
</p>
|
||||
)}
|
||||
{missingFields.length > 0 && (
|
||||
<p className="text-sm text-destructive">Fill in: {missingFields.join(", ")}.</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={completeStage}
|
||||
disabled={busy || missingFields.length > 0 || missingScrapReasons.length > 0}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Complete stage
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "Done" && !stage.isTerminal && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Approve & transfer</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Approving pushes work downstream. Leave the amounts as they are to transfer everything, or lower one to
|
||||
hold some back — you can transfer the remainder later.
|
||||
</p>
|
||||
{stage.outputs.map((output) => (
|
||||
<label key={output.runOutputId} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate text-muted-foreground">{output.name}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={output.availableToTransfer}
|
||||
step="any"
|
||||
value={transferQty[output.runOutputId] ?? ""}
|
||||
onChange={(e) => setTransferQty((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">of {fmt(output.availableToTransfer)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<Button onClick={approveStage} disabled={busy}>
|
||||
<Send className="size-4" />
|
||||
Approve & transfer
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "Done" && stage.isTerminal && terminalOutput && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Finish the run</h3>
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-info/30 bg-info/5 p-3">
|
||||
<Row label="Good quantity">{fmt(goodQty)} {uomName(terminalOutput.uomId)}</Row>
|
||||
<Row label="Materials consumed">{money(run.costPool.consumed)}</Row>
|
||||
<Row label="Leftovers returned">−{money(run.costPool.returned)}</Row>
|
||||
<Separator className="my-1" />
|
||||
<Row label="Cost pool">{money(run.costPool.net)}</Row>
|
||||
<Row label="Unit cost">{previewUnitCost === null ? "—" : money(previewUnitCost)}</Row>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Approving creates a costed stock layer of {fmt(goodQty)} {itemName(terminalOutput.itemId)} and completes
|
||||
the run. Its costs close at that point — return any leftover materials first.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={approveStage} disabled={busy || goodQty <= 0}>
|
||||
<PackageCheck className="size-4" />
|
||||
Approve & receive
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setConfirm("rejectTerminal")} disabled={busy}>
|
||||
<Undo2 className="size-4" />
|
||||
Reject for rework
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "Approved" && availableTotal > 0 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Transfer remainder</h3>
|
||||
{stage.outputs
|
||||
.filter((o) => o.availableToTransfer > 0)
|
||||
.map((output) => (
|
||||
<div key={output.runOutputId} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{output.name}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={output.availableToTransfer}
|
||||
step="any"
|
||||
value={transferQty[output.runOutputId] ?? ""}
|
||||
onChange={(e) => setTransferQty((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||
className="h-8 w-24 text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={() => transferRemainder(output)} disabled={busy}>
|
||||
Transfer
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "Approved" && availableTotal === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Approved — everything this stage produced has moved on.</p>
|
||||
)}
|
||||
|
||||
{canRejectIntake && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-bold text-foreground">Reject what was delivered</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sends {fmt(deliveredSoFar)} back to the stage(s) that delivered it and reopens them for correction.
|
||||
No stock moves — this is work in progress, not inventory.
|
||||
</p>
|
||||
<Button variant="outline" className="w-fit text-destructive" onClick={() => setConfirm("rejectIntake")} disabled={busy}>
|
||||
<Ban className="size-4" />
|
||||
Reject intake
|
||||
</Button>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --------------------------------------------------------- history */}
|
||||
{stageEvents.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-bold text-foreground">History</h3>
|
||||
<ol className="flex flex-col gap-2">
|
||||
{stageEvents.map((event) => (
|
||||
<li key={event.eventId} className="flex gap-2 text-sm">
|
||||
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="font-medium text-foreground">{event.eventType}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(event.createdAt).toLocaleString()}
|
||||
</span>
|
||||
{event.note && <span className="text-xs text-muted-foreground">{event.note}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={confirm === "rejectIntake"} onOpenChange={(open) => !open && setConfirm(null)}>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title="Reject the delivered work?"
|
||||
description={`${stageName(stage.runStageId)} will go back to Waiting and every stage that delivered to it reopens for correction. Their recorded output is cleared, so it has to be re-entered.`}
|
||||
confirmLabel="Reject intake"
|
||||
onConfirm={() =>
|
||||
submit(() => productionRunsApi.rejectIntake(run.runId, stage.runStageId, {}, idempotencyKey.current))
|
||||
}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={confirm === "rejectTerminal"} onOpenChange={(open) => !open && setConfirm(null)}>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title="Reject and rework the whole run?"
|
||||
description="Every stage resets and the run starts over as a rework pass. Materials already consumed stay in the cost pool — no stock is returned — so raise planned quantities before restarting if more will be needed."
|
||||
confirmLabel="Reject for rework"
|
||||
onConfirm={() =>
|
||||
submit(() => productionRunsApi.rejectTerminal(run.runId, stage.runStageId, {}, idempotencyKey.current))
|
||||
}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Split out only because the input card has three mutually exclusive quantity presentations. */
|
||||
function InputCard({
|
||||
input,
|
||||
editable,
|
||||
value,
|
||||
onValueChange,
|
||||
itemName,
|
||||
uomName,
|
||||
}: {
|
||||
input: RunStageInput
|
||||
editable: boolean
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
itemName: (id: number | null) => string
|
||||
uomName: (id: number) => string
|
||||
}) {
|
||||
const isUpstream = input.source === "Upstream"
|
||||
const short = isUpstream && input.deliveredQty < input.plannedQty
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{isUpstream ? "Upstream work in progress" : itemName(input.itemId)}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{uomName(input.uomId)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{editable ? (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<Row label="Planned">{fmt(input.plannedQty)}</Row>
|
||||
)}
|
||||
|
||||
{isUpstream && (
|
||||
<Row label="Delivered">
|
||||
<span className={cn(short ? "text-warning" : "text-success")}>{fmt(input.deliveredQty)}</span>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Consumed/returned figures are in the item's BASE uom, while `plannedQty` above is in the
|
||||
input's declared uom — an input declared in "box of 12" shows planned 3 and consumed 36.
|
||||
Labelled explicitly so the two are never read as the same unit.
|
||||
*/}
|
||||
{input.consumedQty > 0 && (
|
||||
<>
|
||||
<Row label="Consumed (base)">
|
||||
{fmt(input.consumedQty)} · {money(input.consumedValue)}
|
||||
</Row>
|
||||
{input.returnedQty > 0 && (
|
||||
<Row label="Returned (base)">
|
||||
{fmt(input.returnedQty)} · {money(input.returnedValue)}
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
|
||||
function CustomFields({
|
||||
stage,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
stage: RunStage
|
||||
values: Record<string, unknown>
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
}) {
|
||||
if (stage.fieldDefs.length === 0) return null
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-bold text-foreground">Checks</h3>
|
||||
<CustomFieldForm defs={stage.fieldDefs} values={values} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { ReactFlow, Background, Controls, type Edge, type Node } from "@xyflow/react"
|
||||
import { ReactFlow, Background, Controls, MiniMap, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import { useTheme } from "next-themes"
|
||||
import { ArrowLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||
import { ArrowLeft, RotateCcw } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RunStatus, StageSummary } from "@/types/production"
|
||||
import { INITIAL_RUNS, buildStagePlan, type RunStagePlanItem } from "@/lib/production-mock-runs"
|
||||
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, STAGE_STATUS_ORDER } from "@/lib/production-status-colors"
|
||||
import { productionRunsApi } from "@/lib/api/production-runs"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProductionRunGraph, ProductionRunStatus, RunStage, StageSummary } from "@/types/production"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
|
||||
import {
|
||||
RunHeaderNodeComponent,
|
||||
RunStageNodeComponent,
|
||||
@@ -22,13 +29,10 @@ import { StageProgressStrip, StageStatusLegend } from "@/components/production/s
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { StageDrawer } from "./StageDrawer"
|
||||
import { RunActions } from "./RunActions"
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function runStatusBadgeClass(status: RunStatus) {
|
||||
function runStatusBadgeClass(status: ProductionRunStatus) {
|
||||
if (status === "Completed") return "bg-success/10 text-success"
|
||||
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
|
||||
return "bg-info/10 text-info"
|
||||
@@ -36,116 +40,168 @@ function runStatusBadgeClass(status: RunStatus) {
|
||||
|
||||
const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeComponent }
|
||||
|
||||
const STAGE_START_X = 260
|
||||
const STAGE_GAP_X = 220
|
||||
/**
|
||||
* Where the run header box sits relative to the stages.
|
||||
*
|
||||
* Stage positions come from the run's own `posX`/`posY` — copied from the template at creation
|
||||
* (FR-MFG-06), so the canvas matches what was drawn in the builder. The header is placed to the
|
||||
* left of the leftmost stage rather than at a fixed origin, because those coordinates are
|
||||
* arbitrary and could otherwise put the header on top of a stage.
|
||||
*/
|
||||
const HEADER_GAP_X = 240
|
||||
|
||||
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
|
||||
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
|
||||
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: "header",
|
||||
type: "runHeader",
|
||||
position: { x: minX - HEADER_GAP_X, y: minY },
|
||||
data: { docNo: run.docNo, templateName: run.templateName, status: run.status } satisfies RunHeaderData,
|
||||
draggable: false,
|
||||
},
|
||||
]
|
||||
|
||||
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
|
||||
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
|
||||
const actionable = ["InProgress", "Done", "Ready"] as const
|
||||
const activeId =
|
||||
run.status === "InProgress"
|
||||
? actionable.reduce<number | null>(
|
||||
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
|
||||
null
|
||||
)
|
||||
: null
|
||||
|
||||
for (const stage of run.stages) {
|
||||
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
|
||||
nodes.push({
|
||||
id: String(stage.runStageId),
|
||||
type: "runStage",
|
||||
position: { x: stage.posX, y: stage.posY },
|
||||
data: {
|
||||
name: stage.name,
|
||||
roleLabel: stage.roleLabel,
|
||||
state: stage.status,
|
||||
isTerminal: stage.isTerminal,
|
||||
isEntry: stage.isEntry,
|
||||
estimatedMinutes: stage.estimatedMinutes,
|
||||
actualMinutes: stage.actualMinutes,
|
||||
actualStartAt: stage.actualStartAt,
|
||||
intake:
|
||||
upstream.length === 0
|
||||
? null
|
||||
: {
|
||||
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
|
||||
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
|
||||
},
|
||||
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
|
||||
isActive: stage.runStageId === activeId,
|
||||
} satisfies RunStageData,
|
||||
draggable: false,
|
||||
})
|
||||
}
|
||||
|
||||
const edges: Edge[] = run.edges.map((e) => {
|
||||
const parent = run.stages.find((s) => s.runStageId === e.parentRunStageId)
|
||||
return {
|
||||
id: `e${e.runEdgeId}`,
|
||||
source: String(e.parentRunStageId),
|
||||
target: String(e.childRunStageId),
|
||||
animated: parent?.status === "InProgress",
|
||||
}
|
||||
})
|
||||
|
||||
// Entry stages hang off the run header so the line reads left to right from the run itself.
|
||||
for (const stage of run.stages.filter((s) => s.isEntry)) {
|
||||
edges.push({ id: `eh-${stage.runStageId}`, source: "header", target: String(stage.runStageId) })
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
export default function ProductionRunDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const runId = Number(params.id)
|
||||
const run = useMemo(() => INITIAL_RUNS.find((r) => r.runId === runId) ?? null, [runId])
|
||||
|
||||
// Same hydration-mismatch guard as the other canvas pages (templates/page.tsx,
|
||||
// templates/[id]/page.tsx): colorMode depends on resolvedTheme, unknown on first paint.
|
||||
const [run, setRun] = useState<ProductionRunGraph | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [selectedStageId, setSelectedStageId] = useState<number | null>(null)
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||
|
||||
// Same hydration-mismatch guard as the other canvas pages: colorMode depends on
|
||||
// resolvedTheme, which is unknown on the server and on the client's first paint.
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
const [status, setStatus] = useState<RunStatus>(run?.status ?? "InProgress")
|
||||
const [completedAt, setCompletedAt] = useState<string | null>(run?.completedAt ?? null)
|
||||
const [stages, setStages] = useState<RunStagePlanItem[]>(() =>
|
||||
run ? buildStagePlan(run.templateName, run.stageSummary) : []
|
||||
)
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null)
|
||||
productionRunsApi
|
||||
.get(runId)
|
||||
.then(({ data }) => setRun(data))
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [runId])
|
||||
|
||||
const activeIndex = stages.findIndex((s) => s.state !== "Approved")
|
||||
|
||||
function advanceStage(index: number) {
|
||||
setStages((prev) => {
|
||||
const curIdx = STAGE_STATUS_ORDER.indexOf(prev[index].state)
|
||||
if (curIdx >= STAGE_STATUS_ORDER.length - 1) return prev
|
||||
const next = [...prev]
|
||||
next[index] = { ...next[index], state: STAGE_STATUS_ORDER[curIdx + 1] }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// No real backend to push this to — advancing every stage to Approved locally completes
|
||||
// the run on this page only (the Runs board keeps its own separate seed state).
|
||||
useEffect(() => {
|
||||
if (stages.length > 0 && stages.every((s) => s.state === "Approved") && status === "InProgress") {
|
||||
setStatus("Completed")
|
||||
setCompletedAt(todayIso())
|
||||
toast.success("Run completed", run ? `${run.docNo} — all stages approved` : undefined)
|
||||
}
|
||||
}, [stages, status, run])
|
||||
if (Number.isFinite(runId)) load()
|
||||
}, [runId, load])
|
||||
|
||||
const progressPercent = stages.length > 0 ? Math.round((stages.filter((s) => s.state === "Approved").length / stages.length) * 100) : 0
|
||||
const activeStage = activeIndex >= 0 ? stages[activeIndex] : null
|
||||
const canGiveProgress = status === "InProgress" && activeStage !== null
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
reasonCodesApi.list("Production", { pageSize: 100 }),
|
||||
])
|
||||
.then(([itemRes, uomRes, warehouseRes, reasonRes]) => {
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(warehouseRes.items)
|
||||
setReasonCodes(reasonRes.items)
|
||||
})
|
||||
.catch(() => {
|
||||
// Reference data only feeds names and pickers — a failure here degrades labels to ids
|
||||
// rather than blocking the run, so it deliberately doesn't set loadError.
|
||||
})
|
||||
}, [])
|
||||
|
||||
function giveProgress() {
|
||||
if (activeIndex < 0) return
|
||||
const stage = stages[activeIndex]
|
||||
const nextState = STAGE_STATUS_ORDER[STAGE_STATUS_ORDER.indexOf(stage.state) + 1]
|
||||
advanceStage(activeIndex)
|
||||
toast.success(`${stage.name} → ${STAGE_STATUS_LABEL[nextState]}`, run?.docNo)
|
||||
}
|
||||
|
||||
const stageSummary: StageSummary = useMemo(
|
||||
() => ({
|
||||
waiting: stages.filter((s) => s.state === "Waiting").length,
|
||||
ready: stages.filter((s) => s.state === "Ready").length,
|
||||
inProgress: stages.filter((s) => s.state === "InProgress").length,
|
||||
done: stages.filter((s) => s.state === "Done").length,
|
||||
approved: stages.filter((s) => s.state === "Approved").length,
|
||||
}),
|
||||
[stages]
|
||||
const { nodes, edges } = useMemo(
|
||||
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
|
||||
[run]
|
||||
)
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!run) return { nodes: [] as Node[], edges: [] as Edge[] }
|
||||
const stageSummary: StageSummary = useMemo(() => {
|
||||
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
|
||||
for (const stage of run?.stages ?? []) {
|
||||
if (stage.status === "Waiting") counts.waiting++
|
||||
else if (stage.status === "Ready") counts.ready++
|
||||
else if (stage.status === "InProgress") counts.inProgress++
|
||||
else if (stage.status === "Done") counts.done++
|
||||
else counts.approved++
|
||||
}
|
||||
return counts
|
||||
}, [run])
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: "header",
|
||||
type: "runHeader",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { docNo: run.docNo, templateName: run.templateName, status } satisfies RunHeaderData,
|
||||
draggable: false,
|
||||
},
|
||||
]
|
||||
const edges: Edge[] = []
|
||||
const selectedStage: RunStage | null =
|
||||
run?.stages.find((s) => s.runStageId === selectedStageId) ?? null
|
||||
|
||||
stages.forEach((s, i) => {
|
||||
const id = `stage-${i}`
|
||||
const isActive = i === activeIndex && status === "InProgress"
|
||||
nodes.push({
|
||||
id,
|
||||
type: "runStage",
|
||||
position: { x: STAGE_START_X + i * STAGE_GAP_X, y: -8 },
|
||||
data: {
|
||||
name: s.name,
|
||||
state: s.state,
|
||||
isActive,
|
||||
onAdvance: isActive ? () => advanceStage(i) : undefined,
|
||||
} satisfies RunStageData,
|
||||
draggable: false,
|
||||
})
|
||||
edges.push({
|
||||
id: `e-${id}`,
|
||||
source: i === 0 ? "header" : `stage-${i - 1}`,
|
||||
target: id,
|
||||
animated: s.state === "InProgress",
|
||||
})
|
||||
})
|
||||
const onNodeClick: NodeMouseHandler = useCallback((_, node) => {
|
||||
if (node.type !== "runStage") return
|
||||
setSelectedStageId(Number(node.id))
|
||||
}, [])
|
||||
|
||||
return { nodes, edges }
|
||||
}, [run, stages, activeIndex, status])
|
||||
|
||||
if (!run) {
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">Run not found.</p>
|
||||
<p className="text-base text-muted-foreground">{loadError}</p>
|
||||
<Button variant="outline" onClick={() => router.push("/dashboard/production/runs")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to runs
|
||||
@@ -154,6 +210,20 @@ export default function ProductionRunDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-40 w-full rounded-2xl" />
|
||||
<Skeleton className="h-[45vh] w-full rounded-2xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const warehouse = warehouses.find((w) => w.warehouseId === run.warehouseId)
|
||||
const approvedCount = stageSummary.approved
|
||||
const progressPercent = run.stages.length > 0 ? Math.round((approvedCount / run.stages.length) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Button
|
||||
@@ -170,8 +240,8 @@ export default function ProductionRunDetailPage() {
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-lg font-bold text-foreground">{run.docNo}</span>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(status))}>
|
||||
{status === "InProgress" ? "In Progress" : status}
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(run.status))}>
|
||||
{run.status === "InProgress" ? "In Progress" : run.status}
|
||||
</Badge>
|
||||
{run.reworkCount > 0 && (
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning">
|
||||
@@ -180,27 +250,31 @@ export default function ProductionRunDetailPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{run.templateName} · {run.warehouseName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{run.templateName}
|
||||
{warehouse && ` · ${warehouse.name}`}
|
||||
{` · ×${Number(run.scaleFactor.toFixed(6)).toLocaleString()} scale`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col text-left sm:text-right">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{run.targetQty.toLocaleString()} {run.uom} · {run.finishedItemName}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">Target {run.targetQty.toLocaleString()}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Created {new Date(run.createdAt).toLocaleDateString()}
|
||||
{completedAt && <> · Completed {new Date(completedAt).toLocaleDateString()}</>}
|
||||
{run.completedAt && <> · Completed {new Date(run.completedAt).toLocaleDateString()}</>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StageProgressStrip status={status} summary={stageSummary} className="mt-4" />
|
||||
<StageProgressStrip status={run.status} summary={stageSummary} className="mt-4" />
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{progressPercent}% complete
|
||||
{activeStage && <span className="text-muted-foreground"> · Current: {activeStage.name}</span>}
|
||||
{progressPercent}% approved
|
||||
<span className="text-muted-foreground">
|
||||
{" "}· cost pool {run.costPool.net.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
@@ -210,10 +284,7 @@ export default function ProductionRunDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={giveProgress} disabled={!canGiveProgress} className="w-full shrink-0 sm:w-auto">
|
||||
{activeStage ? `Give Progress — ${activeStage.name}` : "All stages approved"}
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<RunActions run={run} items={items} reasonCodes={reasonCodes} onActed={load} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -221,15 +292,18 @@ export default function ProductionRunDetailPage() {
|
||||
<StageStatusLegend />
|
||||
</div>
|
||||
|
||||
<div className="h-[45vh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
<p className="text-sm text-muted-foreground">Click a stage to open it.</p>
|
||||
|
||||
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
elementsSelectable
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
@@ -238,11 +312,22 @@ export default function ProductionRunDetailPage() {
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<Skeleton className="size-full rounded-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StageDrawer
|
||||
run={run}
|
||||
stage={selectedStage}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
reasonCodes={reasonCodes}
|
||||
onClose={() => setSelectedStageId(null)}
|
||||
onActed={load}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronRight, PlayCircle, RotateCcw, Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ProductionRun, RunStatus } from "@/types/production"
|
||||
import { INITIAL_RUNS, STARTABLE_TEMPLATES, buildStagePlan } from "@/lib/production-mock-runs"
|
||||
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL } from "@/lib/production-status-colors"
|
||||
import { productionRunsApi } from "@/lib/api/production-runs"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProductionRunStatus, ProductionRunSummary, ProductionTemplateSummary } from "@/types/production"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, STAGE_STATUS_ORDER } from "@/lib/production-status-colors"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -15,101 +20,152 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
|
||||
|
||||
const TEMPLATE_NAMES = Array.from(new Set(INITIAL_RUNS.map((r) => r.templateName)))
|
||||
const WAREHOUSE_NAMES = Array.from(new Set(INITIAL_RUNS.map((r) => r.warehouseName)))
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
type StatusFilter = ProductionRunStatus | "All"
|
||||
|
||||
type StatusFilter = RunStatus | "All"
|
||||
type NameFilter = string | "All"
|
||||
|
||||
function runStatusBadgeClass(status: RunStatus) {
|
||||
function runStatusBadgeClass(status: ProductionRunStatus) {
|
||||
if (status === "Completed") return "bg-success/10 text-success"
|
||||
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
|
||||
return "bg-info/10 text-info"
|
||||
}
|
||||
|
||||
const SUMMARY_KEYS = {
|
||||
Waiting: "waiting",
|
||||
Ready: "ready",
|
||||
InProgress: "inProgress",
|
||||
Done: "done",
|
||||
Approved: "approved",
|
||||
} as const
|
||||
|
||||
export default function ProductionRunsPage() {
|
||||
const router = useRouter()
|
||||
const [runs, setRuns] = useState<ProductionRun[]>(INITIAL_RUNS)
|
||||
|
||||
// null = still loading (the codebase convention for "no data yet" vs "empty result").
|
||||
const [runs, setRuns] = useState<ProductionRunSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [template, setTemplate] = useState<NameFilter>("All")
|
||||
const [warehouse, setWarehouse] = useState<NameFilter>("All")
|
||||
const [templateId, setTemplateId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [startTemplateId, setStartTemplateId] = useState<number | null>(null)
|
||||
const [targetQty, setTargetQty] = useState("")
|
||||
const [startWarehouse, setStartWarehouse] = useState<string | null>(null)
|
||||
const [outputBin, setOutputBin] = useState("")
|
||||
const [startWarehouseId, setStartWarehouseId] = useState<number | null>(null)
|
||||
const [bins, setBins] = useState<Bin[]>([])
|
||||
const [outputBinId, setOutputBinId] = useState<number | null>(null)
|
||||
const [formError, setFormError] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const startTemplate = STARTABLE_TEMPLATES.find((t) => t.templateId === startTemplateId) ?? null
|
||||
// 300ms debounce, matching app/dashboard/receiving/grn/page.tsx. Resets to page 1 with the
|
||||
// query so a narrower search can't leave you stranded past the last page.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setQuery(searchInput.trim())
|
||||
setPage(1)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null)
|
||||
productionRunsApi
|
||||
.list({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
templateId: templateId ?? undefined,
|
||||
warehouseId: warehouseId ?? undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
setRuns(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => {
|
||||
setRuns([])
|
||||
setLoadError(errorMessage(err))
|
||||
})
|
||||
}, [page, query, status, templateId, warehouseId])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
|
||||
// Inactive template that still has historical runs; the start dialog narrows to Active
|
||||
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
|
||||
useEffect(() => {
|
||||
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
|
||||
.then(([templateRes, warehouseRes]) => {
|
||||
setTemplates(templateRes.items)
|
||||
setWarehouses(warehouseRes.items)
|
||||
})
|
||||
.catch((err) => toast.error("Could not load filters", errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
// Bins belong to a warehouse, so the list is only meaningful once one is picked.
|
||||
useEffect(() => {
|
||||
if (startWarehouseId === null) {
|
||||
setBins([])
|
||||
return
|
||||
}
|
||||
warehousesApi
|
||||
.listBins(startWarehouseId)
|
||||
.then(setBins)
|
||||
.catch(() => setBins([]))
|
||||
}, [startWarehouseId])
|
||||
|
||||
const startableTemplates = templates.filter((t) => t.status === "Active")
|
||||
const startTemplate = startableTemplates.find((t) => t.templateId === startTemplateId) ?? null
|
||||
const targetQtyNum = Number(targetQty)
|
||||
const scaleFactor = startTemplate && targetQtyNum > 0 ? targetQtyNum / startTemplate.nominalBatchQty : null
|
||||
|
||||
function openStartDialog() {
|
||||
setStartTemplateId(null)
|
||||
setTargetQty("")
|
||||
setStartWarehouse(null)
|
||||
setOutputBin("")
|
||||
setStartWarehouseId(null)
|
||||
setOutputBinId(null)
|
||||
setFormError("")
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function handleStartRun() {
|
||||
if (!startTemplate) {
|
||||
setFormError("Pick a template.")
|
||||
return
|
||||
}
|
||||
if (!(targetQtyNum > 0)) {
|
||||
setFormError("Target quantity must be greater than 0.")
|
||||
return
|
||||
}
|
||||
if (!startWarehouse) {
|
||||
setFormError("Pick a warehouse.")
|
||||
return
|
||||
}
|
||||
async function handleStartRun() {
|
||||
if (!startTemplate) return setFormError("Pick a template.")
|
||||
if (!(targetQtyNum > 0)) return setFormError("Target quantity must be greater than 0.")
|
||||
if (startWarehouseId === null) return setFormError("Pick a warehouse.")
|
||||
|
||||
setFormError("")
|
||||
setSubmitting(true)
|
||||
const nextId = runs.reduce((max, r) => Math.max(max, r.runId), 0) + 1
|
||||
const created: ProductionRun = {
|
||||
runId: nextId,
|
||||
docNo: `PRD-2026-${String(nextId).padStart(5, "0")}`,
|
||||
templateName: startTemplate.name,
|
||||
targetQty: targetQtyNum,
|
||||
finishedItemName: startTemplate.finishedItemName,
|
||||
uom: startTemplate.uom,
|
||||
warehouseName: startWarehouse,
|
||||
status: "InProgress",
|
||||
reworkCount: 0,
|
||||
createdAt: todayIso(),
|
||||
completedAt: null,
|
||||
// Freshly started: nothing done yet, first stage ready, the rest waiting.
|
||||
stageSummary: { waiting: Math.max(startTemplate.stageCount - 1, 0), ready: 1, inProgress: 0, done: 0, approved: 0 },
|
||||
try {
|
||||
const { data } = await productionRunsApi.create({
|
||||
templateId: startTemplate.templateId,
|
||||
targetQty: targetQtyNum,
|
||||
warehouseId: startWarehouseId,
|
||||
outputBinId,
|
||||
})
|
||||
toast.success("Run started", `${data.docNo} — ${data.templateName}`)
|
||||
setOpen(false)
|
||||
// Straight to the run: the per-stage quantities the operator may want to adjust before
|
||||
// starting stage one only exist there (docs/30 §4).
|
||||
router.push(`/dashboard/production/runs/${data.runId}`)
|
||||
} catch (err) {
|
||||
setFormError(errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
setRuns((prev) => [...prev, created])
|
||||
toast.success("Run started", `${created.docNo} — ${created.templateName}${outputBin ? ` → bin ${outputBin}` : ""}`)
|
||||
setSubmitting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchInput.trim().toLowerCase()
|
||||
return runs
|
||||
.filter((r) => (status === "All" ? true : r.status === status))
|
||||
.filter((r) => (template === "All" ? true : r.templateName === template))
|
||||
.filter((r) => (warehouse === "All" ? true : r.warehouseName === warehouse))
|
||||
.filter((r) => (q ? r.docNo.toLowerCase().includes(q) : true))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}, [runs, searchInput, status, template, warehouse])
|
||||
|
||||
const hasFilters = searchInput.trim().length > 0 || status !== "All" || template !== "All" || warehouse !== "All"
|
||||
const hasFilters = query.length > 0 || status !== "All" || templateId !== null || warehouseId !== null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -128,56 +184,70 @@ export default function ProductionRunsPage() {
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!formError && !startTemplate}>
|
||||
<FieldLabel>Template</FieldLabel>
|
||||
<Select<number> value={startTemplateId ?? null} onValueChange={(v) => setStartTemplateId(v)}>
|
||||
<Select<number> value={startTemplateId} onValueChange={setStartTemplateId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Pick a template" />
|
||||
<SelectValue placeholder={startableTemplates.length === 0 ? "No active templates" : "Pick a template"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STARTABLE_TEMPLATES.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||
{startableTemplates.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">
|
||||
{t.name} · {t.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!formError && !(targetQtyNum > 0)}>
|
||||
<FieldLabel htmlFor="target-qty">
|
||||
Target quantity{startTemplate && <span className="font-normal text-muted-foreground"> ({startTemplate.uom}, {startTemplate.finishedItemName})</span>}
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor="target-qty">Target quantity</FieldLabel>
|
||||
<Input
|
||||
id="target-qty"
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={targetQty}
|
||||
onChange={(e) => setTargetQty(e.target.value)}
|
||||
placeholder="e.g. 200"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!formError && !startWarehouse}>
|
||||
<Field data-invalid={!!formError && startWarehouseId === null}>
|
||||
<FieldLabel>Warehouse</FieldLabel>
|
||||
<Select<string> value={startWarehouse} onValueChange={setStartWarehouse}>
|
||||
<Select<number> value={startWarehouseId} onValueChange={(v) => { setStartWarehouseId(v); setOutputBinId(null) }}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Pick a warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WAREHOUSE_NAMES.map((n) => (
|
||||
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.name} · {w.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="output-bin">Output bin (optional)</FieldLabel>
|
||||
<Input id="output-bin" value={outputBin} onChange={(e) => setOutputBin(e.target.value)} placeholder="e.g. BIN-04" />
|
||||
<FieldLabel>Output bin (optional)</FieldLabel>
|
||||
<Select<number> value={outputBinId} onValueChange={setOutputBinId}>
|
||||
<SelectTrigger className="h-11! w-full text-base" disabled={bins.length === 0}>
|
||||
<SelectValue placeholder={startWarehouseId === null ? "Pick a warehouse first" : bins.length === 0 ? "No bins in this warehouse" : "No specific bin"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
{b.binType ? ` · ${b.binType}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{scaleFactor !== null && (
|
||||
{startTemplate && targetQtyNum > 0 && (
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
Scale factor <span className="font-semibold text-foreground">{scaleFactor.toFixed(2)}×</span> — target {targetQtyNum.toLocaleString()} {startTemplate!.uom} vs
|
||||
{" "}a nominal batch of {startTemplate!.nominalBatchQty.toLocaleString()} {startTemplate!.uom}. Every stage's inputs/outputs scale by this factor; the authoritative
|
||||
figures come back once the run is created.
|
||||
Every stage's planned inputs and outputs are scaled from the template's per-batch figures against
|
||||
this target. The authoritative numbers come back with the run and stay editable until each stage starts.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -206,29 +276,33 @@ export default function ProductionRunsPage() {
|
||||
aria-label="Search runs"
|
||||
/>
|
||||
</div>
|
||||
<Select<NameFilter> value={template} onValueChange={(v) => setTemplate(v ?? "All")}>
|
||||
<Select<number | null>
|
||||
value={templateId}
|
||||
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
|
||||
>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All templates" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All templates</SelectItem>
|
||||
{TEMPLATE_NAMES.map((n) => (
|
||||
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<NameFilter> value={warehouse} onValueChange={(v) => setWarehouse(v ?? "All")}>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
|
||||
>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
|
||||
{WAREHOUSE_NAMES.map((n) => (
|
||||
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -241,11 +315,38 @@ export default function ProductionRunsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
setSearchInput("")
|
||||
setStatus("All")
|
||||
setTemplateId(null)
|
||||
setWarehouseId(null)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10">
|
||||
<StageStatusLegend />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{runs === null ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-32 w-full rounded-2xl" />
|
||||
<Skeleton className="h-32 w-full rounded-2xl" />
|
||||
<Skeleton className="h-32 w-full rounded-2xl" />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PlayCircle className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -253,64 +354,94 @@ export default function ProductionRunsPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{filtered.map((r) => (
|
||||
<button
|
||||
key={r.runId}
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${r.runId}`)}
|
||||
className="w-full rounded-2xl bg-card p-4 text-left shadow-sm ring-1 ring-foreground/10 transition-colors hover:ring-primary/40 sm:p-5"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{r.docNo}</span>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(r.status))}>
|
||||
{r.status === "InProgress" ? "In Progress" : r.status}
|
||||
</Badge>
|
||||
{r.reworkCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning"
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{runs.map((r) => {
|
||||
const warehouse = warehouses.find((w) => w.warehouseId === r.warehouseId)
|
||||
return (
|
||||
<button
|
||||
key={r.runId}
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${r.runId}`)}
|
||||
className="w-full rounded-2xl bg-card p-4 text-left shadow-sm ring-1 ring-foreground/10 transition-colors hover:ring-primary/40 sm:p-5"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{r.docNo}</span>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(r.status))}>
|
||||
{r.status === "InProgress" ? "In Progress" : r.status}
|
||||
</Badge>
|
||||
{r.reworkCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Rework #{r.reworkCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{r.templateName}
|
||||
{warehouse && ` · ${warehouse.name}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2 sm:items-center">
|
||||
<div className="flex flex-col text-left sm:text-right">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{r.targetQty.toLocaleString()}
|
||||
{r.finishedItemName && ` · ${r.finishedItemName}`}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Created {new Date(r.createdAt).toLocaleDateString()}
|
||||
{r.completedAt && <> · Completed {new Date(r.completedAt).toLocaleDateString()}</>}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="hidden size-5 shrink-0 text-muted-foreground sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StageProgressStrip status={r.status} summary={r.stageSummary} className="mt-4" />
|
||||
|
||||
{/*
|
||||
Counts per status, not named stages. The list projection carries `stageSummary`
|
||||
only — which stage is in which state is on the run detail — so naming them here
|
||||
would mean guessing an allocation, which is what the mock used to do.
|
||||
*/}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{STAGE_STATUS_ORDER.filter((key) => r.stageSummary[SUMMARY_KEYS[key]] > 0).map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted/50 px-2.5 py-1 text-xs font-medium text-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Rework #{r.reworkCount}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[key] }} />
|
||||
{r.stageSummary[SUMMARY_KEYS[key]]} {STAGE_STATUS_LABEL[key]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{r.templateName} · {r.warehouseName}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2 sm:items-center">
|
||||
<div className="flex flex-col text-left sm:text-right">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{r.targetQty.toLocaleString()} {r.uom} · {r.finishedItemName}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Created {new Date(r.createdAt).toLocaleDateString()}
|
||||
{r.completedAt && <> · Completed {new Date(r.completedAt).toLocaleDateString()}</>}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="hidden size-5 shrink-0 text-muted-foreground sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<StageProgressStrip status={r.status} summary={r.stageSummary} className="mt-4" />
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{buildStagePlan(r.templateName, r.stageSummary).map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted/50 px-2.5 py-1 text-xs font-medium text-foreground"
|
||||
>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[s.state] }} />
|
||||
{s.name}
|
||||
<span className="text-muted-foreground">· {STAGE_STATUS_LABEL[s.state]}</span>
|
||||
</span>
|
||||
))}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-base text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={pagination.page >= pagination.totalPages}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+176
-121
@@ -3,7 +3,16 @@
|
||||
import { Plus, Trash2, X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FieldDef, FieldType, FormulaInput, FormulaOutput, InputSource, MockItem, StageNodeData } from "./types"
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import {
|
||||
BuilderFieldDef,
|
||||
BuilderInput,
|
||||
BuilderOutput,
|
||||
StageNodeData,
|
||||
newKey,
|
||||
newLocalId,
|
||||
} from "./types"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
@@ -15,65 +24,138 @@ function slugify(label: string) {
|
||||
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
function newId() {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
|
||||
const FIELD_TYPES: FieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
|
||||
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
|
||||
|
||||
export interface UpstreamOutputOption {
|
||||
stageId: string
|
||||
stageKey: string
|
||||
stageName: string
|
||||
outputId: string
|
||||
outputKey: string
|
||||
outputName: string
|
||||
}
|
||||
|
||||
/** One row per input/output quantity — UOM select plus qty, used three times below. */
|
||||
function QtyRow({
|
||||
qty,
|
||||
uomId,
|
||||
uoms,
|
||||
readOnly,
|
||||
onQtyChange,
|
||||
onUomChange,
|
||||
}: {
|
||||
qty: number
|
||||
uomId: number | null
|
||||
uoms: Uom[]
|
||||
readOnly: boolean
|
||||
onQtyChange: (qty: number) => void
|
||||
onUomChange: (uomId: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={qty}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onQtyChange(Number(e.target.value) || 0)}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Qty per batch"
|
||||
aria-label="Quantity per batch"
|
||||
/>
|
||||
<Select<number> value={uomId} onValueChange={(v) => v && onUomChange(v)}>
|
||||
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="UOM">
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StageEditorPanel({
|
||||
nodeId,
|
||||
data,
|
||||
isTerminal,
|
||||
upstreamOptions,
|
||||
items,
|
||||
uoms,
|
||||
readOnly,
|
||||
onChange,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
nodeId: string
|
||||
data: StageNodeData
|
||||
isTerminal: boolean
|
||||
upstreamOptions: UpstreamOutputOption[]
|
||||
items: MockItem[]
|
||||
items: ItemListItem[]
|
||||
uoms: Uom[]
|
||||
readOnly: boolean
|
||||
onChange: (patch: Partial<StageNodeData>) => void
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
function updateInput(inputId: string, patch: Partial<FormulaInput>) {
|
||||
onChange({ inputs: data.inputs.map((i) => (i.inputId === inputId ? { ...i, ...patch } : i)) })
|
||||
function updateInput(localId: string, patch: Partial<BuilderInput>) {
|
||||
onChange({ inputs: data.inputs.map((i) => (i.localId === localId ? { ...i, ...patch } : i)) })
|
||||
}
|
||||
function addInput() {
|
||||
onChange({ inputs: [...data.inputs, { inputId: newId(), source: "Stock" as InputSource, qty: 1 }] })
|
||||
onChange({
|
||||
inputs: [
|
||||
...data.inputs,
|
||||
{ localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, uomId: null, qtyPerBatch: 1 },
|
||||
],
|
||||
})
|
||||
}
|
||||
function removeInput(inputId: string) {
|
||||
onChange({ inputs: data.inputs.filter((i) => i.inputId !== inputId) })
|
||||
function removeInput(localId: string) {
|
||||
onChange({ inputs: data.inputs.filter((i) => i.localId !== localId) })
|
||||
}
|
||||
|
||||
function updateOutput(outputId: string, patch: Partial<FormulaOutput>) {
|
||||
onChange({ outputs: data.outputs.map((o) => (o.outputId === outputId ? { ...o, ...patch } : o)) })
|
||||
/**
|
||||
* Switching source clears the other side's field. Leaving a stale `itemId` on an Upstream
|
||||
* input (or a stale `fromOutputKey` on a Stock one) is a 422 GRAPH_INPUT_SOURCE_INVALID —
|
||||
* the validator rejects an input that carries both.
|
||||
*/
|
||||
function changeInputSource(localId: string, source: StageInputSource) {
|
||||
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
|
||||
}
|
||||
|
||||
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
|
||||
function pickInputItem(input: BuilderInput, itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
|
||||
}
|
||||
|
||||
function updateOutput(key: string, patch: Partial<BuilderOutput>) {
|
||||
onChange({ outputs: data.outputs.map((o) => (o.key === key ? { ...o, ...patch } : o)) })
|
||||
}
|
||||
function addOutput() {
|
||||
onChange({ outputs: [...data.outputs, { outputId: newId(), name: "", uom: "PCS", qty: 1 }] })
|
||||
onChange({
|
||||
outputs: [...data.outputs, { key: newKey(), itemId: null, name: "", uomId: null, qtyPerBatch: 1 }],
|
||||
})
|
||||
}
|
||||
function removeOutput(outputId: string) {
|
||||
onChange({ outputs: data.outputs.filter((o) => o.outputId !== outputId) })
|
||||
function removeOutput(key: string) {
|
||||
onChange({ outputs: data.outputs.filter((o) => o.key !== key) })
|
||||
}
|
||||
|
||||
function updateField(fieldId: string, patch: Partial<FieldDef>) {
|
||||
/** The terminal output's name mirrors the finished item, so the two can't drift apart. */
|
||||
function pickOutputItem(output: BuilderOutput, itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
updateOutput(output.key, {
|
||||
itemId,
|
||||
name: item?.name ?? output.name,
|
||||
uomId: output.uomId ?? item?.baseUomId ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
function updateField(localId: string, patch: Partial<BuilderFieldDef>) {
|
||||
onChange({
|
||||
fieldDefs: data.fieldDefs.map((f) => {
|
||||
if (f.fieldId !== fieldId) return f
|
||||
if (f.localId !== localId) return f
|
||||
const next = { ...f, ...patch }
|
||||
if (patch.label !== undefined) next.key = slugify(patch.label) || f.key
|
||||
return next
|
||||
@@ -82,11 +164,14 @@ export function StageEditorPanel({
|
||||
}
|
||||
function addField() {
|
||||
onChange({
|
||||
fieldDefs: [...data.fieldDefs, { fieldId: newId(), key: "", label: "", type: "Text", options: [], required: false }],
|
||||
fieldDefs: [
|
||||
...data.fieldDefs,
|
||||
{ localId: newLocalId(), key: "", label: "", type: "Text", options: [], required: false },
|
||||
],
|
||||
})
|
||||
}
|
||||
function removeField(fieldId: string) {
|
||||
onChange({ fieldDefs: data.fieldDefs.filter((f) => f.fieldId !== fieldId) })
|
||||
function removeField(localId: string) {
|
||||
onChange({ fieldDefs: data.fieldDefs.filter((f) => f.localId !== localId) })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -145,13 +230,13 @@ export function StageEditorPanel({
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.inputId} className="rounded-lg border border-border p-2.5">
|
||||
<div key={input.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<InputSource>
|
||||
<Select<StageInputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && updateInput(input.inputId, { source: v })}
|
||||
onValueChange={(v) => v && changeInputSource(input.localId, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -160,70 +245,56 @@ export function StageEditorPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.inputId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{input.source === "Stock" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{input.source === "Stock" ? (
|
||||
<Select<number>
|
||||
value={input.itemId ?? null}
|
||||
onValueChange={(v) => {
|
||||
const item = items.find((i) => i.itemId === v)
|
||||
updateInput(input.inputId, { itemId: v ?? undefined, itemName: item?.name, uom: item?.uom })
|
||||
}}
|
||||
value={input.itemId}
|
||||
onValueChange={(v) => v && pickInputItem(input, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-sm" disabled={readOnly}>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">{i.name}</SelectItem>
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={input.qty ?? 0}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateInput(input.inputId, { qty: Number(e.target.value) || 0 })}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Qty"
|
||||
/>
|
||||
<span className="w-14 shrink-0 text-sm text-muted-foreground">{input.uom ?? "—"}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={input.batch ?? ""}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateInput(input.inputId, { batch: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Batch (optional)"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.upstreamOutputId ?? null}
|
||||
onValueChange={(v) => {
|
||||
const opt = upstreamOptions.find((o) => o.outputId === v)
|
||||
updateInput(input.inputId, { upstreamOutputId: v ?? undefined, upstreamStageId: opt?.stageId })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputId} value={o.outputId} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.fromOutputKey}
|
||||
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -245,22 +316,18 @@ export function StageEditorPanel({
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.outputId} className="rounded-lg border border-border p-2.5">
|
||||
<div key={output.key} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number>
|
||||
value={output.itemId ?? null}
|
||||
onValueChange={(v) => {
|
||||
const item = items.find((i) => i.itemId === v)
|
||||
updateOutput(output.outputId, { itemId: v ?? undefined, name: item?.name ?? "", uom: item?.uom ?? output.uom })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
|
||||
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">{i.name}</SelectItem>
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -268,35 +335,25 @@ export function StageEditorPanel({
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.outputId, { name: e.target.value })}
|
||||
placeholder="Output name"
|
||||
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
|
||||
placeholder="Output name (work in progress)"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.outputId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={output.qty}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.outputId, { qty: Number(e.target.value) || 0 })}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Qty"
|
||||
/>
|
||||
<Input
|
||||
value={output.uom}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.outputId, { uom: e.target.value })}
|
||||
className="h-8 w-20 text-sm"
|
||||
placeholder="UOM"
|
||||
/>
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -316,28 +373,28 @@ export function StageEditorPanel({
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.fieldId} className="rounded-lg border border-border p-2.5">
|
||||
<div key={field.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.fieldId, { label: e.target.value })}
|
||||
onChange={(e) => updateField(field.localId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.fieldId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<FieldType>
|
||||
<Select<CustomFieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.fieldId, { type: v })}
|
||||
onValueChange={(v) => v && updateField(field.localId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -351,7 +408,7 @@ export function StageEditorPanel({
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.fieldId, { required: checked })}
|
||||
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
@@ -360,7 +417,7 @@ export function StageEditorPanel({
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.fieldId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Options, comma separated"
|
||||
className="mt-2 h-8 text-sm"
|
||||
/>
|
||||
@@ -380,5 +437,3 @@ export function StageEditorPanel({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { newId }
|
||||
|
||||
@@ -18,7 +18,9 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-56 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
|
||||
selected ? "ring-primary" : "ring-foreground/10",
|
||||
// `focused` wins over `selected`: it marks the stage the server's 422 named, and the
|
||||
// click that selects a node must not hide the reason the save failed.
|
||||
data.focused ? "ring-destructive" : selected ? "ring-primary" : "ring-foreground/10",
|
||||
disconnected && "opacity-40"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useSearchParams } from "next/navigation"
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
addEdge,
|
||||
applyEdgeChanges,
|
||||
@@ -19,47 +20,94 @@ import {
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import { useTheme } from "next-themes"
|
||||
import { AlertTriangle, Lock, Minus, Plus, Save, Square } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, Lock, Minus, Plus, Save, Square } from "lucide-react"
|
||||
|
||||
import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import StageNode from "./StageNode"
|
||||
import AnnotationBoxNode, { LineNodeComponent } from "./AnnotationNodes"
|
||||
import { StageEditorPanel, type UpstreamOutputOption, newId } from "./StageEditorPanel"
|
||||
import { AnnotationData, MockItem, StageNodeData } from "./types"
|
||||
|
||||
const MOCK_ITEMS: MockItem[] = [
|
||||
{ itemId: 101, name: "Steel Sheet 2mm", uom: "KG" },
|
||||
{ itemId: 102, name: "Screws M4", uom: "PCS" },
|
||||
{ itemId: 103, name: "Steel Bracket A", uom: "PCS" },
|
||||
{ itemId: 104, name: "PCB Board X", uom: "PCS" },
|
||||
{ itemId: 105, name: "Solder Wire", uom: "M" },
|
||||
{ itemId: 106, name: "Electronic Component Kit", uom: "SET" },
|
||||
{ itemId: 107, name: "Wood Plank", uom: "PCS" },
|
||||
{ itemId: 108, name: "Pallet Standard", uom: "PCS" },
|
||||
{ itemId: 109, name: "Cable Wire", uom: "M" },
|
||||
{ itemId: 110, name: "Harness Kit B", uom: "SET" },
|
||||
]
|
||||
import { StageEditorPanel, type UpstreamOutputOption } from "./StageEditorPanel"
|
||||
import { AnnotationData, StageNodeData, newKey, newLocalId } from "./types"
|
||||
|
||||
const nodeTypes = { stage: StageNode, box: AnnotationBoxNode, line: LineNodeComponent }
|
||||
|
||||
function buildInitialGraph(stageNames: string[]): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = stageNames.map((name, i) => ({
|
||||
id: `n${i + 1}`,
|
||||
const DEFAULT_BOX = { width: 320, height: 220 }
|
||||
const DEFAULT_LINE = { width: 220, height: 4 }
|
||||
|
||||
/**
|
||||
* Server graph -> React Flow.
|
||||
*
|
||||
* A stage's node id **is** its server key, which is why edges need no translation in either
|
||||
* direction: `edge.source`/`edge.target` are already the `parentKey`/`childKey` the save
|
||||
* payload wants. Annotations come first in the array because React Flow paints later entries
|
||||
* on top, and a grouping box belongs behind the stage cards it groups.
|
||||
*/
|
||||
function graphToFlow(graph: ProductionTemplateGraph): { nodes: Node[]; edges: Edge[] } {
|
||||
const annotations: Node[] = graph.annotations.map((a) => ({
|
||||
id: `ann-${newLocalId()}`,
|
||||
type: a.kind,
|
||||
position: { x: a.posX, y: a.posY },
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
data: { label: a.label ?? "", rotation: a.rotation ?? undefined } satisfies AnnotationData,
|
||||
}))
|
||||
|
||||
const stages: Node[] = graph.stages.map((s) => ({
|
||||
id: s.key,
|
||||
type: "stage",
|
||||
position: { x: i * 280 + 40, y: 120 },
|
||||
data: { name, roleLabel: "", estimatedMinutes: 15, inputs: [], outputs: [], fieldDefs: [] } satisfies StageNodeData,
|
||||
position: { x: s.posX, y: s.posY },
|
||||
data: {
|
||||
name: s.name,
|
||||
roleLabel: s.roleLabel ?? "",
|
||||
estimatedMinutes: s.estimatedMinutes,
|
||||
inputs: s.inputs.map((i) => ({
|
||||
localId: newLocalId(),
|
||||
source: i.source,
|
||||
itemId: i.itemId,
|
||||
fromOutputKey: i.fromOutputKey,
|
||||
uomId: i.uomId,
|
||||
qtyPerBatch: i.qtyPerBatch,
|
||||
})),
|
||||
outputs: s.outputs.map((o) => ({
|
||||
key: o.key,
|
||||
itemId: o.itemId,
|
||||
name: o.name,
|
||||
uomId: o.uomId,
|
||||
qtyPerBatch: o.qtyPerBatch,
|
||||
})),
|
||||
fieldDefs: s.fieldDefs.map((f) => ({
|
||||
localId: newLocalId(),
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
options: f.options ?? [],
|
||||
required: f.required,
|
||||
})),
|
||||
} satisfies StageNodeData,
|
||||
}))
|
||||
const edges: Edge[] = stageNames.slice(1).map((_, i) => ({
|
||||
id: `e${i + 1}`,
|
||||
source: `n${i + 1}`,
|
||||
target: `n${i + 2}`,
|
||||
|
||||
const edges: Edge[] = graph.edges.map((e) => ({
|
||||
id: `e${e.edgeId}`,
|
||||
source: e.parentKey,
|
||||
target: e.childKey,
|
||||
}))
|
||||
return { nodes, edges }
|
||||
|
||||
return { nodes: [...annotations, ...stages], edges }
|
||||
}
|
||||
|
||||
/** Kahn's algorithm — returns the ids left over (unprocessable) once no more in-degree-0 nodes exist, i.e. the cycle. */
|
||||
/** Kahn's algorithm — true when the toposort can't reach every node, i.e. there's a cycle. */
|
||||
function detectCycle(nodes: Node[], edges: Edge[]): boolean {
|
||||
const inDegree = new Map(nodes.map((n) => [n.id, 0]))
|
||||
for (const e of edges) inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1)
|
||||
@@ -77,36 +125,118 @@ function detectCycle(nodes: Node[], edges: Edge[]): boolean {
|
||||
return visited !== nodes.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort mapping from a `422 GRAPH_*` detail back to the stages it names.
|
||||
*
|
||||
* The validator's messages quote real stage names ("...contains a cycle involving: Cut frame,
|
||||
* Assemble"), so a substring match finds them without the server having to return keys. It is
|
||||
* deliberately advisory: the full message is always shown in the banner too, so a stage renamed
|
||||
* to something ambiguous costs a highlight, never the explanation.
|
||||
*/
|
||||
function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<string> {
|
||||
if (!detail) return new Set()
|
||||
const named = stageNodes.filter((n) => {
|
||||
const name = (n.data as StageNodeData).name.trim()
|
||||
return name.length > 0 && detail.includes(name)
|
||||
})
|
||||
return new Set(named.map((n) => n.id))
|
||||
}
|
||||
|
||||
export default function TemplateBuilderPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
// A template just created on the list page (see app/dashboard/production/templates/page.tsx
|
||||
// handleCreate) — no backend exists to look it up by id, so its name/blank graph arrive via
|
||||
// the URL instead. Every other id falls back to the 5 seeded mock templates.
|
||||
const isFresh = searchParams.get("fresh") === "1"
|
||||
const freshName = searchParams.get("name")
|
||||
const template = isFresh && freshName
|
||||
? { name: freshName, activeRunCount: 0, stages: [] as string[] }
|
||||
: (MOCK_TEMPLATE_INFO[params.id] ?? { name: `Template #${params.id}`, activeRunCount: 0, stages: ["Stage 1"] })
|
||||
const locked = template.activeRunCount > 0
|
||||
// "new" is a draft that exists only in this page until the first successful Save. A template
|
||||
// cannot be created from a name alone — the server requires at least one stage and a terminal
|
||||
// output naming a real finished item (FR-MFG-02/05) — so there is nothing to POST up front.
|
||||
const isNew = params.id === "new"
|
||||
const templateId = Number(params.id)
|
||||
|
||||
// Deliberately only keyed on the id, not `template.stages` — this is the seed for
|
||||
// uncontrolled node/edge state below, meant to run once per template, not on every
|
||||
// in-place edit (which also changes what buildInitialGraph would return via stageNodes).
|
||||
const initial = useMemo(() => buildInitialGraph(template.stages), [params.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const [nodes, setNodes] = useState<Node[]>(initial.nodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initial.edges)
|
||||
const [graph, setGraph] = useState<ProductionTemplateGraph | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
// Derived rather than its own state: a draft is ready immediately, and a saved template is
|
||||
// ready as soon as the GET resolves either way.
|
||||
const loaded = isNew || graph !== null || loadError !== null
|
||||
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [edges, setEdges] = useState<Edge[]>([])
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [lockedByServer, setLockedByServer] = useState(false)
|
||||
const [focusedKeys, setFocusedKeys] = useState<Set<string>>(new Set())
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
|
||||
// `lockedByServer` covers the edit-lock TOCTOU: a run can start between our GET and our PUT,
|
||||
// in which case the 409 is the first we hear of it (FR-MFG-06).
|
||||
const activeRunCount = graph?.activeRunCount ?? 0
|
||||
const locked = activeRunCount > 0 || lockedByServer
|
||||
|
||||
// `resolvedTheme` is unknown on the server (and on the client's first paint, before
|
||||
// next-themes reads localStorage), so `colorMode` below would differ between the SSR
|
||||
// markup and the client's first render — same hydration-mismatch class theme-toggle.tsx
|
||||
// already guards against. Render the canvas only once mounted.
|
||||
// next-themes reads localStorage), so `colorMode` would differ between the SSR markup and the
|
||||
// first client render — the same hydration-mismatch class theme-toggle.tsx guards against.
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
const applyGraph = useCallback((data: ProductionTemplateGraph, tag: string | null) => {
|
||||
const flow = graphToFlow(data)
|
||||
setGraph(data)
|
||||
setEtag(tag)
|
||||
setCode(data.code)
|
||||
setName(data.name)
|
||||
setDescription(data.description ?? "")
|
||||
setNodes(flow.nodes)
|
||||
setEdges(flow.edges)
|
||||
setSelectedNodeId(null)
|
||||
setConflict(false)
|
||||
setLockedByServer(false)
|
||||
setFocusedKeys(new Set())
|
||||
setSaveError(null)
|
||||
}, [])
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null)
|
||||
productionTemplatesApi
|
||||
.get(templateId)
|
||||
.then(({ data, etag: tag }) => applyGraph(data, tag))
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [templateId, applyGraph])
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
// Seeded from the overview's "New Template" dialog.
|
||||
setCode(searchParams.get("code") ?? "")
|
||||
setName(searchParams.get("name") ?? "")
|
||||
return
|
||||
}
|
||||
if (Number.isFinite(templateId)) load()
|
||||
// searchParams is read once for the draft seed; re-running on every query change would
|
||||
// overwrite what the user has typed since.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNew, templateId, load])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
|
||||
.then(([itemRes, uomRes]) => {
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
})
|
||||
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[]
|
||||
@@ -136,18 +266,16 @@ export default function TemplateBuilderPage() {
|
||||
const onPaneClick = useCallback(() => setSelectedNodeId(null), [])
|
||||
|
||||
// Guarded here, not just by hiding the toolbar/panel controls: `elementsSelectable` stays
|
||||
// true even when locked (so a locked template can still be inspected), and these two
|
||||
// setters go straight to setNodes/setEdges — they don't route through onNodesChange, which
|
||||
// is what actually gets set to `undefined` when locked. Without this check, a locked
|
||||
// template's box/line labels, rotation, and now the inline delete buttons would all still
|
||||
// be editable via those paths.
|
||||
// true even when locked (so a locked template can still be inspected), and these two setters
|
||||
// go straight to setNodes/setEdges — they don't route through onNodesChange, which is what
|
||||
// actually gets set to `undefined` when locked.
|
||||
function updateNodeData(nodeId: string, patch: Partial<StageNodeData> | Partial<AnnotationData>) {
|
||||
if (locked) return
|
||||
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...patch } } : n)))
|
||||
}
|
||||
|
||||
function addStage() {
|
||||
const id = `n${newId()}`
|
||||
const id = newKey()
|
||||
const existingStages = nodes.filter((n) => n.type === "stage")
|
||||
const maxX = existingStages.reduce((max, n) => Math.max(max, n.position.x), 0)
|
||||
const y = existingStages.length > 0 ? existingStages[existingStages.length - 1].position.y : 120
|
||||
@@ -157,15 +285,22 @@ export default function TemplateBuilderPage() {
|
||||
id,
|
||||
type: "stage",
|
||||
position: { x: existingStages.length > 0 ? maxX + 280 : 40, y },
|
||||
data: { name: "New stage", roleLabel: "", estimatedMinutes: 15, inputs: [], outputs: [], fieldDefs: [] } satisfies StageNodeData,
|
||||
data: {
|
||||
name: "New stage",
|
||||
roleLabel: "",
|
||||
estimatedMinutes: 15,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
fieldDefs: [],
|
||||
} satisfies StageNodeData,
|
||||
},
|
||||
])
|
||||
setSelectedNodeId(id)
|
||||
}
|
||||
|
||||
// Generic across every node type — stage cards, boxes, lines all use this (inline × buttons
|
||||
// on the nodes themselves, plus the stage editor panel's "Delete stage" button). Box/line
|
||||
// nodes never have edges, so the edge-filter is a no-op for them, not a special case.
|
||||
// Generic across every node type — stage cards, boxes and lines all use this (the inline ×
|
||||
// buttons on the nodes themselves, plus the stage editor's "Delete stage"). Box/line nodes
|
||||
// never have edges, so the edge filter is a no-op for them, not a special case.
|
||||
function deleteNode(nodeId: string) {
|
||||
if (locked) return
|
||||
setNodes((nds) => nds.filter((n) => n.id !== nodeId))
|
||||
@@ -173,30 +308,14 @@ export default function TemplateBuilderPage() {
|
||||
setSelectedNodeId((id) => (id === nodeId ? null : id))
|
||||
}
|
||||
|
||||
// Boxes/lines are prepended (not appended) so React Flow — which paints later array
|
||||
// entries on top — renders them behind the stage nodes.
|
||||
function addBox() {
|
||||
function addAnnotation(kind: "box" | "line") {
|
||||
const size = kind === "box" ? DEFAULT_BOX : DEFAULT_LINE
|
||||
setNodes((nds) => [
|
||||
{
|
||||
id: `a${newId()}`,
|
||||
type: "box",
|
||||
position: { x: 40, y: 40 },
|
||||
width: 320,
|
||||
height: 220,
|
||||
data: { label: "" } satisfies AnnotationData,
|
||||
},
|
||||
...nds,
|
||||
])
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setNodes((nds) => [
|
||||
{
|
||||
id: `a${newId()}`,
|
||||
type: "line",
|
||||
position: { x: 60, y: 300 },
|
||||
width: 220,
|
||||
height: 4,
|
||||
id: `ann-${newLocalId()}`,
|
||||
type: kind,
|
||||
position: kind === "box" ? { x: 40, y: 40 } : { x: 60, y: 300 },
|
||||
...size,
|
||||
data: { label: "" } satisfies AnnotationData,
|
||||
},
|
||||
...nds,
|
||||
@@ -205,15 +324,14 @@ export default function TemplateBuilderPage() {
|
||||
|
||||
// React Flow's built-in keyboard delete (Backspace/Delete on a selected node) goes through
|
||||
// this callback, not through deleteNode() above — stage edges need cleaning up either way.
|
||||
// Box/line nodes never have edges, so this is a no-op for them.
|
||||
const onNodesDelete = useCallback((deleted: Node[]) => {
|
||||
const deletedIds = new Set(deleted.map((n) => n.id))
|
||||
setEdges((eds) => eds.filter((e) => !deletedIds.has(e.source) && !deletedIds.has(e.target)))
|
||||
}, [])
|
||||
|
||||
// Boxes/lines are pure annotations — never part of the stage graph, so every graph check
|
||||
// below operates on stage nodes only (docs/21-FRONTEND-PHASE2.md §2 "Client-side graph
|
||||
// checks (UX only — server re-validates on save)").
|
||||
// Boxes and lines are pure annotations — never part of the stage graph, so every graph check
|
||||
// below operates on stage nodes only (docs/21 §2 "Client-side graph checks (UX only — server
|
||||
// re-validates on save)").
|
||||
const stageNodes = useMemo(() => nodes.filter((n) => n.type === "stage"), [nodes])
|
||||
|
||||
const analysis = useMemo(() => {
|
||||
@@ -228,20 +346,33 @@ export default function TemplateBuilderPage() {
|
||||
return { terminalIds, entryIds, disconnectedIds, hasCycle }
|
||||
}, [stageNodes, edges])
|
||||
|
||||
// Clear stale Upstream references after an edge is deleted, with a warning toast — per
|
||||
/** Output keys a stage may legally draw from — its *direct* parents' outputs (FR-MFG-04). */
|
||||
const allowedUpstreamKeys = useCallback(
|
||||
(stageId: string) => {
|
||||
const parentIds = new Set(edges.filter((e) => e.target === stageId).map((e) => e.source))
|
||||
return new Set(
|
||||
nodes
|
||||
.filter((n) => n.type === "stage" && parentIds.has(n.id))
|
||||
.flatMap((n) => (n.data as StageNodeData).outputs.map((o) => o.key))
|
||||
)
|
||||
},
|
||||
[edges, nodes]
|
||||
)
|
||||
|
||||
// Clear stale Upstream references after an edge is deleted, with a warning toast — docs/21 §2
|
||||
// "re-check after edge deletions and clear broken references with a warning toast".
|
||||
useEffect(() => {
|
||||
for (const node of stageNodes) {
|
||||
const data = node.data as StageNodeData
|
||||
const directParentIds = new Set(edges.filter((e) => e.target === node.id).map((e) => e.source))
|
||||
const stale = data.inputs.filter((i) => i.source === "Upstream" && i.upstreamStageId && !directParentIds.has(i.upstreamStageId))
|
||||
const allowed = allowedUpstreamKeys(node.id)
|
||||
const stale = data.inputs.filter(
|
||||
(i) => i.source === "Upstream" && i.fromOutputKey && !allowed.has(i.fromOutputKey)
|
||||
)
|
||||
if (stale.length > 0) {
|
||||
updateNodeData(node.id, {
|
||||
inputs: data.inputs.map((i) =>
|
||||
stale.includes(i) ? { ...i, upstreamStageId: undefined, upstreamOutputId: undefined } : i
|
||||
),
|
||||
inputs: data.inputs.map((i) => (stale.includes(i) ? { ...i, fromOutputKey: null } : i)),
|
||||
})
|
||||
toast.warning("Input reference cleared", `"${data.name}" referenced a stage that's no longer connected.`)
|
||||
toast.warning("Input reference cleared", `"${data.name}" drew from a stage that no longer feeds it.`)
|
||||
}
|
||||
}
|
||||
// Only re-run when the edge set changes — re-running on every node data edit would loop.
|
||||
@@ -250,28 +381,61 @@ export default function TemplateBuilderPage() {
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const list: string[] = []
|
||||
if (!code.trim()) list.push("Code is required.")
|
||||
if (!name.trim()) list.push("Name is required.")
|
||||
if (stageNodes.length === 0) list.push("Add at least one stage.")
|
||||
|
||||
if (analysis.hasCycle) list.push("Cycle detected — stages must form a one-directional flow.")
|
||||
if (analysis.terminalIds.size !== 1) {
|
||||
if (stageNodes.length > 0 && analysis.terminalIds.size !== 1) {
|
||||
list.push(
|
||||
analysis.terminalIds.size === 0
|
||||
? "No terminal stage — connect stages so the line converges to a single final stage."
|
||||
: `${analysis.terminalIds.size} terminal stages found — connect stages so the line converges to a single final stage.`
|
||||
)
|
||||
}
|
||||
if (analysis.entryIds.size === 0) list.push("No entry stage — at least one stage must have no inputs from other stages.")
|
||||
if (stageNodes.length > 0 && analysis.entryIds.size === 0) {
|
||||
list.push("No entry stage — at least one stage must have no inputs from other stages.")
|
||||
}
|
||||
if (analysis.disconnectedIds.size > 0) {
|
||||
const names = stageNodes.filter((n) => analysis.disconnectedIds.has(n.id)).map((n) => (n.data as StageNodeData).name)
|
||||
list.push(`Disconnected stage${names.length > 1 ? "s" : ""}: ${names.join(", ")}.`)
|
||||
}
|
||||
|
||||
// Field-completeness. These mirror the server's own requirements, and they are what make
|
||||
// the non-null assertions in buildRequest() below sound — a row is never sent half-filled.
|
||||
for (const node of stageNodes) {
|
||||
if (!analysis.terminalIds.has(node.id)) continue
|
||||
const data = node.data as StageNodeData
|
||||
if (data.outputs.length === 0 || data.outputs.some((o) => !o.itemId)) {
|
||||
list.push(`Terminal stage "${data.name}" needs an output with a finished-good item picked.`)
|
||||
const label = data.name.trim() || "Untitled stage"
|
||||
const isTerminal = analysis.terminalIds.has(node.id)
|
||||
|
||||
if (!data.name.trim()) list.push("Every stage needs a name.")
|
||||
|
||||
data.inputs.forEach((input, i) => {
|
||||
const where = `Input ${i + 1} of "${label}"`
|
||||
if (input.source === "Stock" && input.itemId === null) list.push(`${where} needs an item.`)
|
||||
if (input.source === "Upstream" && !input.fromOutputKey) list.push(`${where} needs an upstream output.`)
|
||||
if (input.uomId === null) list.push(`${where} needs a UOM.`)
|
||||
if (input.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
|
||||
})
|
||||
|
||||
data.outputs.forEach((output, i) => {
|
||||
const where = `Output ${i + 1} of "${label}"`
|
||||
if (!isTerminal && !output.name.trim()) list.push(`${where} needs a name.`)
|
||||
if (output.uomId === null) list.push(`${where} needs a UOM.`)
|
||||
if (output.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
|
||||
})
|
||||
|
||||
if (isTerminal) {
|
||||
if (data.outputs.length !== 1) {
|
||||
list.push(`Terminal stage "${label}" must have exactly one output (the finished good).`)
|
||||
} else if (data.outputs[0].itemId === null) {
|
||||
list.push(`Terminal stage "${label}" needs its output linked to a finished-good item.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}, [analysis, stageNodes])
|
||||
}, [analysis, stageNodes, code, name])
|
||||
|
||||
const selectedNode = stageNodes.find((n) => n.id === selectedNodeId)
|
||||
const upstreamOptions: UpstreamOutputOption[] = useMemo(() => {
|
||||
@@ -282,9 +446,9 @@ export default function TemplateBuilderPage() {
|
||||
if (!parent) return []
|
||||
const parentData = parent.data as StageNodeData
|
||||
return parentData.outputs.map((o) => ({
|
||||
stageId: parent.id,
|
||||
stageKey: parent.id,
|
||||
stageName: parentData.name,
|
||||
outputId: o.outputId,
|
||||
outputKey: o.key,
|
||||
outputName: o.name || "(unnamed output)",
|
||||
}))
|
||||
})
|
||||
@@ -299,6 +463,7 @@ export default function TemplateBuilderPage() {
|
||||
data: {
|
||||
...n.data,
|
||||
disconnected: analysis.disconnectedIds.has(n.id),
|
||||
focused: focusedKeys.has(n.id),
|
||||
onDelete: locked ? undefined : () => deleteNode(n.id),
|
||||
},
|
||||
}
|
||||
@@ -312,64 +477,288 @@ export default function TemplateBuilderPage() {
|
||||
},
|
||||
}
|
||||
),
|
||||
[nodes, analysis.disconnectedIds] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[nodes, analysis.disconnectedIds, focusedKeys] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
|
||||
function handleSave() {
|
||||
function buildRequest(): SaveTemplateRequest {
|
||||
const stages = stageNodes.map((n) => {
|
||||
const data = n.data as StageNodeData
|
||||
const isTerminal = analysis.terminalIds.has(n.id)
|
||||
return {
|
||||
key: n.id,
|
||||
name: data.name.trim(),
|
||||
roleLabel: data.roleLabel.trim() || null,
|
||||
estimatedMinutes: data.estimatedMinutes,
|
||||
posX: Math.round(n.position.x),
|
||||
posY: Math.round(n.position.y),
|
||||
fieldDefs: data.fieldDefs.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
options: f.type === "Select" ? f.options : null,
|
||||
required: f.required,
|
||||
})),
|
||||
inputs: data.inputs.map((i) => ({
|
||||
source: i.source,
|
||||
itemId: i.source === "Stock" ? i.itemId : null,
|
||||
fromOutputKey: i.source === "Upstream" ? i.fromOutputKey : null,
|
||||
uomId: i.uomId!,
|
||||
qtyPerBatch: i.qtyPerBatch,
|
||||
})),
|
||||
// Only the terminal stage's output may name an item (FR-MFG-05). A stage that *was*
|
||||
// terminal and then got a child keeps its picked itemId in local state with the item
|
||||
// field no longer rendered, so dropping it here is the only way the user can recover —
|
||||
// an issue-list message about an invisible field would be unactionable.
|
||||
outputs: data.outputs.map((o) => ({
|
||||
key: o.key,
|
||||
itemId: isTerminal ? o.itemId : null,
|
||||
name: o.name.trim(),
|
||||
uomId: o.uomId!,
|
||||
qtyPerBatch: o.qtyPerBatch,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const annotations: CanvasAnnotation[] = nodes
|
||||
.filter((n) => n.type === "box" || n.type === "line")
|
||||
.map((n) => {
|
||||
const data = n.data as AnnotationData
|
||||
const fallback = n.type === "box" ? DEFAULT_BOX : DEFAULT_LINE
|
||||
return {
|
||||
kind: n.type as "box" | "line",
|
||||
posX: Math.round(n.position.x),
|
||||
posY: Math.round(n.position.y),
|
||||
// `width`/`height` are set on creation and updated by NodeResizer; `measured` is what
|
||||
// React Flow fills in after layout for nodes sized purely by CSS.
|
||||
width: Math.round(n.width ?? n.measured?.width ?? fallback.width),
|
||||
height: Math.round(n.height ?? n.measured?.height ?? fallback.height),
|
||||
label: data.label.trim() || null,
|
||||
rotation: data.rotation ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
code: code.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
stages,
|
||||
edges: edges.map((e) => ({ parentKey: e.source, childKey: e.target })),
|
||||
annotations,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (issues.length > 0) {
|
||||
toast.error("Can't save yet", `${issues.length} issue${issues.length > 1 ? "s" : ""} to fix first.`)
|
||||
return
|
||||
}
|
||||
// No backend contract exists yet (docs/21-FRONTEND-PHASE2.md) — this is a UI-only stub.
|
||||
toast.success("Template saved", `${template.name} — ${stageNodes.length} stage${stageNodes.length === 1 ? "" : "s"}.`)
|
||||
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
setFocusedKeys(new Set())
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
const result = await productionTemplatesApi.create(buildRequest())
|
||||
toast.success("Template created", `${result.data.code} — ${result.data.name}`)
|
||||
// Swap the draft URL for the real one. The load effect re-runs on the new id and
|
||||
// rehydrates from the server, so keys minted here are replaced by real ones.
|
||||
router.replace(`/dashboard/production/templates/${result.data.templateId}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!etag) return
|
||||
const result = await productionTemplatesApi.update(templateId, buildRequest(), etag)
|
||||
applyGraph(result.data, result.etag)
|
||||
toast.success("Template saved", `${result.data.code} — ${result.data.stages.length} stage(s)`)
|
||||
} catch (err) {
|
||||
const errorCode = (err as { code?: string })?.code
|
||||
const detail = (err as { detail?: string })?.detail
|
||||
|
||||
if (errorCode === "CONCURRENCY_CONFLICT" || errorCode === "PRECONDITION_REQUIRED") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
return
|
||||
}
|
||||
if (errorCode === "TEMPLATE_IN_USE") {
|
||||
// A run started between our GET and this PUT. Lock the canvas rather than reloading,
|
||||
// so nothing the user just drew is thrown away without them seeing why.
|
||||
setLockedByServer(true)
|
||||
setSaveError(errorMessage(err))
|
||||
return
|
||||
}
|
||||
if (errorCode?.startsWith("GRAPH_") || errorCode === "TERMINAL_OUTPUT_ITEM_REQUIRED") {
|
||||
setFocusedKeys(stagesNamedIn(detail, stageNodes))
|
||||
}
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not save template", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus() {
|
||||
if (!graph) return
|
||||
const next: TemplateStatus = graph.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingStatus(true)
|
||||
try {
|
||||
await productionTemplatesApi.updateStatus(templateId, next)
|
||||
// PATCH /status bumps the row's xmin, which invalidates the ETag we hold. Re-read it (and
|
||||
// only it) so an unsaved canvas edit can still be saved afterwards — a full reload here
|
||||
// would silently discard the user's work.
|
||||
const refreshed = await productionTemplatesApi.get(templateId)
|
||||
setEtag(refreshed.etag)
|
||||
setGraph((g) =>
|
||||
g ? { ...g, status: refreshed.data.status, activeRunCount: refreshed.data.activeRunCount } : g
|
||||
)
|
||||
toast.success(next === "Active" ? "Template activated" : "Template deactivated")
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-[60vh] w-full rounded-2xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/production/templates" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to templates
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/production/templates" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{template.name}</h1>
|
||||
<p className="text-base text-muted-foreground">{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection{edges.length === 1 ? "" : "s"}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1>
|
||||
{graph ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
graph.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{graph.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-muted px-2.5 text-sm text-muted-foreground">
|
||||
Unsaved draft
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
|
||||
{edges.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!locked && (
|
||||
<Button type="button" variant="outline" onClick={addStage}>
|
||||
<Plus className="size-5" />
|
||||
Add Stage
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={addStage}>
|
||||
<Plus className="size-5" />
|
||||
Add Stage
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => addAnnotation("box")}>
|
||||
<Square className="size-5" />
|
||||
Add Box
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => addAnnotation("line")}>
|
||||
<Minus className="size-5" />
|
||||
Add Line
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{graph && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={graph.status === "Active" ? "destructive" : "success"}
|
||||
onClick={handleToggleStatus}
|
||||
disabled={togglingStatus}
|
||||
>
|
||||
{togglingStatus ? "Updating…" : graph.status === "Active" ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<Button type="button" variant="outline" onClick={addBox}>
|
||||
<Square className="size-5" />
|
||||
Add Box
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<Button type="button" variant="outline" onClick={addLine}>
|
||||
<Minus className="size-5" />
|
||||
Add Line
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<Button type="button" onClick={handleSave}>
|
||||
<Button type="button" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
Save
|
||||
{saving ? "Saving…" : isNew ? "Create" : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[10rem_1fr_1fr]">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
|
||||
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
|
||||
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tpl-description">Description</FieldLabel>
|
||||
<Input
|
||||
id="tpl-description"
|
||||
value={description}
|
||||
disabled={locked}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-10"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{locked && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
||||
<Lock className="size-4 shrink-0" />
|
||||
Template locked — {template.activeRunCount} run{template.activeRunCount === 1 ? "" : "s"} in progress.
|
||||
{activeRunCount > 0
|
||||
? `Template locked — ${activeRunCount} run${activeRunCount === 1 ? "" : "s"} in progress. It can be viewed but not edited until they finish.`
|
||||
: "Template locked — a run started while you were editing, so this template can no longer be changed."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>{saveError ?? "This template was changed by someone else."} Reload before retrying.</span>
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{saveError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<div className="flex max-h-32 flex-col gap-1.5 overflow-y-auto rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{issues.map((issue, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
@@ -407,11 +796,11 @@ export default function TemplateBuilderPage() {
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
nodeId={selectedNode.id}
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={MOCK_ITEMS}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
|
||||
@@ -1,73 +1,87 @@
|
||||
// Canvas builder types (docs/21-FRONTEND-PHASE2.md §2). Frontend-only shapes — no
|
||||
// Dtos/Production backend contract exists yet; these mirror the doc's described jsonb
|
||||
// shapes closely enough to swap in real API types later without touching the canvas/panel.
|
||||
// Canvas builder state (docs/21-FRONTEND-PHASE2.md §2), shaped for editing rather than for
|
||||
// the wire. `page.tsx` converts between these and the real contract in `types/production.ts`.
|
||||
//
|
||||
// Two things the API shapes can't express and the canvas needs:
|
||||
//
|
||||
// * **Stable React list keys.** Stage inputs and custom fields have no client-facing key in
|
||||
// the contract (only outputs do, because Upstream inputs reference them by key). Rendering
|
||||
// them by array index would make React reuse the wrong <input> when a row is removed, so
|
||||
// every editable row carries a throwaway `localId` that is stripped on save.
|
||||
// * **Half-filled rows.** `uomId` is `number | null` here but `number` on the wire: a row the
|
||||
// user just added has nothing picked yet. `page.tsx` blocks the save until every one is set,
|
||||
// which is what makes the non-null assertions in its payload builder sound.
|
||||
//
|
||||
// A stage's identity IS its React Flow node id, which is its server key — the stringified
|
||||
// stage id, or `tmp-<uuid>` for a stage drawn in this session. That is why edges need no
|
||||
// translation on save: `edge.source`/`edge.target` are already `parentKey`/`childKey`.
|
||||
|
||||
export type FieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select"
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
|
||||
export interface FieldDef {
|
||||
fieldId: string
|
||||
/** `tmp-` prefixed so the server can tell a newly drawn stage/output from one it already has. */
|
||||
export function newKey(): string {
|
||||
return `tmp-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
/** Render-only identity for rows the contract keys by position. Never sent. */
|
||||
export function newLocalId(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
export interface BuilderInput {
|
||||
localId: string
|
||||
source: StageInputSource
|
||||
/** Stock inputs only. */
|
||||
itemId: number | null
|
||||
/** Upstream inputs only — an output key belonging to a *direct* parent stage. */
|
||||
fromOutputKey: string | null
|
||||
uomId: number | null
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
export interface BuilderOutput {
|
||||
/** Server output id as a string, or `tmp-<uuid>`. Upstream inputs reference this. */
|
||||
key: string
|
||||
/** Terminal stage only — the finished good. Must stay null on WIP outputs (FR-MFG-05). */
|
||||
itemId: number | null
|
||||
name: string
|
||||
uomId: number | null
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
export interface BuilderFieldDef {
|
||||
localId: string
|
||||
/** Slugified from `label`; the run's `fieldValues` are keyed by it (FR-MFG-07). */
|
||||
key: string
|
||||
label: string
|
||||
type: FieldType
|
||||
/** Only meaningful when type === "Select". */
|
||||
type: CustomFieldType
|
||||
/** Only sent when `type` is `Select`. */
|
||||
options: string[]
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export type InputSource = "Stock" | "Upstream"
|
||||
|
||||
export interface FormulaInput {
|
||||
inputId: string
|
||||
source: InputSource
|
||||
// Stock source:
|
||||
itemId?: number
|
||||
itemName?: string
|
||||
uom?: string
|
||||
qty?: number
|
||||
batch?: string
|
||||
// Upstream source — references a direct parent stage's output:
|
||||
upstreamStageId?: string
|
||||
upstreamOutputId?: string
|
||||
}
|
||||
|
||||
export interface FormulaOutput {
|
||||
outputId: string
|
||||
/** Free text for a non-terminal stage; on the terminal stage this mirrors the picked item's name. */
|
||||
name: string
|
||||
uom: string
|
||||
qty: number
|
||||
batch?: string
|
||||
/** Required once this output sits on the terminal stage (finished good). */
|
||||
itemId?: number
|
||||
}
|
||||
|
||||
export interface StageNodeData extends Record<string, unknown> {
|
||||
name: string
|
||||
roleLabel: string
|
||||
estimatedMinutes: number
|
||||
inputs: FormulaInput[]
|
||||
outputs: FormulaOutput[]
|
||||
fieldDefs: FieldDef[]
|
||||
/** Computed by the page on every graph change, not user-editable — no in/out edges at all. */
|
||||
inputs: BuilderInput[]
|
||||
outputs: BuilderOutput[]
|
||||
fieldDefs: BuilderFieldDef[]
|
||||
/** Recomputed by the page on every graph change, not user-editable — no in/out edges at all. */
|
||||
disconnected?: boolean
|
||||
/** Set when a server `422 GRAPH_*` named this stage, so the canvas can point at it. */
|
||||
focused?: boolean
|
||||
/** Injected by the page at render time — deletes this node (and any edges touching it). */
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export interface MockItem {
|
||||
itemId: number
|
||||
name: string
|
||||
uom: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-floating annotations — grouping boxes and divider lines. Purely visual: they carry
|
||||
* no graph semantics (no ports, never appear in cycle/terminal/entry/disconnected checks
|
||||
* or the save-blocking issue list), unlike "stage" nodes.
|
||||
* Free-floating annotations — grouping boxes and divider lines. Purely visual: they carry no
|
||||
* graph semantics (no ports, never part of the cycle/terminal/entry/disconnected checks or the
|
||||
* save-blocking issue list), unlike "stage" nodes. Persisted verbatim in the template's
|
||||
* `annotations` jsonb so a layout survives a reload.
|
||||
*/
|
||||
export interface AnnotationData extends Record<string, unknown> {
|
||||
label: string
|
||||
/** Degrees, applied as a CSS rotation around the node's own center. Lines only (§ AnnotationNodes). */
|
||||
/** Degrees, applied as a CSS rotation around the node's own center. Lines only. */
|
||||
rotation?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import { useTheme } from "next-themes"
|
||||
import { LayoutTemplate, Plus, Search } from "lucide-react"
|
||||
|
||||
import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates"
|
||||
import { ProductionTemplate, TemplateStatus } from "@/types/production"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import {
|
||||
LineHeaderNodeComponent,
|
||||
LineStageNodeComponent,
|
||||
@@ -22,22 +24,10 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
// Frontend-only mock data — no Dtos/Production backend exists yet (docs/21-FRONTEND-PHASE2.md).
|
||||
const INITIAL_TEMPLATES: ProductionTemplate[] = [
|
||||
{ templateId: 1, docNo: "TPL-1001", name: "Steel Bracket Assembly", status: "Active", stageCount: 3, activeRunCount: 2, updatedAt: "2026-07-20" },
|
||||
{ templateId: 2, docNo: "TPL-1002", name: "PCB Soldering Line", status: "Active", stageCount: 5, activeRunCount: 0, updatedAt: "2026-07-18" },
|
||||
{ templateId: 3, docNo: "TPL-1003", name: "Wooden Pallet Build", status: "Active", stageCount: 2, activeRunCount: 1, updatedAt: "2026-07-25" },
|
||||
{ templateId: 4, docNo: "TPL-1004", name: "Plastic Injection Mold", status: "Inactive", stageCount: 4, activeRunCount: 0, updatedAt: "2026-07-10" },
|
||||
{ templateId: 5, docNo: "TPL-1005", name: "Cable Harness Kit", status: "Active", stageCount: 3, activeRunCount: 0, updatedAt: "2026-07-22" },
|
||||
]
|
||||
|
||||
type StatusFilter = TemplateStatus | "All"
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
|
||||
|
||||
@@ -46,7 +36,7 @@ const STAGE_START_X = 300
|
||||
const STAGE_GAP_X = 200
|
||||
|
||||
/** One row per template — its production line, header on the left, stages left to right. */
|
||||
function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edges: Edge[] } {
|
||||
function buildLinesGraph(templates: ProductionTemplateSummary[]): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
@@ -58,7 +48,7 @@ function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edge
|
||||
position: { x: 0, y },
|
||||
data: {
|
||||
templateId: t.templateId,
|
||||
docNo: t.docNo,
|
||||
code: t.code,
|
||||
name: t.name,
|
||||
status: t.status,
|
||||
activeRunCount: t.activeRunCount,
|
||||
@@ -66,8 +56,9 @@ function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edge
|
||||
draggable: false,
|
||||
})
|
||||
|
||||
const stages = MOCK_TEMPLATE_INFO[t.templateId]?.stages ?? []
|
||||
stages.forEach((stageName, i) => {
|
||||
// Names come straight from the list projection, so the overview needs one request
|
||||
// regardless of how many templates it shows.
|
||||
t.stageNames.forEach((stageName, i) => {
|
||||
const stageId = `s${t.templateId}-${i}`
|
||||
nodes.push({
|
||||
id: stageId,
|
||||
@@ -90,32 +81,61 @@ function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edge
|
||||
export default function ProductionTemplatesPage() {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [templates, setTemplates] = useState<ProductionTemplate[]>(INITIAL_TEMPLATES)
|
||||
|
||||
// null = still loading (the codebase convention for "no data yet" vs "empty result").
|
||||
const [templates, setTemplates] = useState<ProductionTemplateSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState<{ code?: string; name?: string }>({})
|
||||
|
||||
// Same hydration-mismatch guard as the builder canvas (theme-toggle.tsx / templates/[id]/page.tsx):
|
||||
// colorMode depends on resolvedTheme, which is unknown on the server and on first paint.
|
||||
// Same hydration-mismatch guard as the builder canvas: colorMode depends on resolvedTheme,
|
||||
// which is unknown on the server and on first paint.
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchInput.trim().toLowerCase()
|
||||
return templates.filter((t) => {
|
||||
if (status !== "All" && t.status !== status) return false
|
||||
if (q && !t.name.toLowerCase().includes(q) && !t.docNo.toLowerCase().includes(q)) return false
|
||||
return true
|
||||
})
|
||||
}, [templates, searchInput, status])
|
||||
// 300ms debounce, matching app/dashboard/receiving/grn/page.tsx. Resets to page 1 with the
|
||||
// query so a narrower search can't leave you stranded past the last page — done here rather
|
||||
// than in a second effect watching [query, status], which would be a cascading render.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setQuery(searchInput.trim())
|
||||
setPage(1)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const hasFilters = searchInput.trim().length > 0 || status !== "All"
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null)
|
||||
productionTemplatesApi
|
||||
.list({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
})
|
||||
.then((res) => {
|
||||
setTemplates(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => {
|
||||
setTemplates([])
|
||||
setLoadError(errorMessage(err))
|
||||
})
|
||||
}, [page, query, status])
|
||||
|
||||
const { nodes, edges } = useMemo(() => buildLinesGraph(filtered), [filtered])
|
||||
useEffect(load, [load])
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
|
||||
|
||||
const onNodeClick: NodeMouseHandler = (_, node) => {
|
||||
const templateId = (node.data as LineHeaderData | LineStageData).templateId
|
||||
@@ -123,35 +143,30 @@ export default function ProductionTemplatesPage() {
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setCode("")
|
||||
setName("")
|
||||
setError("")
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the builder on an unsaved draft rather than creating anything now.
|
||||
*
|
||||
* A template cannot exist without a valid graph: the server requires at least one stage and
|
||||
* a terminal output naming a real finished item (FR-MFG-02/05). There is nothing sensible to
|
||||
* POST from a name alone, so the draft lives in the builder and the first Save creates it.
|
||||
*/
|
||||
function handleCreate() {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
setError("Name is required.")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
const nextId = templates.reduce((max, t) => Math.max(max, t.templateId), 0) + 1
|
||||
const created: ProductionTemplate = {
|
||||
templateId: nextId,
|
||||
docNo: `TPL-${1000 + nextId}`,
|
||||
name: trimmed,
|
||||
status: "Active",
|
||||
stageCount: 0,
|
||||
activeRunCount: 0,
|
||||
updatedAt: todayIso(),
|
||||
}
|
||||
setTemplates((prev) => [...prev, created])
|
||||
toast.success("Template created", trimmed)
|
||||
const nextErrors: { code?: string; name?: string } = {}
|
||||
if (!code.trim()) nextErrors.code = "Code is required."
|
||||
if (!name.trim()) nextErrors.name = "Name is required."
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setOpen(false)
|
||||
setSubmitting(false)
|
||||
// No backend exists yet, so the builder can't look this template up by id (its mock
|
||||
// lookup only knows the 5 seeded ones) — pass the name through and start it blank.
|
||||
router.push(`/dashboard/production/templates/${nextId}?name=${encodeURIComponent(trimmed)}&fresh=1`)
|
||||
router.push(
|
||||
`/dashboard/production/templates/new?code=${encodeURIComponent(code.trim())}&name=${encodeURIComponent(name.trim())}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -166,28 +181,41 @@ export default function ProductionTemplatesPage() {
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New template</DialogTitle>
|
||||
<DialogDescription>Give the template a name — you'll build its stage graph next.</DialogDescription>
|
||||
<DialogDescription>
|
||||
Name it, then build its stage graph. It's saved once the graph is valid.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!error}>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
|
||||
<Input
|
||||
id="tpl-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="e.g. PT-CHAIR"
|
||||
aria-invalid={!!errors.code}
|
||||
/>
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="tpl-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Aluminium Frame Assembly"
|
||||
aria-invalid={!!error}
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
<FieldError errors={[error ? { message: error } : undefined]} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create & open builder"}
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate}>
|
||||
Open builder
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -200,7 +228,7 @@ export default function ProductionTemplatesPage() {
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search templates…"
|
||||
placeholder="Search by code or name…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search templates"
|
||||
/>
|
||||
@@ -217,7 +245,15 @@ export default function ProductionTemplatesPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templates === null ? (
|
||||
<Skeleton className="h-[70vh] min-h-105 w-full rounded-2xl" />
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<LayoutTemplate className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -225,29 +261,52 @@ export default function ProductionTemplatesPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<Skeleton className="size-full rounded-none" />
|
||||
<>
|
||||
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<Skeleton className="size-full rounded-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-base text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
@@ -183,6 +183,9 @@ export default function ItemDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
||||
import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
||||
|
||||
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Category, SubCategory } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -111,6 +113,9 @@ export default function CategorySubCategoriesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -166,13 +171,13 @@ export default function CategorySubCategoriesPage() {
|
||||
|
||||
{!error && subCategories !== null && subCategories.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
|
||||
|
||||
import { itemTypesApi } from "@/lib/api/item-types"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateItemTypeName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ItemType } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
@@ -108,6 +110,9 @@ export default function ItemTypesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -169,13 +174,13 @@ export default function ItemTypesPage() {
|
||||
|
||||
{!error && itemTypes !== null && itemTypes.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { ArrowLeft, Plus, X } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
@@ -254,6 +254,9 @@ export default function NewItemPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
||||
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
|
||||
@@ -529,14 +532,14 @@ export default function NewItemPage() {
|
||||
{variants.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
{activeCategories.map((cat) => (
|
||||
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
|
||||
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
|
||||
))}
|
||||
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
|
||||
{priceMode === "fixed" && (
|
||||
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm text-indigo-700">Sale price</TableHead>
|
||||
)}
|
||||
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
|
||||
Item contract and no initial-receipt flow — stock arrives via a GRN.
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Package } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Package } from "lucide-react"
|
||||
|
||||
import { productConfigApi } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ProductConfig } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -67,6 +70,9 @@ export default function ProductSettingsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Ruler } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Ruler } from "lucide-react"
|
||||
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateUomName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Uom } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
@@ -57,6 +59,9 @@ export default function UomsPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
|
||||
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
import { ArrowLeft, CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -118,6 +118,9 @@ export default function GrnDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
@@ -303,6 +303,9 @@ export default function NewGrnPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New GRN</h1>
|
||||
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
|
||||
@@ -443,7 +446,7 @@ export default function NewGrnPage() {
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
||||
@@ -451,7 +454,7 @@ export default function NewGrnPage() {
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
@@ -132,6 +132,9 @@ export default function RoleDetailPage() {
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Save } from "lucide-react"
|
||||
import { ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
@@ -83,6 +83,9 @@ export default function UserDetailPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { CheckCircle2, Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, CheckCircle2, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -123,6 +123,9 @@ export default function NewAdjustmentPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Adjustment</h1>
|
||||
<p className="text-base text-muted-foreground">Posts immediately on creation (FR-STK-07) — a reason code is mandatory.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Plus, SlidersHorizontal } from "lucide-react"
|
||||
import { ArrowLeft, Plus, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -39,6 +39,9 @@ export default function AdjustmentsListPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1>
|
||||
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { CheckCircle2, ClipboardCheck, Save } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, ClipboardCheck, Save } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PostCountResponse, StockCount } from "@/types/stock"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -90,6 +91,9 @@ export default function CountDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{count.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {} from "lucide-react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -82,6 +82,9 @@ export default function NewCountPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Count</h1>
|
||||
<p className="text-base text-muted-foreground">System quantities are snapshotted immediately; enter counted quantities next.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ClipboardList, Plus } from "lucide-react"
|
||||
import { ArrowLeft, ClipboardList, Plus } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -36,6 +36,9 @@ export default function CountsListPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Counts</h1>
|
||||
<p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { PackageSearch, Search } from "lucide-react"
|
||||
import { ArrowLeft, PackageSearch, Search } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { OnHand } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -57,6 +59,9 @@ export default function StockEnquiryPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Enquiry</h1>
|
||||
<p className="text-base text-muted-foreground">On-hand, available, on-hold, and in-transit by item and warehouse (FR-STK-12).</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -12,7 +13,7 @@ import { LedgerEntry } from "@/types/stock"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -80,6 +81,9 @@ export default function StockLedgerPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Ledger</h1>
|
||||
<p className="text-base text-muted-foreground">Immutable, append-only movement journal (FR-STK-01).</p>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReorderAlert } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -57,6 +59,9 @@ export default function ReorderAlertsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Reorder Alerts</h1>
|
||||
<p className="text-base text-muted-foreground">Items at or below their reorder point (FR-STK-10).</p>
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { CheckCircle2, PackageCheck, Truck } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, PackageCheck, Truck } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -83,6 +85,9 @@ export default function TransferDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{transfer.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -138,6 +138,9 @@ export default function NewTransferPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Transfer</h1>
|
||||
<p className="text-base text-muted-foreground">Create a transfer, then dispatch and receive it (FR-STK-05).</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeftRight, Plus } from "lucide-react"
|
||||
import { ArrowLeft, ArrowLeftRight, Plus } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -36,6 +36,9 @@ export default function TransfersListPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Transfers</h1>
|
||||
<p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { BadgeDollarSign } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, BadgeDollarSign } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Valuation } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -62,6 +65,9 @@ function ValuationContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Valuation</h1>
|
||||
<p className="text-base text-muted-foreground">FIFO cost-layer breakdown and total stock value (FR-STK-04).</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, CheckCircle2 } from "lucide-react"
|
||||
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
@@ -99,6 +99,9 @@ export default function NewWastagePage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Record Wastage</h1>
|
||||
<p className="text-base text-muted-foreground">Posts immediately as a stock adjustment (FR-STK-07) — a reason code is mandatory.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, Plus } from "lucide-react"
|
||||
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
|
||||
|
||||
import { isWastageReasonCode, wastageApi, WastageRecord } from "@/lib/api/wastage"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
@@ -68,6 +68,9 @@ export default function WastagePage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Wastage</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
|
||||
+4
-1
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
@@ -132,6 +132,9 @@ export default function VendorDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/vendors" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{vendor.code}</h1>
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { MapPinned, Plus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, MapPinned, Plus } from "lucide-react"
|
||||
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
@@ -88,6 +90,9 @@ export default function WarehouseDetailPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/warehouse" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
|
||||
<p className="text-base text-muted-foreground">{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)</p>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
||||
import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
||||
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
@@ -99,6 +100,9 @@ export default function WarehousesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Warehouses</h1>
|
||||
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
|
||||
|
||||
@@ -341,9 +341,9 @@ export function AppSidebar() {
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
//
|
||||
// "procurement" and "hrm" are exempted from that check (frontend-only): no role is
|
||||
// currently seeded with NAV:procurement/NAV:hrm or their children server-side, which
|
||||
// would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||
// "procurement", "hrm" and "production" are exempted from that check (frontend-only): no
|
||||
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
||||
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
|
||||
@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
export interface LineHeaderData extends Record<string, unknown> {
|
||||
templateId: number
|
||||
docNo: string
|
||||
/** The template's `code` (e.g. `PT-CHAIR`). Templates carry a code; only runs get a doc no. */
|
||||
code: string
|
||||
name: string
|
||||
status: "Active" | "Inactive"
|
||||
activeRunCount: number
|
||||
@@ -16,7 +17,7 @@ function LineHeaderNode({ data }: NodeProps & { data: LineHeaderData }) {
|
||||
return (
|
||||
<div className="flex w-56 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.code}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user