Compare commits

..

9 Commits

Author SHA1 Message Date
Sasanka 96f81cb03a Refactor: Remove unused imports and clean up code across multiple pages
- Removed unused Link imports and associated code for navigation in various pages.
- Simplified TableHeader components by removing unnecessary class names.
- Updated alert dialog styles for better consistency with the design system.
- Cleaned up imports in several components to streamline the codebase.
2026-07-31 11:04:32 +05:30
ImanThiyanga cb3f1e5cde Merge branch 'production' into Dev 2026-07-31 05:24:32 +00:00
ImanThiyanga c3fef88bcf Merge pull request 'feat: add production templates API and documentation for manufacturing phase 2' (#19) from feat/production-integration into Dev
Reviewed-on: #19
2026-07-31 04:54:00 +00:00
ImanThiyanga 7d6e597389 feat: add production templates API and documentation for manufacturing phase 2
- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
2026-07-31 10:22:40 +05:30
ImanThiyanga 415ac94ab2 Merge pull request 'Dashborad and prouction' (#18) from Dashborad-and-prouction into Dev
Reviewed-on: #18
2026-07-29 17:08:57 +00:00
Sasanka 4561ef7ba8 feat: implement frontend-only mock for production lines and runs
- Add ProductionTemplatesPage component for managing production templates with a visual representation using React Flow.
- Create LineHeaderNode and LineStageNode components for rendering production line nodes.
- Introduce RunStageNode and RunHeaderNode components for displaying run stages and headers.
- Implement StageProgressStrip for visualizing stage progress in runs.
- Create mock data for production runs and templates to simulate backend functionality.
- Define types for production templates and runs to structure mock data.
- Document frontend Phase 2 specifications for manufacturing processes, including screens, dialogs, and validation posture.
2026-07-28 22:55:56 +05:30
Sasanka b12adebaa0 feat: add dashboard overview stats endpoint and UI integration
- Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement.
- Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data.
- Developed `DashboardService` to fetch and compute necessary statistics from the database.
- Added `IDashboardService` interface for service abstraction.
- Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint.
- Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend.
- Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons.
2026-07-28 14:59:09 +05:30
ImanThiyanga 0b95d6f1cd Merge pull request 'feat: enhance UI components and add vibrant theme support' (#17) from ui-fixers into Dev
Reviewed-on: #17
2026-07-27 06:10:10 +00:00
ImanThiyanga 8484601494 Merge pull request 'Dev' (#13) from Dev into production
Reviewed-on: #13
2026-07-22 06:12:34 +00:00
130 changed files with 13231 additions and 600 deletions
@@ -0,0 +1,20 @@
using ERPCore.Dtos.Dashboard;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
[Route("api/v1/dashboard")]
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct));
}
@@ -0,0 +1,157 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2D.3).</summary>
[Route("api/v1/production-runs")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] ProductionRunStatus? status,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
{
var result = await _runs.GetAsync(runId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
{
var result = await _runs.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
}
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
// --- stage actions (docs/30 §D.3) ---------------------------------------
//
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
// the status guards instead: a double-fire finds the stage already moved on and gets a
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
// deviation from §D.3's "idempotency-key honored".
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<StartStageResultDto>> Start(
int runId, int runStageId,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> Complete(
int runId, int runStageId, [FromBody] CompleteStageRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ApproveStageResultDto>> Approve(
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TransferResultDto>> Transfer(
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
[HttpPost("{runId:int}/cancel")]
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CancelAsync(runId, request, ct));
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _templates.ListAsync(query, status, ct));
[HttpGet("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
{
var result = await _templates.GetAsync(templateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Create(
[FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var result = await _templates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
}
[HttpPut("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Update(
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{templateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
{
await _templates.SetStatusAsync(templateId, request.Status, ct);
return NoContent();
}
}
+3
View File
@@ -14,4 +14,7 @@ public static class DocumentTypes
public const string Adjustment = "ADJ"; public const string Adjustment = "ADJ";
public const string Count = "CNT"; public const string Count = "CNT";
public const string PurchaseReturn = "PRET"; public const string PurchaseReturn = "PRET";
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
public const string Production = "PRD";
} }
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One execution instance of a template (FR-MFG-08), numbered <c>PRD-2026-00001</c>.
/// Every stage, input, output and edge is <b>copied</b> from the template at creation
/// with quantities scaled by <see cref="ScaleFactor"/>, so a completed run stays
/// readable even if the template is later edited (FR-MFG-06).
/// <para>The run's <b>cost pool</b> is derived, never stored:
/// <c>Σ RunStageInput.ConsumedValue Σ RunStageInput.ReturnedValue</c>. The terminal
/// approve divides it by the good quantity to cost the finished layer, then closes it
/// (FR-MFG-13, <c>409 RUN_COST_CLOSED</c>).</para>
/// Mutable aggregate with a <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
public class ProductionRun
{
public int RunId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
/// <summary>Stock inputs are consumed from, and the finished good received into, this warehouse.</summary>
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.</summary>
public int? OutputBinId { get; set; }
public Bin? OutputBin { get; set; }
/// <summary>Target quantity of the finished item; drives <see cref="ScaleFactor"/>.</summary>
public decimal TargetQty { get; set; }
/// <summary><c>TargetQty / terminalOutput.QtyPerBatch</c>, applied to every copied quantity.</summary>
public decimal ScaleFactor { get; set; }
public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
/// <summary>Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.</summary>
public int ReworkCount { get; set; }
public int? CancelReasonCodeId { get; set; }
public ReasonCode? CancelReason { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Set by the terminal approve only. A cancelled run leaves this null.</summary>
public DateTime? CompletedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<RunStage> Stages { get; set; } = new List<RunStage>();
public ICollection<RunEdge> Edges { get; set; } = new List<RunEdge>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A reusable production-line definition — the stage graph designed on the canvas
/// (FR-MFG-01). Never hard-deleted once referenced by a run; deactivated instead
/// (FR-MD-08 posture). Editing is locked while any run of it is InProgress
/// (FR-MFG-06, <c>409 TEMPLATE_IN_USE</c>) — edit-lock replaces versioning, which is
/// why runs copy display fields at creation. Mutable aggregate with a
/// <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
public class ProductionTemplate
{
public int TemplateId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
/// <summary>
/// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
/// verbatim and never interpreted server-side.
/// </summary>
/// <remarks>
/// Not in docs/30 Part C — added because the builder canvas already draws these and
/// without somewhere to keep them a save would silently discard the user's layout notes.
/// They carry no graph semantics: no ports, no edges, and the validator never sees them.
/// Nullable so a template that has none stores nothing rather than an empty array.
/// </remarks>
public string? Annotations { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<TemplateStage> Stages { get; set; } = new List<TemplateStage>();
public ICollection<StageEdge> Edges { get; set; } = new List<StageEdge>();
public ICollection<ProductionRun> Runs { get; set; } = new List<ProductionRun>();
}
@@ -0,0 +1,28 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow copied from the template's <see cref="StageEdge"/> set at run
/// creation.
/// <para><b>Addition to docs/30 Part C (recorded).</b> The doc's entity model has no run
/// edge table, but the run graph needs its own copy: deriving edges at read time through
/// <c>RunStage.TemplateStageId → STAGE_EDGE</c> would let a later template edit silently
/// rewrite completed-run history — the exact thing FR-MFG-06 exists to prevent — and
/// breaks outright once that link is nulled by a stage deletion.</para>
/// <para>Used for the run canvas, child-readiness evaluation and reject-intake's
/// "delivering parents". Note that <b>WIP delivery is routed by
/// <c>RunStageInput.FromRunOutputId</c>, not by these edges</b> — an edge is display and
/// validation only.</para>
/// </summary>
public class RunEdge
{
public int RunEdgeId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
public int ParentRunStageId { get; set; }
public RunStage? ParentRunStage { get; set; }
public int ChildRunStageId { get; set; }
public RunStage? ChildRunStage { get; set; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One stage of a run — a copy of a <see cref="TemplateStage"/> taken at run creation
/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
/// <para><b>Whether this stage is terminal is derived</b>, never stored: a stage is
/// terminal when it has no outbound <see cref="RunEdge"/>. Storing it would let it
/// drift from the edge set.</para>
/// </summary>
public class RunStage
{
public int RunStageId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>
/// Provenance link back to the template stage. <b>Nullable</b>: a template PUT may
/// delete a stage while completed/cancelled runs still reference it (the edit-lock
/// only blocks edits during InProgress runs), so the FK is <c>SET NULL</c> rather
/// than blocking the edit forever. Everything needed to display a historical run is
/// copied below, which is exactly what FR-MFG-06 anticipates.
/// </summary>
public int? TemplateStageId { get; set; }
public TemplateStage? TemplateStage { get; set; }
// --- copied from the template at run creation (FR-MFG-06) ---
public string Name { get; set; } = string.Empty;
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
public decimal PosX { get; set; }
public decimal PosY { get; set; }
public ProductionStageStatus Status { get; set; } = ProductionStageStatus.Waiting;
/// <summary>Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).</summary>
public DateTime? ActualStartAt { get; set; }
public DateTime? ActualEndAt { get; set; }
/// <summary>Copied from the template stage; definitions survive a rework.</summary>
public string FieldDefs { get; set; } = "[]";
/// <summary>Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.</summary>
public string? FieldValues { get; set; }
/// <summary>
/// Concurrency token. Stage actions re-read the stage inside their transaction and
/// let this xmin check serialize concurrent requests — it is what stops two
/// simultaneous terminal approves from both reading <c>Done</c> and posting two
/// receipts. See the idempotency note in <c>ProductionRunService</c>.
/// </summary>
public uint RowVersion { get; set; }
public ICollection<RunStageInput> Inputs { get; set; } = new List<RunStageInput>();
public ICollection<RunStageOutput> Outputs { get; set; } = new List<RunStageOutput>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Immutable history of everything that happened to a run (docs/30 Part C). Written by
/// every mutating action and never updated or deleted, so a run's story — including the
/// figures discarded by each rework — survives in full.
/// <para><b>Addition to docs/30 Part C (recorded):</b> <see cref="RunId"/>. The doc hangs
/// events off the stage only, which leaves run-level events (cancel, terminal reject)
/// with no home and forces the detail timeline to join through stages. Keeping both
/// links makes <see cref="RunStageId"/> optional and the timeline a single query.</para>
/// </summary>
public class RunStageEvent
{
public int EventId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>Null for run-level events (Cancel).</summary>
public int? RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public RunStageEventType EventType { get; set; }
public string? Note { get; set; }
/// <summary>
/// Event-specific detail as jsonb — consumed layers on a Start, pulled-back
/// quantities on a RejectIntake, the full pre-rework snapshot on a TerminalReject.
/// Pre-serialized string, written only through <c>ProductionJson</c>.
/// </summary>
public string? Payload { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,55 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One input line of a run stage — a copy of a <see cref="StageInput"/> with its
/// quantity scaled at creation, plus the live consumption/delivery figures.
/// Model: docs/30 Part C.
/// <para><b>Stock inputs</b> accumulate <see cref="ConsumedQty"/>/<see cref="ConsumedValue"/>
/// at each start and <see cref="ReturnedQty"/>/<see cref="ReturnedValue"/> on leftover
/// return or run cancel. Those four columns are the whole cost pool
/// (<c>Σ consumed Σ returned</c>) and are deliberately <b>not</b> reset by a terminal
/// reject — already-consumed material stays in the pool (FR-MFG-16).</para>
/// <para><b>Upstream inputs</b> accumulate <see cref="DeliveredQty"/> as parent stages
/// transfer WIP in. The stage becomes Ready only when every upstream input has
/// <c>DeliveredQty &gt;= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
/// </summary>
public class RunStageInput
{
public int RunInputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public StageInputSource Source { get; set; }
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
public decimal PlannedQty { get; set; }
/// <summary>
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
/// <c>max(0, PlannedQty ConsumedQty)</c> and adds to these, so a rework restart
/// with an unchanged planned quantity consumes nothing and one with a raised planned
/// quantity consumes only the delta (FR-MFG-16).
/// </summary>
public decimal ConsumedQty { get; set; }
public decimal ConsumedValue { get; set; }
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
public decimal DeliveredQty { get; set; }
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
public decimal ReturnedQty { get; set; }
public decimal ReturnedValue { get; set; }
}
@@ -0,0 +1,43 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
/// Model: docs/30 Part C.
/// <para><b>Available to transfer is derived, never stored:</b>
/// <c>ProducedQty ScrappedQty TransferredQty</c>. Every transfer path checks it and
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
/// write-off ledger entry is posted (FR-MFG-11).</para>
/// </summary>
public class RunStageOutput
{
public int RunOutputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
public decimal ProducedQty { get; set; }
public decimal ScrappedQty { get; set; }
/// <summary>Mandatory when <see cref="ScrappedQty"/> &gt; 0, context <c>Production</c> (FR-MFG-11).</summary>
public int? ScrapReasonCodeId { get; set; }
public ReasonCode? ScrapReason { get; set; }
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
public decimal TransferredQty { get; set; }
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow on the template canvas. The edge set must form a DAG with
/// at least one entry stage and exactly one terminal stage; that is enforced in
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
/// Model: docs/30 Part C.
/// </summary>
public class StageEdge
{
public int EdgeId { get; set; }
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
public int ParentStageId { get; set; }
public TemplateStage? ParentStage { get; set; }
public int ChildStageId { get; set; }
public TemplateStage? ChildStage { get; set; }
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
/// <list type="bullet">
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
/// internal WIP and never touches stock or the ledger.</item>
/// </list>
/// Model: docs/30 Part C.
/// </summary>
public class StageInput
{
public int InputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
public int? FromOutputId { get; set; }
public StageOutput? FromOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public decimal QtyPerBatch { get; set; }
}
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
/// <b>internal WIP only</b> — <see cref="ItemId"/> is null, no stock and no ledger row
/// is ever written for them. The terminal stage is the exception: it has exactly one
/// output and that output <b>must</b> reference a real Item (the finished good), which
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
/// </summary>
public class StageOutput
{
public int OutputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
public Uom? Uom { get; set; }
public decimal QtyPerBatch { get; set; }
}
@@ -0,0 +1,36 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
/// custom field definitions. Model: docs/30 Part C.
/// </summary>
public class TemplateStage
{
public int StageId { get; set; }
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
/// <summary>
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
/// hold canonical JSON.
/// </summary>
public string FieldDefs { get; set; } = "[]";
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
/// Lives inside the <c>field_defs</c> jsonb rather than a column, but is modelled as an
/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
/// <see cref="Select"/> is the only type that reads <c>options</c>.
/// </summary>
public enum CustomFieldType
{
Text,
Number,
Checkbox,
Date,
Select
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
/// <see cref="InProgress"/> and reaches exactly one final state: it completes at the
/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
/// stock return (FR-MFG-17). Stored as a string.
/// </summary>
public enum ProductionRunStatus
{
InProgress,
Completed,
Cancelled
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
/// <code>
/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
/// InProgress ──complete (produced/scrap/fields)──▶ Done
/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
/// </code>
/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
/// rework transition back into this set and is recorded as a
/// <see cref="RunStageEventType"/> instead. Stored as a string.
/// </summary>
public enum ProductionStageStatus
{
Waiting,
Ready,
InProgress,
Done,
Approved
}
@@ -5,5 +5,8 @@ public enum ReasonContext
{ {
Adjustment, Adjustment,
Return, 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,24 @@
namespace ERPCore.Dtos.Dashboard;
/// <summary>
/// Aggregate counts for the dashboard overview — mirrors the widget set in
/// docs/dashboard-implementation.pdf: reorder alerts, on-hand summary, stock valuation,
/// pending-approval POs, pending GRNs, open requisitions, open counts awaiting posting,
/// and open RFQs. Recent movements isn't here — it's just GET /stock/ledger with a small
/// pageSize, no aggregation needed. A single computed-on-read object, not a stored
/// entity — same as reorder alerts (docs/11 §5.7).
/// </summary>
public sealed record DashboardStatsDto(
int LowStockAlerts,
decimal OnHandTotal,
int OnHandWarehouses,
decimal StockValuationTotal,
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
int PendingApprovalPurchaseOrders,
int PendingGrns,
int OpenRequisitions,
int PendingCounts,
int OpenRfqs);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
+113
View File
@@ -0,0 +1,113 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Production;
// Production run contract (docs/30-BACKEND-PHASE2.md §D.2D.3).
//
// Statuses are never client-settable (02-SECURITY §B.6): a run's status and every stage
// status move only through the stage-action endpoints. Requests here carry quantities and
// references, nothing else.
// --- responses ---------------------------------------------------------------
/// <summary>Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).</summary>
public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
/// <summary>Row on the run board (docs/30 §D.2 <c>GET /production-runs</c>).</summary>
public sealed record RunSummaryDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
decimal TargetQty, ProductionRunStatus Status, int ReworkCount,
StageSummaryDto StageSummary, int WarehouseId,
int? FinishedItemId, string? FinishedItemName,
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
/// <summary>
/// The run cost pool (FR-MFG-13). Always derived from the stage inputs, never stored —
/// surfaced so the UI can preview the finished unit cost before approving the terminal.
/// </summary>
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
public sealed record RunStageInputDto(
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced scrapped
/// transferred (FR-MFG-12) — and never stored.
/// </summary>
public sealed record RunStageOutputDto(
int RunOutputId, int? ItemId, string Name, int UomId,
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
decimal TransferredQty, decimal AvailableToTransfer);
/// <summary>
/// One stage of a run. <c>IsTerminal</c> (no outbound edge) and <c>IsEntry</c> (no inbound
/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
/// <c>ActualMinutes</c> is the elapsed whole minutes once the stage has finished, null while
/// it is still running (FR-MFG-19). <c>FieldValues</c> is the raw jsonb captured at complete,
/// passed through verbatim.
/// </summary>
public sealed record RunStageDto(
int RunStageId, int? TemplateStageId, string Name, string? RoleLabel,
int EstimatedMinutes, decimal PosX, decimal PosY,
ProductionStageStatus Status, bool IsTerminal, bool IsEntry,
DateTime? ActualStartAt, DateTime? ActualEndAt, int? ActualMinutes,
IReadOnlyList<FieldDefDto> FieldDefs, JsonElement? FieldValues,
IReadOnlyList<RunStageInputDto> Inputs, IReadOnlyList<RunStageOutputDto> Outputs);
public sealed record RunEdgeDto(int RunEdgeId, int ParentRunStageId, int ChildRunStageId);
public sealed record RunEventDto(
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
JsonElement? Payload, int UserId, DateTime CreatedAt);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
public sealed record RunGraphDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
int WarehouseId, int? OutputBinId,
decimal TargetQty, decimal ScaleFactor,
ProductionRunStatus Status, int ReworkCount, int? CancelReasonCodeId,
CostPoolDto CostPool,
IReadOnlyList<RunStageDto> Stages, IReadOnlyList<RunEdgeDto> Edges, IReadOnlyList<RunEventDto> Events,
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
// --- requests ----------------------------------------------------------------
public sealed class CreateRunRequest
{
[Range(1, int.MaxValue)]
public int TemplateId { get; set; }
/// <summary>Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).</summary>
[Range(0.0001, double.MaxValue)]
public decimal TargetQty { get; set; }
[Range(1, int.MaxValue)]
public int WarehouseId { get; set; }
/// <summary>Optional bin for the finished goods; must belong to <see cref="WarehouseId"/>.</summary>
public int? OutputBinId { get; set; }
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
public List<StageQuantityLine> Inputs { get; set; } = new();
public List<StageQuantityLine> Outputs { get; set; } = new();
}
public sealed class StageQuantityLine
{
/// <summary>The <c>runInputId</c> or <c>runOutputId</c> being adjusted.</summary>
[Range(1, int.MaxValue)]
public int Id { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
}
@@ -0,0 +1,177 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Production;
// Stage-action contract (docs/30-BACKEND-PHASE2.md §D.3). Every action is transactional,
// guards on the stage's current status, and returns the refreshed stage plus whatever
// stock effects it caused.
// --- start -------------------------------------------------------------------
/// <summary>One FIFO layer a stage start drew from, at that layer's cost.</summary>
public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
/// <summary>What a single Stock input consumed at start (FR-MFG-10).</summary>
public sealed record ConsumedInputDto(
int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList<ConsumedLayerDto> ConsumedLayers);
public sealed record StartStageResultDto(
int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
IReadOnlyList<ConsumedInputDto> Consumed, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
// --- complete ----------------------------------------------------------------
public sealed class CompleteStageRequest
{
[Required, MinLength(1)]
public List<CompleteOutputLine> Outputs { get; set; } = new();
/// <summary>
/// Values for the stage's custom fields, keyed by <c>fieldDefs[].key</c>. Every field
/// marked required must be present and non-empty (<c>400 REQUIRED_FIELD_MISSING</c>).
/// </summary>
public JsonElement? FieldValues { get; set; }
}
public sealed class CompleteOutputLine
{
[Range(1, int.MaxValue)]
public int RunOutputId { get; set; }
[Range(0, double.MaxValue)]
public decimal ProducedQty { get; set; }
[Range(0, double.MaxValue)]
public decimal ScrappedQty { get; set; }
/// <summary>Mandatory once <see cref="ScrappedQty"/> &gt; 0; must be a Production reason.</summary>
public int? ScrapReasonCodeId { get; set; }
}
// --- approve / transfer ------------------------------------------------------
/// <summary>
/// One WIP hand-off from a parent output to a child input. Deliveries route by
/// <c>fromRunOutputId</c>, not by edge — see the note on <c>RunEdge</c>.
/// </summary>
public sealed record TransferDto(
int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
/// <summary>The finished-goods layer a terminal approve created (FR-MFG-13).</summary>
public sealed record ReceiptDto(
int LayerId, int ItemId, int WarehouseId, int? BinId,
decimal QtyReceived, decimal UnitCost, decimal Value);
public sealed record ApproveStageResultDto(
int RunStageId, ProductionStageStatus Status, ProductionRunStatus RunStatus,
IReadOnlyList<TransferDto> Transfers,
ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
public sealed class ApproveStageRequest
{
/// <summary>
/// Optional partial transfers. Omitted or empty means transfer the full available
/// quantity of every output (FR-MFG-12).
/// </summary>
public List<TransferLine> Transfers { get; set; } = new();
}
public sealed class TransferLine
{
[Range(1, int.MaxValue)]
public int RunOutputId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
/// <summary>
/// Optional explicit target. When an output feeds several child inputs the server
/// otherwise fills them in <c>runInputId</c> order; naming one removes the ambiguity.
/// </summary>
public int? RunInputId { get; set; }
}
public sealed class TransferRemainderRequest
{
[Range(1, int.MaxValue)]
public int RunOutputId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
public int? RunInputId { get; set; }
}
public sealed record TransferResultDto(
int RunStageId, IReadOnlyList<TransferDto> Transfers, RunStageDto Stage);
// --- leftover return ---------------------------------------------------------
public sealed class ReturnLeftoverRequest
{
/// <summary>
/// Quantity to return, in the item's <b>base</b> UOM — the same unit
/// <c>consumedQty</c>/<c>returnedQty</c> are stored in, since the return posts straight
/// to stock. Cannot exceed consumed already returned.
/// </summary>
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
/// <summary>Mandatory, and must be a Production-context reason (FR-MFG-14).</summary>
public int? ReasonCodeId { get; set; }
}
public sealed record ProductionCreatedLayerDto(int LayerId, decimal UnitCost);
public sealed record ReturnLeftoverResultDto(
int RunInputId, decimal ReturnedQty, decimal ReturnedValue,
ProductionCreatedLayerDto CreatedLayer, IReadOnlyList<int> LedgerRefs, CostPoolDto CostPool);
// --- rejection / rework ------------------------------------------------------
public sealed class RejectRequest
{
[StringLength(500)]
public string? Note { get; set; }
}
/// <summary>One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).</summary>
public sealed record PulledBackDto(
int RunInputId, int ParentRunStageId, int ParentRunOutputId,
decimal Qty, ProductionStageStatus PriorParentStatus, ProductionStageStatus ParentStatus);
public sealed record RejectIntakeResultDto(
int RunStageId, ProductionStageStatus Status,
IReadOnlyList<PulledBackDto> PulledBack, RunGraphDto Run);
public sealed record TerminalRejectResultDto(
int RunStageId, int ReworkCount, RunGraphDto Run);
// --- cancel ------------------------------------------------------------------
public sealed class CancelRunRequest
{
public int? ReasonCodeId { get; set; }
[StringLength(500)]
public string? Note { get; set; }
}
public sealed record CancelReturnDto(int ItemId, decimal Qty, decimal UnitCost, int LayerId);
/// <summary>
/// Scrapped output quantities written off by a cancel. Recorded on the event only — scrap
/// sits on outputs, which never entered stock, so there is nothing to return (FR-MFG-17).
/// </summary>
public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
public sealed record CancelRunResultDto(
int RunId, ProductionRunStatus Status,
IReadOnlyList<CancelReturnDto> Returns,
IReadOnlyList<ScrapWriteOffDto> ScrappedWrittenOff,
IReadOnlyList<int> LedgerRefs);
@@ -0,0 +1,177 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Production;
// Production template contract (docs/30-BACKEND-PHASE2.md §D.1).
//
// The `Key` vocabulary: every stage and every output carries a client-facing string key
// alongside its database id. On a GET the key IS the stringified id; on a save the client
// echoes those keys back for rows it kept and mints "tmp-<uuid>" keys for rows it just
// drew. Edges and Upstream inputs then reference stages/outputs *by key* only, which is
// what lets one payload shape — and one validator — serve both POST (nothing has an id
// yet) and PUT (most things do).
// --- responses ---------------------------------------------------------------
/// <summary>
/// Row on the template list (docs/30 §D.1 <c>GET /production-templates</c>).
/// </summary>
/// <remarks>
/// <c>StageNames</c> is an addition to the documented shape. The template overview is a
/// canvas showing every template as a production line with its stages left-to-right
/// (docs/21 §1), so it needs the names for every listed row — without them the client would
/// have to fetch each template's full graph just to label the boxes. Ordered by stage id,
/// matching the graph endpoint.
/// </remarks>
public sealed record TemplateSummaryDto(
int TemplateId, string Code, string Name, EntityStatus Status,
int StageCount, IReadOnlyList<string> StageNames,
int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
/// <summary>One custom field definition, serialized verbatim into the stage's <c>field_defs</c> jsonb.</summary>
public sealed record FieldDefDto(
string Key, string Label, CustomFieldType Type, IReadOnlyList<string>? Options, bool Required);
public sealed record StageInputDto(
int InputId, StageInputSource Source, int? ItemId,
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
public sealed record StageOutputDto(
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
public sealed record TemplateStageDto(
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
decimal PosX, decimal PosY, IReadOnlyList<FieldDefDto> FieldDefs,
IReadOnlyList<StageInputDto> Inputs, IReadOnlyList<StageOutputDto> Outputs);
public sealed record TemplateEdgeDto(
int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
/// <summary>
/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
/// meaning whatsoever, and invisible to the graph validator.
/// </summary>
public sealed record CanvasAnnotationDto(
string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
string? Label, decimal? Rotation);
/// <summary>Full graph (docs/30 §D.1 <c>GET /production-templates/{id}</c>).</summary>
/// <remarks>
/// <c>ActiveRunCount</c> and <c>Annotations</c> are additions to the documented shape.
/// The first is what puts the builder into its edit-locked state (docs/21 §2) and mirrors
/// the condition <c>UpdateAsync</c> enforces — without it the builder would need a second
/// request to the list endpoint just to know whether to disable itself.
/// </remarks>
public sealed record TemplateGraphDto(
int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
IReadOnlyList<TemplateStageDto> Stages, IReadOnlyList<TemplateEdgeDto> Edges,
IReadOnlyList<CanvasAnnotationDto> Annotations, int ActiveRunCount,
int CreatedBy, DateTime CreatedAt);
// --- requests ----------------------------------------------------------------
// Narrow by design (02-SECURITY §B.6): no status, no ids, no createdBy, no timestamps.
// POST and PUT share this shape; PUT replaces the whole graph.
public sealed class SaveTemplateRequest
{
[Required, StringLength(30, MinimumLength = 1)]
public string Code { get; set; } = string.Empty;
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
[Required, MinLength(1)]
public List<SaveStageRequest> Stages { get; set; } = new();
/// <summary>Empty is legal — a single-stage template is both entry and terminal.</summary>
public List<SaveEdgeRequest> Edges { get; set; } = new();
/// <summary>
/// Canvas boxes/lines. Capped because this is free-form client state going into a jsonb
/// column — a hand-rolled request should not be able to store an unbounded blob.
/// </summary>
[MaxLength(200)]
public List<CanvasAnnotationDto> Annotations { get; set; } = new();
}
public sealed class SaveStageRequest
{
/// <summary>Existing stage id as a string, or a client-minted <c>tmp-*</c> key for a new stage.</summary>
[Required, StringLength(60, MinimumLength = 1)]
public string Key { get; set; } = string.Empty;
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
[StringLength(60)]
public string? RoleLabel { get; set; }
[Range(0, 1_000_000)]
public int EstimatedMinutes { get; set; }
/// <summary>Canvas coordinates, stored verbatim and never interpreted server-side.</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
public List<FieldDefDto> FieldDefs { get; set; } = new();
public List<SaveInputRequest> Inputs { get; set; } = new();
public List<SaveOutputRequest> Outputs { get; set; } = new();
}
public sealed class SaveInputRequest
{
[Required]
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; must be null when Upstream.</summary>
public int? ItemId { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must name an output of a direct parent.</summary>
[StringLength(60)]
public string? FromOutputKey { get; set; }
[Range(1, int.MaxValue)]
public int UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
}
public sealed class SaveOutputRequest
{
/// <summary>Existing output id as a string, or a <c>tmp-*</c> key. Referenced by Upstream inputs.</summary>
[Required, StringLength(60, MinimumLength = 1)]
public string Key { get; set; } = string.Empty;
/// <summary>Required on the terminal stage's single output; must be null on every other output.</summary>
public int? ItemId { get; set; }
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
[Range(1, int.MaxValue)]
public int UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
}
public sealed class SaveEdgeRequest
{
[Required, StringLength(60, MinimumLength = 1)]
public string ParentKey { get; set; } = string.Empty;
[Required, StringLength(60, MinimumLength = 1)]
public string ChildKey { get; set; } = string.Empty;
}
/// <summary>Body of <c>PATCH /production-templates/{id}/status</c> (docs/30 §D.1).</summary>
public sealed class UpdateTemplateStatusRequest
{
[Required]
public EntityStatus Status { get; set; }
}
@@ -0,0 +1,277 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
// Manufacturing / Production Lines (docs/30 Part C). All eleven tables live in this one
// file, following the StockConfiguration.cs precedent of grouping an aggregate's
// configurations together.
//
// Cascade shape (deliberate): the two aggregate roots cascade to their own children —
// template → stages/edges → inputs/outputs, run → stages/edges/events → inputs/outputs.
// Every *cross* reference is Restrict, because making them cascade would give EF two
// delete paths to the same table and the model validator rejects that. The consequence
// is an ordering rule for the service layer: when replacing a graph, delete edges before
// stages and inputs before outputs.
public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration<ProductionTemplate>
{
public void Configure(EntityTypeBuilder<ProductionTemplate> builder)
{
builder.ToTable("production_templates");
builder.HasKey(t => t.TemplateId);
builder.Property(t => t.Code).IsRequired().HasMaxLength(30);
builder.HasIndex(t => t.Code).IsUnique();
builder.Property(t => t.Name).IsRequired().HasMaxLength(150);
builder.Property(t => t.Description).HasMaxLength(500);
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
// Same jsonb-as-string mapping as TemplateStage.FieldDefs and AuditLog.ChangeSet: a
// typed/owned mapping would make AuditScribe emit audit rows for the nested entries.
builder.Property(t => t.Annotations).HasColumnType("jsonb");
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
}
}
public sealed class TemplateStageConfiguration : IEntityTypeConfiguration<TemplateStage>
{
public void Configure(EntityTypeBuilder<TemplateStage> builder)
{
builder.ToTable("template_stages");
builder.HasKey(s => s.StageId);
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
builder.Property(s => s.RoleLabel).HasMaxLength(60);
builder.Property(s => s.PosX).HasPrecision(18, 4);
builder.Property(s => s.PosY).HasPrecision(18, 4);
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
builder.HasOne(s => s.Template).WithMany(t => t.Stages)
.HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class StageEdgeConfiguration : IEntityTypeConfiguration<StageEdge>
{
public void Configure(EntityTypeBuilder<StageEdge> builder)
{
builder.ToTable("stage_edges");
builder.HasKey(e => e.EdgeId);
builder.HasOne(e => e.Template).WithMany(t => t.Edges)
.HasForeignKey(e => e.TemplateId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.ParentStage).WithMany()
.HasForeignKey(e => e.ParentStageId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.ChildStage).WithMany()
.HasForeignKey(e => e.ChildStageId).OnDelete(DeleteBehavior.Restrict);
// One arrow per ordered pair; self-loops and cycles are rejected by the validator.
builder.HasIndex(e => new { e.ParentStageId, e.ChildStageId }).IsUnique();
}
}
public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInput>
{
public void Configure(EntityTypeBuilder<StageInput> builder)
{
builder.ToTable("stage_inputs");
builder.HasKey(i => i.InputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromOutput).WithMany()
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class StageOutputConfiguration : IEntityTypeConfiguration<StageOutput>
{
public void Configure(EntityTypeBuilder<StageOutput> builder)
{
builder.ToTable("stage_outputs");
builder.HasKey(o => o.OutputId);
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
builder.Property(o => o.QtyPerBatch).HasPrecision(18, 4);
builder.HasOne(o => o.Stage).WithMany(s => s.Outputs)
.HasForeignKey(o => o.StageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class ProductionRunConfiguration : IEntityTypeConfiguration<ProductionRun>
{
public void Configure(EntityTypeBuilder<ProductionRun> builder)
{
builder.ToTable("production_runs");
builder.HasKey(r => r.RunId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.TargetQty).HasPrecision(18, 4);
builder.Property(r => r.ScaleFactor).HasPrecision(18, 6);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasOne(r => r.Template).WithMany(t => t.Runs)
.HasForeignKey(r => r.TemplateId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.OutputBin).WithMany().HasForeignKey(r => r.OutputBinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.CancelReason).WithMany().HasForeignKey(r => r.CancelReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
// Run board filters (docs/30 §D.2) and the template edit-lock count (FR-MFG-06).
builder.HasIndex(r => r.Status);
builder.HasIndex(r => new { r.TemplateId, r.Status });
}
}
public sealed class RunStageConfiguration : IEntityTypeConfiguration<RunStage>
{
public void Configure(EntityTypeBuilder<RunStage> builder)
{
builder.ToTable("run_stages");
builder.HasKey(s => s.RunStageId);
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
builder.Property(s => s.RoleLabel).HasMaxLength(60);
builder.Property(s => s.PosX).HasPrecision(18, 4);
builder.Property(s => s.PosY).HasPrecision(18, 4);
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
builder.Property(s => s.FieldValues).HasColumnType("jsonb");
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasOne(s => s.Run).WithMany(r => r.Stages)
.HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade);
// SetNull, not Restrict: a template edit may delete a stage that completed or
// cancelled runs still point at. Everything needed to render such a run is copied
// onto this row, so losing the provenance link is the intended trade (FR-MFG-06).
builder.HasOne(s => s.TemplateStage).WithMany()
.HasForeignKey(s => s.TemplateStageId).OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(s => new { s.RunId, s.Status });
}
}
public sealed class RunEdgeConfiguration : IEntityTypeConfiguration<RunEdge>
{
public void Configure(EntityTypeBuilder<RunEdge> builder)
{
builder.ToTable("run_edges");
builder.HasKey(e => e.RunEdgeId);
builder.HasOne(e => e.Run).WithMany(r => r.Edges)
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.ParentRunStage).WithMany()
.HasForeignKey(e => e.ParentRunStageId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.ChildRunStage).WithMany()
.HasForeignKey(e => e.ChildRunStageId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.ParentRunStageId, e.ChildRunStageId }).IsUnique();
}
}
public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunStageInput>
{
public void Configure(EntityTypeBuilder<RunStageInput> builder)
{
builder.ToTable("run_stage_inputs");
builder.HasKey(i => i.RunInputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
builder.Property(i => i.DeliveredQty).HasPrecision(18, 4);
builder.Property(i => i.ReturnedQty).HasPrecision(18, 4);
builder.Property(i => i.ReturnedValue).HasPrecision(18, 4);
builder.HasOne(i => i.RunStage).WithMany(s => s.Inputs)
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromRunOutput).WithMany()
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
// Transfers route by the source output, so this is the hot lookup.
builder.HasIndex(i => i.FromRunOutputId);
}
}
public sealed class RunStageOutputConfiguration : IEntityTypeConfiguration<RunStageOutput>
{
public void Configure(EntityTypeBuilder<RunStageOutput> builder)
{
builder.ToTable("run_stage_outputs");
builder.HasKey(o => o.RunOutputId);
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
builder.Property(o => o.PlannedQty).HasPrecision(18, 4);
builder.Property(o => o.ProducedQty).HasPrecision(18, 4);
builder.Property(o => o.ScrappedQty).HasPrecision(18, 4);
builder.Property(o => o.TransferredQty).HasPrecision(18, 4);
builder.HasOne(o => o.RunStage).WithMany(s => s.Outputs)
.HasForeignKey(o => o.RunStageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(o => o.ScrapReason).WithMany().HasForeignKey(o => o.ScrapReasonCodeId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class RunStageEventConfiguration : IEntityTypeConfiguration<RunStageEvent>
{
public void Configure(EntityTypeBuilder<RunStageEvent> builder)
{
// Append-only history: the app never updates or deletes these rows.
builder.ToTable("run_stage_events");
builder.HasKey(e => e.EventId);
builder.Property(e => e.EventType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(e => e.Note).HasMaxLength(500);
builder.Property(e => e.Payload).HasColumnType("jsonb");
builder.Property(e => e.CreatedAt).IsRequired();
builder.HasOne(e => e.Run).WithMany(r => r.Events)
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
// SetNull rather than Cascade: the run-level cascade above already removes these
// rows, and a second cascade path (run → stage → event) is what EF rejects.
builder.HasOne(e => e.RunStage).WithMany(s => s.Events)
.HasForeignKey(e => e.RunStageId).OnDelete(DeleteBehavior.SetNull);
builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
// The run-detail timeline reads the whole run's history in one ordered pass.
builder.HasIndex(e => new { e.RunId, e.EventId });
}
}
@@ -30,6 +30,12 @@ public static class DataSeeder
("WRONG", "Wrong Item", ReasonContext.Return), ("WRONG", "Wrong Item", ReasonContext.Return),
("OVER", "Over-supply", ReasonContext.Return), ("OVER", "Over-supply", ReasonContext.Return),
("QREJ", "Quality Reject", 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) 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<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
public DbSet<Payslip> Payslips => Set<Payslip>(); 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(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 => modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{ {
b.Property<int>("PoId") b.Property<int>("PoId")
@@ -2769,6 +2899,261 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("role_permissions", (string)null); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b =>
{ {
b.Property<int>("SalaryComponentId") b.Property<int>("SalaryComponentId")
@@ -2856,6 +3241,114 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("serials", (string)null); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{ {
b.Property<int>("AdjustmentId") b.Property<int>("AdjustmentId")
@@ -3537,6 +4030,48 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("hr_tax_slabs", (string)null); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{ {
b.Property<int>("UomId") b.Property<int>("UomId")
@@ -4392,6 +4927,58 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("UpdatedByUser"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -4558,6 +5145,143 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Role"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Item", "Item") b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -4569,6 +5293,92 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Item"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -4837,6 +5647,17 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("NavItem"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
@@ -4954,6 +5775,24 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Lines"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
@@ -4976,6 +5815,15 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Quotations"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
@@ -4991,6 +5839,13 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b =>
{
b.Navigation("Inputs");
b.Navigation("Outputs");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
+16 -1
View File
@@ -10,6 +10,7 @@ using ERPCore.Services;
using ERPCore.Services.Auth; using ERPCore.Services.Auth;
using ERPCore.Services.Hrm; using ERPCore.Services.Hrm;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.Services.Production;
using ERPCore.Services.Stock; using ERPCore.Services.Stock;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
@@ -81,6 +82,7 @@ builder.Services.AddScoped<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>(); builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Stock core + goods receipt (docs/11 §45) // Stock core + goods receipt (docs/11 §45)
builder.Services.AddScoped<IUomConverter, UomConverter>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>(); builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>(); builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>(); builder.Services.AddScoped<IGrnService, GrnService>();
@@ -97,6 +99,9 @@ builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) // Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
builder.Services.AddScoped<IAuditService, AuditService>(); builder.Services.AddScoped<IAuditService, AuditService>();
// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement)
builder.Services.AddScoped<IDashboardService, DashboardService>();
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents // HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>(); builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
builder.Services.AddScoped<IBranchService, BranchService>(); builder.Services.AddScoped<IBranchService, BranchService>();
@@ -133,12 +138,22 @@ builder.Services.AddScoped<IPayslipService, PayslipService>();
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities // HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
builder.Services.AddScoped<IHrReportService, HrReportService>(); 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) // Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>(); builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1) // Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
builder.Services.AddEndpointsApiExplorer(); 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(); var app = builder.Build();
@@ -0,0 +1,79 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Dashboard;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Aggregate counts pulled straight from each domain's repository — no PagedResponse
/// overhead, since the dashboard only needs totals. Low-stock reuses <see cref="IReorderService"/>
/// rather than re-deriving the FIFO-available-vs-reorder-point comparison (docs/11 §5.7).
/// On-hand summary and stock valuation sum <see cref="StockLayer.QtyRemaining"/> (and
/// QtyRemaining × UnitCost for valuation) directly in SQL — cheap, unlike reorder alerts,
/// because they need no per-item live lookup.
/// </summary>
public sealed class DashboardService : IDashboardService
{
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<Grn> _grns;
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<Requisition> _requisitions;
private readonly IRepository<StockCount> _counts;
private readonly IRepository<Rfq> _rfqs;
private readonly IReorderService _reorder;
public DashboardService(
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IReorderService reorder)
{
_layers = layers;
_grns = grns;
_pos = pos;
_requisitions = requisitions;
_counts = counts;
_rfqs = rfqs;
_reorder = reorder;
}
public async Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default)
{
// Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
// which cannot run concurrent operations.
var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
var onHandWarehouses = await _layers.Query().AsNoTracking()
.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await _layers.Query().AsNoTracking()
.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
// Select — project to an anonymous type first, then materialize into the record.
var stockValuationByWarehouseRaw = await _layers.Query().AsNoTracking()
.GroupBy(l => l.WarehouseId)
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.OrderByDescending(w => w.Total)
.ToListAsync(ct);
var stockValuationByWarehouse = stockValuationByWarehouseRaw
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
.ToList();
var pendingGrns = await _grns.Query().AsNoTracking().CountAsync(g => g.Status == GrnStatus.Draft, ct);
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var pendingCounts = await _counts.Query().AsNoTracking()
.CountAsync(c => c.Status == CountStatus.Counted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging.
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems;
return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs);
}
}
+10 -15
View File
@@ -33,9 +33,9 @@ public sealed class GrnService : IGrnService
private readonly IRepository<Bin> _bins; private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors; private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches; private readonly IRepository<Batch> _batches;
private readonly IRepository<UomConversion> _conversions;
private readonly IRepository<StockLayer> _layers; private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IUomConverter _uomConverter;
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
@@ -45,7 +45,7 @@ public sealed class GrnService : IGrnService
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines, IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, 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) IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{ {
_grns = grns; _grns = grns;
@@ -57,9 +57,9 @@ public sealed class GrnService : IGrnService
_bins = bins; _bins = bins;
_vendors = vendors; _vendors = vendors;
_batches = batches; _batches = batches;
_conversions = conversions;
_layers = layers; _layers = layers;
_ledger = ledger; _ledger = ledger;
_uomConverter = uomConverter;
_fifo = fifo; _fifo = fifo;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
@@ -345,19 +345,14 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges 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) Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
{ => _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, 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);
}
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{ {
@@ -0,0 +1,9 @@
using ERPCore.Dtos.Dashboard;
namespace ERPCore.Services.Interfaces;
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
public interface IDashboardService
{
Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default);
}
@@ -29,11 +29,22 @@ public interface IFifoCostingService
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync( Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default); 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( Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId, int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, 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> /// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default); Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -0,0 +1,99 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Production run lifecycle (docs/30 §D.2D.3, FR-MFG-08..19). Every stock-affecting
/// action runs inside a single <c>ExecuteInTransactionAsync</c> scope and consumes stock
/// only through <c>IFifoCostingService</c> (NFR-02/NFR-05).
/// </summary>
public interface IProductionRunService
{
Task<PagedResponse<RunSummaryDto>> ListAsync(
PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId,
CancellationToken ct = default);
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
/// <summary>
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
/// <c>PRD-…</c> document number, and leaves entry stages Ready with the rest Waiting.
/// </summary>
Task<ETagged<RunGraphDto>> CreateAsync(CreateRunRequest request, CancellationToken ct = default);
/// <summary>
/// Per-run quantity override on a stage that has not started
/// (<c>409 STAGE_NOT_EDITABLE</c> once it has). Re-evaluates the stage's readiness,
/// since raising an upstream input's planned quantity can un-ready it.
/// </summary>
Task<RunStageDto> UpdateStageQuantitiesAsync(
int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default);
/// <summary>
/// <c>Ready → InProgress</c> (FR-MFG-10). FIFO-consumes every Stock input from the run
/// warehouse in one transaction and stamps <c>actualStartAt</c>.
/// <para>Consumes <c>max(0, plannedBase consumedQty)</c> per input, so a rework
/// restart with an unchanged planned quantity consumes nothing and one with a raised
/// planned quantity consumes only the delta (FR-MFG-16).</para>
/// </summary>
Task<StartStageResultDto> StartStageAsync(int runId, int runStageId, CancellationToken ct = default);
/// <summary>
/// <c>InProgress → Done</c> (FR-MFG-11). Records produced and scrapped quantities per
/// output plus the custom field values. A re-complete after a rework <b>overwrites</b>
/// the previous figures rather than adding to them.
/// </summary>
Task<RunStageDto> CompleteStageAsync(
int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default);
/// <summary>
/// <c>Done → Approved</c> (FR-MFG-12/13). Non-terminal: hands WIP to the children,
/// defaulting to the full available quantity. Terminal: posts the production receipt —
/// a finished-goods layer costed at <c>costPool / goodQty</c> — and completes the run.
/// </summary>
Task<ApproveStageResultDto> ApproveStageAsync(
int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default);
/// <summary>
/// Later partial transfer of a remainder held on an already-Approved stage (FR-MFG-12),
/// never exceeding produced scrapped already transferred.
/// </summary>
Task<TransferResultDto> TransferAsync(
int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default);
/// <summary>
/// Returns unconsumed material to stock before the receipt closes the pool (FR-MFG-14).
/// The inbound layer is created at the <b>weighted cost actually consumed</b> for that
/// input, so the move is cost-preserving and the pool reduces by exactly what leaves it.
/// <c>409 RUN_COST_CLOSED</c> once the run has completed.
/// </summary>
Task<ReturnLeftoverResultDto> ReturnLeftoverAsync(
int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default);
/// <summary>
/// Downstream reject (FR-MFG-15): this stage rejects the work it received, its delivering
/// parents revert <c>Approved → InProgress</c> with their transferred quantities pulled
/// back, and this stage returns to Waiting. Consumed stock stays consumed.
/// </summary>
Task<RejectIntakeResultDto> RejectIntakeAsync(
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
/// <summary>
/// Terminal reject (FR-MFG-16): resets the whole run to its starting stages, increments
/// <c>reworkCount</c> and snapshots the discarded figures into the event history.
/// Already-consumed material remains in the cost pool.
/// </summary>
Task<TerminalRejectResultDto> RejectTerminalAsync(
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
/// <summary>
/// Cancels an in-progress run (FR-MFG-17). Net consumed-and-not-returned stock goes back
/// at its consumed weighted cost; scrapped output quantities are written off on the event.
/// <c>409 RUN_NOT_CANCELLABLE</c> for a completed run.
/// </summary>
Task<CancelRunResultDto> CancelAsync(int runId, CancelRunRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,33 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Production template CRUD and graph validation (docs/30 §D.1, FR-MFG-01..07).
/// </summary>
public interface IProductionTemplateService
{
Task<PagedResponse<TemplateSummaryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default);
Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default);
/// <summary>
/// Replaces the whole graph. Requires the current row version (<c>If-Match</c>) and is
/// refused with <c>409 TEMPLATE_IN_USE</c> while any run of this template is
/// InProgress — edit-lock stands in for versioning (FR-MFG-06).
/// </summary>
Task<ETagged<TemplateGraphDto>> UpdateAsync(
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>
/// Activate/deactivate (FR-MFG-01). Deliberately <b>not</b> edit-locked: deactivating is
/// the "never delete a referenced master" path (FR-MD-08) and only stops <i>new</i> runs
/// being started, so it must stay available while runs are in flight.
/// </summary>
Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
/// </summary>
/// <remarks>
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
/// one place that bridges the two.</para>
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
/// mis-cost the run).</para>
/// </remarks>
public interface IUomConverter
{
/// <summary>
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
/// conversion is defined for the item from that UOM to its base.
/// </summary>
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
/// <summary>
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
/// production stage input declares a quantity; its cost comes from the FIFO layers it
/// consumes, not from the document).
/// </summary>
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
}
@@ -0,0 +1,211 @@
using ERPCore.Domain.Enums;
using ERPCore.System.Errors;
namespace ERPCore.Services.Production;
/// <summary>
/// Validates a template stage graph (FR-MFG-02, FR-MFG-04, FR-MFG-05) and throws the
/// matching <c>422 GRAPH_*</c> domain error on the first violation.
/// </summary>
/// <remarks>
/// <para>This is an <b>algorithm, not a service</b> — pure, synchronous, no database and
/// no DI, in the spirit of <c>FifoCostingService</c> being the one place FIFO lives. It
/// deliberately has no interface: <c>Services/Interfaces/</c> exists for things that get
/// injected, and registering this would buy nothing.</para>
/// <para>It works entirely in <b>keys</b>, never database ids, so the identical code path
/// runs for a POST (where nothing has an id yet) and a PUT (where most things do). All
/// database-dependent checks — does this item exist, is it Active, does this UOM exist —
/// stay in <c>ProductionTemplateService</c> so this stays free of I/O.</para>
/// <para>Checks run cheapest-first and fail fast, and every message names the offending
/// stage or edge so the canvas can focus it (docs/21 §2).</para>
/// </remarks>
public static class ProductionGraphValidator
{
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
public sealed record OutputDraft(string Key, string Name, int? ItemId);
public sealed record StageDraft(
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
public readonly record struct EdgeDraft(string ParentKey, string ChildKey);
/// <summary>
/// Throws on the first rule violation; returns silently for a valid graph.
/// </summary>
public static void Validate(IReadOnlyList<StageDraft> stages, IReadOnlyList<EdgeDraft> edges)
{
// 1 — structural hygiene. These are all blocked in the canvas at draw time
// (docs/21 §2); this is the server backstop for a hand-rolled request.
if (stages.Count == 0)
throw Invalid("A template must have at least one stage.");
var stageByKey = new Dictionary<string, StageDraft>(StringComparer.Ordinal);
foreach (var s in stages)
{
if (string.IsNullOrWhiteSpace(s.Key))
throw Invalid("Every stage requires a key.");
if (!stageByKey.TryAdd(s.Key, s))
throw Invalid($"Duplicate stage key '{s.Key}'.");
}
// Output keys are unique across the whole template, not just within a stage —
// an Upstream input names one by key alone, so a collision would be ambiguous.
var outputOwner = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var s in stages)
foreach (var o in s.Outputs)
{
if (string.IsNullOrWhiteSpace(o.Key))
throw Invalid($"Every output of stage '{s.Name}' requires a key.");
if (!outputOwner.TryAdd(o.Key, s.Key))
throw Invalid($"Duplicate output key '{o.Key}' (stage '{s.Name}').");
}
var edgeSet = new HashSet<(string, string)>();
foreach (var e in edges)
{
if (!stageByKey.ContainsKey(e.ParentKey) || !stageByKey.ContainsKey(e.ChildKey))
throw Invalid($"Edge '{e.ParentKey}' → '{e.ChildKey}' references a stage that is not in the payload.");
if (string.Equals(e.ParentKey, e.ChildKey, StringComparison.Ordinal))
throw Invalid($"Stage '{stageByKey[e.ParentKey].Name}' cannot connect to itself.");
if (!edgeSet.Add((e.ParentKey, e.ChildKey)))
throw Invalid($"Duplicate edge '{stageByKey[e.ParentKey].Name}' → '{stageByKey[e.ChildKey].Name}'.");
}
var children = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
var parents = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
foreach (var (parent, child) in edgeSet)
{
children[parent].Add(child);
parents[child].Add(parent);
}
// 2 — cycle detection by Kahn's algorithm. If the toposort can't reach every
// stage, the unreached set is exactly the stages trapped in (or downstream of)
// a cycle, which is what the client highlights.
var indegree = stages.ToDictionary(s => s.Key, s => parents[s.Key].Count, StringComparer.Ordinal);
var queue = new Queue<string>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
var sorted = 0;
while (queue.Count > 0)
{
var key = queue.Dequeue();
sorted++;
foreach (var child in children[key])
if (--indegree[child] == 0)
queue.Enqueue(child);
}
if (sorted != stages.Count)
{
var trapped = indegree.Where(kv => kv.Value > 0).Select(kv => stageByKey[kv.Key].Name);
throw new DomainException(ErrorCodes.GraphCycle,
$"The stage graph contains a cycle involving: {string.Join(", ", trapped)}.", 422);
}
// 3 — exactly one terminal stage (multiple starts may converge, but the line must
// end in one place, because that single stage is what receives the finished good).
var terminals = stages.Where(s => children[s.Key].Count == 0).ToList();
if (terminals.Count != 1)
throw new DomainException(ErrorCodes.GraphTerminalCount,
terminals.Count == 0
? "The stage graph has no final stage."
: $"The stage graph must converge to exactly one final stage, but {terminals.Count} have no outgoing connection: {string.Join(", ", terminals.Select(t => t.Name))}.",
422);
var terminal = terminals[0];
var entries = stages.Where(s => parents[s.Key].Count == 0).ToList();
// 4 — connectivity. One traversal each way subsumes all three of "no disconnected
// stages", "every stage reachable from an entry" and "every stage reaches the
// terminal": an isolated stage simply appears in neither set. A lone stage is both
// an entry and the terminal, so it falls out correctly with no special case.
//
// Kept as defence in depth, but note it is unreachable once checks 2 and 3 pass:
// in an acyclic graph every stage is reachable from some source, and if exactly one
// stage lacks an outbound edge then every stage necessarily reaches it. An isolated
// stage therefore surfaces as GRAPH_TERMINAL_COUNT (it is a second terminal), which
// is the better message anyway because it names both offenders. Verified by the M2
// smoke test, which asserts that behaviour explicitly.
var fromEntry = Reach(entries.Select(s => s.Key), children);
var toTerminal = Reach([terminal.Key], parents);
var stranded = stages.Where(s => !fromEntry.Contains(s.Key) || !toTerminal.Contains(s.Key)).ToList();
if (stranded.Count > 0)
throw new DomainException(ErrorCodes.GraphDisconnected,
$"Every stage must sit on a path from a starting stage to '{terminal.Name}', but these do not: {string.Join(", ", stranded.Select(s => s.Name))}.",
422);
// 5 — input sources (FR-MFG-04). An Upstream input may only draw from an output of
// a DIRECT parent: allowing a grandparent's output would mean WIP skipping a stage.
foreach (var s in stages)
{
var allowed = parents[s.Key]
.SelectMany(p => stageByKey[p].Outputs.Select(o => o.Key))
.ToHashSet(StringComparer.Ordinal);
foreach (var input in s.Inputs)
{
if (input.Source == StageInputSource.Upstream)
{
if (input.ItemId is not null)
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream and cannot also reference an item.", 422);
if (string.IsNullOrWhiteSpace(input.FromOutputKey))
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream but names no source output.", 422);
if (!allowed.Contains(input.FromOutputKey))
{
var owner = outputOwner.TryGetValue(input.FromOutputKey, out var ownerKey)
? $"'{stageByKey[ownerKey].Name}' is not a direct parent of '{s.Name}'"
: $"output '{input.FromOutputKey}' does not exist";
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' must draw from a direct parent's output — {owner}.", 422);
}
}
else
{
if (input.ItemId is null)
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and requires an item.", 422);
if (!string.IsNullOrWhiteSpace(input.FromOutputKey))
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
}
}
}
// 6 — outputs (FR-MFG-05). The terminal stage produces exactly one real Item (the
// finished good the receipt creates a layer for); every other output is internal
// WIP and must stay item-less, or it would imply stock that never exists.
if (terminal.Outputs.Count != 1)
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
$"The final stage '{terminal.Name}' must have exactly one output, but has {terminal.Outputs.Count}.", 422);
if (terminal.Outputs[0].ItemId is null)
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
$"The output of the final stage '{terminal.Name}' must reference the finished item.", 422);
foreach (var s in stages.Where(s => !ReferenceEquals(s, terminal)))
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
throw Invalid(
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
}
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
private static HashSet<string> Reach(IEnumerable<string> roots, Dictionary<string, List<string>> next)
{
var seen = new HashSet<string>(roots, StringComparer.Ordinal);
var queue = new Queue<string>(seen);
while (queue.Count > 0)
foreach (var n in next[queue.Dequeue()])
if (seen.Add(n))
queue.Enqueue(n);
return seen;
}
/// <summary>
/// Structural violations the client already prevents and docs/30 §D.4 assigns no
/// dedicated code to — still 422, still carrying a message that names the culprit.
/// </summary>
private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
}
@@ -0,0 +1,41 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ERPCore.Services.Production;
/// <summary>
/// The one serializer for every jsonb column in the manufacturing module —
/// <c>template_stages.field_defs</c>, <c>run_stages.field_defs</c>/<c>field_values</c>
/// and <c>run_stage_events.payload</c>.
/// </summary>
/// <remarks>
/// <para>Those columns hold a pre-serialized <c>string</c> rather than a mapped POCO,
/// following the <c>AuditLog.ChangeSet</c> precedent. Beyond consistency this keeps
/// <c>AuditScribe</c> honest: an owned/typed jsonb mapping would surface the nested
/// objects as their own change-tracker entries and scatter spurious audit rows across
/// the new tables.</para>
/// <para>Everything is written through here so a column can only ever contain canonical
/// JSON — the raw client string is never stored, which means a malformed
/// <c>fieldDefs</c> can't reach the database and can't later break a run created from
/// that template.</para>
/// </remarks>
public static class ProductionJson
{
/// <summary>Matches the API's own wire format (docs/11 §1.3): camelCase, enums as strings.</summary>
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
/// <summary>
/// Reads a stored column back. Returns <paramref name="fallback"/> for null/blank so a
/// caller never has to null-check; a parse failure is a genuine data-integrity problem
/// and is allowed to throw.
/// </summary>
public static T Deserialize<T>(string? json, T fallback)
=> string.IsNullOrWhiteSpace(json) ? fallback : JsonSerializer.Deserialize<T>(json, Options) ?? fallback;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,496 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Production;
/// <summary>
/// Production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07).
/// Graph rules live in <see cref="ProductionGraphValidator"/>; everything needing the
/// database (referential checks, the edit-lock, reconciliation) lives here.
/// </summary>
public sealed class ProductionTemplateService : IProductionTemplateService
{
private readonly IRepository<ProductionTemplate> _templates;
private readonly IRepository<TemplateStage> _stages;
private readonly IRepository<StageInput> _inputs;
private readonly IRepository<StageOutput> _outputs;
private readonly IRepository<StageEdge> _edges;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<ProductionRun> _runs;
private readonly IUnitOfWork _uow;
private readonly ICurrentUser _currentUser;
public ProductionTemplateService(
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
IUnitOfWork uow, ICurrentUser currentUser)
{
_templates = templates;
_stages = stages;
_inputs = inputs;
_outputs = outputs;
_edges = edges;
_items = items;
_uoms = uoms;
_runs = runs;
_uow = uow;
_currentUser = currentUser;
}
public async Task<PagedResponse<TemplateSummaryDto>> ListAsync(
PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _templates.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
var total = await q.CountAsync(ct);
// Projected to an anonymous type first, then mapped client-side: EF Core 10 cannot
// translate a record constructor sitting alongside aggregates and a collection
// projection (same limitation recorded for WarehouseValuationDto, 2026-07-28).
var rows = await q
.OrderBy(t => t.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new
{
t.TemplateId,
t.Code,
t.Name,
t.Status,
StageCount = t.Stages.Count,
Stages = t.Stages.Select(s => new { s.StageId, s.Name }).ToList(),
Edges = t.Edges.Select(e => new { e.ParentStageId, e.ChildStageId }).ToList(),
// Drives the canvas edit-lock banner (docs/21 §2) and mirrors the
// condition UpdateAsync enforces server-side.
ActiveRunCount = t.Runs.Count(r => r.Status == ProductionRunStatus.InProgress),
t.CreatedBy,
t.CreatedAt
})
.ToListAsync(ct);
var items = rows.Select(t => new TemplateSummaryDto(
t.TemplateId, t.Code, t.Name, t.Status, t.StageCount,
FlowOrderedNames(
t.Stages.Select(s => (s.StageId, s.Name)).ToList(),
t.Edges.Select(e => (e.ParentStageId, e.ChildStageId)).ToList()),
t.ActiveRunCount, t.CreatedBy, t.CreatedAt)).ToList();
return PagedResponse<TemplateSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
/// <summary>
/// Stage names in <b>flow order</b> — upstream first — for the overview canvas, which
/// draws each template as a line left to right (docs/21 §1).
/// </summary>
/// <remarks>
/// Ordering by <c>StageId</c> would be insertion order, which routinely puts the terminal
/// stage first and renders the line backwards. Kahn's algorithm over the edge set gives the
/// real sequence, tie-broken by stage id so parallel branches come out deterministically.
/// Falls back to id order if the graph somehow contains a cycle, so a listing can never
/// fail because of bad data.
/// </remarks>
private static List<string> FlowOrderedNames(
List<(int StageId, string Name)> stages, List<(int ParentStageId, int ChildStageId)> edges)
{
var nameById = stages.ToDictionary(s => s.StageId, s => s.Name);
var children = stages.ToDictionary(s => s.StageId, _ => new List<int>());
var indegree = stages.ToDictionary(s => s.StageId, _ => 0);
foreach (var (parent, child) in edges)
{
if (!children.ContainsKey(parent) || !indegree.ContainsKey(child)) continue;
children[parent].Add(child);
indegree[child]++;
}
// SortedSet keeps the frontier in id order, so the output is stable run to run.
var frontier = new SortedSet<int>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
var ordered = new List<string>(stages.Count);
while (frontier.Count > 0)
{
var next = frontier.Min;
frontier.Remove(next);
ordered.Add(nameById[next]);
foreach (var child in children[next])
if (--indegree[child] == 0)
frontier.Add(child);
}
return ordered.Count == stages.Count
? ordered
: stages.OrderBy(s => s.StageId).Select(s => s.Name).ToList();
}
public async Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default)
{
var template = await LoadGraphQuery().AsNoTracking()
.FirstOrDefaultAsync(t => t.TemplateId == templateId, ct);
if (template is null) return null;
// Counted separately rather than Included: the graph query already fans out over four
// collections, and adding Runs would multiply those rows again for a single integer.
var activeRuns = await _runs.Query().AsNoTracking()
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, ct);
return new ETagged<TemplateGraphDto>(ToGraphDto(template, activeRuns), template.RowVersion);
}
public async Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _templates.Query().AnyAsync(t => t.Code == code, ct))
throw new ConflictException($"A production template with code '{code}' already exists.");
var template = new ProductionTemplate
{
Code = code,
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
Status = EntityStatus.Active,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow
};
await ValidateAsync(request, ct);
await _uow.ExecuteInTransactionAsync(async token =>
{
await _templates.AddAsync(template, token);
BuildGraph(template, request);
}, ct);
return await RequireGraphAsync(template.TemplateId, ct);
}
public async Task<ETagged<TemplateGraphDto>> UpdateAsync(
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
await ValidateAsync(request, ct);
var code = request.Code.Trim();
if (await _templates.Query().AnyAsync(t => t.Code == code && t.TemplateId != templateId, ct))
throw new ConflictException($"A production template with code '{code}' already exists.");
await _uow.ExecuteInTransactionAsync(async token =>
{
var template = await LoadGraphQuery().FirstOrDefaultAsync(t => t.TemplateId == templateId, token)
?? throw new NotFoundException($"Production template {templateId} was not found.");
if (template.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict,
"The template was modified by another request.", 412);
// FR-MFG-06: edit-lock instead of versioning. Checked inside the transaction to
// keep the window small; the residual race is benign because a run copies
// everything it needs at creation and never reads the template again.
var activeRuns = await _runs.Query().AsNoTracking()
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, token);
if (activeRuns > 0)
throw new DomainException(ErrorCodes.TemplateInUse,
$"This template has {activeRuns} run(s) in progress and cannot be edited until they finish.", 409);
template.Code = code;
template.Name = request.Name.Trim();
template.Description = request.Description?.Trim();
// Two passes, deliberately. Tearing the old graph down and flushing before
// rebuilding removes any dependence on how EF happens to order a mixed batch of
// inserts and deletes — which matters because stage_inputs → stage_outputs is a
// Restrict FK and stage_edges carries a unique (parent, child) index.
var keptStages = TearDownGraph(template, request);
await _uow.SaveChangesAsync(token);
BuildGraph(template, request, keptStages);
}, ct);
return await RequireGraphAsync(templateId, ct);
}
public async Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default)
{
var template = await _templates.Query().FirstOrDefaultAsync(t => t.TemplateId == templateId, ct)
?? throw new NotFoundException($"Production template {templateId} was not found.");
template.Status = status;
await _uow.SaveChangesAsync(ct);
}
// --- graph reconciliation ------------------------------------------------
private IQueryable<ProductionTemplate> LoadGraphQuery() =>
_templates.Query()
.Include(t => t.Stages).ThenInclude(s => s.Inputs)
.Include(t => t.Stages).ThenInclude(s => s.Outputs)
.Include(t => t.Edges);
/// <summary>
/// Runs the pure graph rules then the referential ones. Item/UOM existence needs the
/// database, so it can't live in the validator — but it has to run before any write.
/// </summary>
private async Task ValidateAsync(SaveTemplateRequest request, CancellationToken ct)
{
ProductionGraphValidator.Validate(
request.Stages.Select(s => new ProductionGraphValidator.StageDraft(
s.Key, s.Name,
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(),
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(),
request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList());
var itemIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId)))
.OfType<int>().Distinct().ToList();
if (itemIds.Count > 0)
{
var found = await _items.Query().AsNoTracking()
.Where(i => itemIds.Contains(i.ItemId))
.Select(i => new { i.ItemId, i.Status })
.ToListAsync(ct);
var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Item(s) {string.Join(", ", missing)} do not exist.", 422);
var inactive = found.Where(f => f.Status != EntityStatus.Active).Select(f => f.ItemId).ToList();
if (inactive.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
}
var uomIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
.Distinct().ToList();
var knownUoms = await _uoms.Query().AsNoTracking()
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
var missingUoms = uomIds.Except(knownUoms).ToList();
if (missingUoms.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
// client renders off. Unknown kinds would round-trip fine but draw nothing.
var badKinds = request.Annotations
.Select(a => a.Kind)
.Where(k => k is not ("box" or "line"))
.Distinct().ToList();
if (badKinds.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Unknown canvas annotation kind(s): {string.Join(", ", badKinds)}. Expected 'box' or 'line'.", 422);
}
/// <summary>
/// Removes everything the incoming payload replaces and returns the surviving stages
/// by key.
/// </summary>
/// <remarks>
/// Inputs and outputs are always replaced wholesale — nothing outside the template
/// references them, because a run copies into its own <c>run_stage_input</c>/
/// <c>run_stage_output</c> rows. Stages are <b>diffed</b>, not replaced, because
/// <c>run_stages.TemplateStageId</c> points at them; a stage that disappears from the
/// payload is deleted and that FK is set null for any historical run (see
/// <c>RunStageConfiguration</c>).
/// </remarks>
private Dictionary<string, TemplateStage> TearDownGraph(ProductionTemplate template, SaveTemplateRequest request)
{
var payloadKeys = request.Stages.Select(s => s.Key).ToHashSet(StringComparer.Ordinal);
foreach (var stage in template.Stages)
{
foreach (var input in stage.Inputs.ToList()) _inputs.Remove(input);
foreach (var output in stage.Outputs.ToList()) _outputs.Remove(output);
}
var kept = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
var doomed = new List<TemplateStage>();
foreach (var stage in template.Stages.ToList())
{
var key = stage.StageId.ToString();
if (payloadKeys.Contains(key)) kept[key] = stage;
else doomed.Add(stage);
}
// Edges are diffed rather than replaced: stage_edges has a unique (parent, child)
// index, and dropping then re-adding an unchanged edge in the same round trip can
// trip it depending on statement order.
var wanted = request.Edges
.Select(e => (Parent: ResolveKeptId(kept, e.ParentKey), Child: ResolveKeptId(kept, e.ChildKey)))
.Where(e => e.Parent is not null && e.Child is not null)
.Select(e => (e.Parent!.Value, e.Child!.Value))
.ToHashSet();
// Edges must go before stages. StageEdge.ParentStage/ChildStage are required
// relationships, so deleting a stage that a still-live edge points at makes EF throw
// "the association ... has been severed". Any edge touching a doomed stage is
// necessarily absent from `wanted` — the payload cannot reference a stage it dropped
// — so this ordering never orphans an edge the caller wanted to keep.
foreach (var edge in template.Edges.ToList())
if (!wanted.Contains((edge.ParentStageId, edge.ChildStageId)))
_edges.Remove(edge);
foreach (var stage in doomed)
_stages.Remove(stage);
return kept;
}
private static int? ResolveKeptId(Dictionary<string, TemplateStage> kept, string key)
=> kept.TryGetValue(key, out var stage) ? stage.StageId : null;
/// <summary>
/// Materialises the payload onto the template.
/// </summary>
/// <remarks>
/// Everything is wired through <b>navigation properties</b> rather than foreign-key
/// ints, so EF resolves the ids itself during a single <c>SaveChanges</c>. That is what
/// lets a brand-new Upstream input point at a brand-new output without an intermediate
/// save to materialise generated keys — outputs are built in the first pass precisely
/// so the second pass has the entity instances to hand.
/// </remarks>
private void BuildGraph(
ProductionTemplate template, SaveTemplateRequest request,
Dictionary<string, TemplateStage>? keptStages = null)
{
// Replaced wholesale — annotations are opaque client state, not part of the graph, so
// there is nothing to diff and nothing else can reference them.
template.Annotations = request.Annotations.Count == 0
? null
: ProductionJson.Serialize(request.Annotations);
var stagesByKey = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
var outputsByKey = new Dictionary<string, StageOutput>(StringComparer.Ordinal);
// Pass 1 — stages and their outputs.
foreach (var s in request.Stages)
{
if (keptStages is null || !keptStages.TryGetValue(s.Key, out var stage))
{
stage = new TemplateStage { Template = template };
template.Stages.Add(stage);
}
stage.Name = s.Name.Trim();
stage.RoleLabel = string.IsNullOrWhiteSpace(s.RoleLabel) ? null : s.RoleLabel.Trim();
stage.EstimatedMinutes = s.EstimatedMinutes;
stage.PosX = s.PosX;
stage.PosY = s.PosY;
stage.FieldDefs = ProductionJson.Serialize(s.FieldDefs);
stagesByKey[s.Key] = stage;
foreach (var o in s.Outputs)
{
var output = new StageOutput
{
Stage = stage,
ItemId = o.ItemId,
Name = o.Name.Trim(),
UomId = o.UomId,
QtyPerBatch = o.QtyPerBatch
};
stage.Outputs.Add(output);
outputsByKey[o.Key] = output;
}
}
// Pass 2 — inputs, which may reference any output built above.
foreach (var s in request.Stages)
{
var stage = stagesByKey[s.Key];
foreach (var i in s.Inputs)
{
stage.Inputs.Add(new StageInput
{
Stage = stage,
Source = i.Source,
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
UomId = i.UomId,
QtyPerBatch = i.QtyPerBatch
});
}
}
// Pass 3 — edges not already present.
var existing = template.Edges
.Select(e => (e.ParentStageId, e.ChildStageId)).ToHashSet();
foreach (var e in request.Edges)
{
var parent = stagesByKey[e.ParentKey];
var child = stagesByKey[e.ChildKey];
if (parent.StageId != 0 && child.StageId != 0 && existing.Contains((parent.StageId, child.StageId)))
continue;
template.Edges.Add(new StageEdge
{
Template = template,
ParentStage = parent,
ChildStage = child
});
}
}
// --- mapping -------------------------------------------------------------
private async Task<ETagged<TemplateGraphDto>> RequireGraphAsync(int templateId, CancellationToken ct)
=> await GetAsync(templateId, ct)
?? throw new NotFoundException($"Production template {templateId} was not found.");
private static TemplateGraphDto ToGraphDto(ProductionTemplate t, int activeRunCount)
{
// The key a client sends back is just the id as a string; computing it here keeps
// that contract in exactly one place.
var outputKeyById = t.Stages
.SelectMany(s => s.Outputs)
.ToDictionary(o => o.OutputId, o => o.OutputId.ToString());
var stages = t.Stages
.OrderBy(s => s.StageId)
.Select(s => new TemplateStageDto(
s.StageId, s.StageId.ToString(), s.Name, s.RoleLabel, s.EstimatedMinutes, s.PosX, s.PosY,
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
i.InputId, i.Source, i.ItemId, i.FromOutputId,
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
i.UomId, i.QtyPerBatch)).ToList(),
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
.ToList();
var edges = t.Edges
.OrderBy(e => e.EdgeId)
.Select(e => new TemplateEdgeDto(
e.EdgeId, e.ParentStageId, e.ChildStageId,
e.ParentStageId.ToString(), e.ChildStageId.ToString()))
.ToList();
return new TemplateGraphDto(
t.TemplateId, t.Code, t.Name, t.Description, t.Status, stages, edges,
ProductionJson.Deserialize<List<CanvasAnnotationDto>>(t.Annotations, []),
activeRunCount, t.CreatedBy, t.CreatedAt);
}
}
@@ -121,7 +121,8 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<StockLedger> PostLedgerAsync( public async Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId, int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, 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 var entry = new StockLedger
{ {
@@ -134,7 +135,9 @@ public sealed class FifoCostingService : IFifoCostingService
Direction = direction, Direction = direction,
QtyBase = qtyBase, QtyBase = qtyBase,
UnitCost = unitCost, 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, RunningBalance = runningBalance,
SourceDocType = sourceDocType, SourceDocType = sourceDocType,
SourceDocId = sourceDocId, 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 SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE";
public const string TemplateInactive = "TEMPLATE_INACTIVE";
public const string GraphCycle = "GRAPH_CYCLE";
public const string GraphTerminalCount = "GRAPH_TERMINAL_COUNT";
public const string GraphDisconnected = "GRAPH_DISCONNECTED";
public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID";
public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED";
public const string StageNotReady = "STAGE_NOT_READY";
public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS";
public const string StageNotDone = "STAGE_NOT_DONE";
public const string StageNotEditable = "STAGE_NOT_EDITABLE";
public const string StageRejectInvalid = "STAGE_REJECT_INVALID";
public const string RequiredFieldMissing = "REQUIRED_FIELD_MISSING";
public const string TransferExceedsAvailable = "TRANSFER_EXCEEDS_AVAILABLE";
public const string LeftoverExceedsConsumed = "LEFTOVER_EXCEEDS_CONSUMED";
public const string RunCostClosed = "RUN_COST_CLOSED";
public const string RunNotCancellable = "RUN_NOT_CANCELLABLE";
} }
+1 -1
View File
@@ -16,7 +16,7 @@
"RequiredRoleCode": "" "RequiredRoleCode": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5011" "BaseUrl": "http://localhost:5602"
}, },
"FileStorage": { "FileStorage": {
"RootPath": "App_Data/hr-documents", "RootPath": "App_Data/hr-documents",
+99
View File
@@ -178,9 +178,108 @@ 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] 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=`) - [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 1522-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` §§1113. 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 ## Done
<!-- move [x] items here with date + note if the active list grows long --> <!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-28 — Dashboard overview endpoint (`GET /dashboard/stats`)
- **New cross-domain aggregate for the frontend dashboard** — `Dtos/Dashboard/DashboardDtos.cs`, `IDashboardService`/`DashboardService`, `DashboardController` (`GET /api/v1/dashboard/stats`). Mirrors `docs/dashboard-implementation.pdf`'s widget list: low-stock alerts (reuses `IReorderService.GetAlertsAsync`, `PageSize:1` since it computes the full count before paging), on-hand total/warehouse-count and stock-valuation total/by-warehouse (all SQL-side `SUM`/`GROUP BY` over `StockLayer`, cheap unlike reorder alerts since they need no per-item live lookup), pending-approval POs, pending (Draft) GRNs, open (Submitted) requisitions, pending (Counted) stock counts, open RFQs. Registered in `Program.cs`. docs/11-BACKEND-PHASE1.md §5.8.
- **Not covered:** GRN inspection-hold counts (`HoldStatus` lives on GRN lines, no list/count endpoint exposes it) and recent stock movements (frontend calls `GET /stock/ledger` directly — no aggregation needed for a small `pageSize`).
- **Bug found + fixed during this work — `WarehouseValuationDto` construction inside `GroupBy().Select()` doesn't translate.** EF Core 10 can't turn a record's constructor call into SQL inside a grouped projection (`InvalidOperationException`, confirmed live via `logs/erpcore-20260728.log`). Fixed by projecting to an anonymous type first (`Select(g => new { g.Key, Total = ... })`), materializing with `ToListAsync`, then mapping to the DTO record client-side.
- **Unrelated bug found while testing this — `GET /items` 500s on every call: `column i.SalePrice does not exist`.** `ItemConfiguration.cs` maps `Item.SalePrice`, but the `AddItemSalePrice` migration (2026-07-22 entry above) was never actually applied to this dev database — despite that entry claiming "Applied to the local DB". Confirmed via `logs/erpcore-20260727.log`; `dotnet ef migrations add` against the current model produces an **empty** migration (no `Up`/`Down` ops), meaning the model snapshot already believes `SalePrice` exists even though the column doesn't — the snapshot and the real schema have drifted. **Not yet fixed** — needs a hand-written `AddColumn` migration (the auto-diff can't see the gap) run against this specific database; blocks the dashboard's on-hand/valuation widgets from ever showing item names, and blocks the entire Products page and every item picker (GRN/PO/ledger/valuation).
- **Verified:** `dotnet build` clean (isolated output directory, to avoid the Visual-Studio-debugger file lock that repeatedly blocked rebuilding the live dev instance this session). Runtime-verified against the live log after a VS restart — confirmed reaching real code (not 404), the `GroupBy` bug above was caught this way. Full 200-response verification still pending the next VS restart.
### 2026-07-22 — Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`) ### 2026-07-22 — Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`)
- **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1. - **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1.
- **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change**`GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3. - **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change**`GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3.
+351
View File
@@ -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())
+218
View File
@@ -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())
+329
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
+170
View File
@@ -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())
+269
View File
@@ -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())
+70
View File
@@ -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())
+259
View File
@@ -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}")
+64
View File
@@ -115,9 +115,73 @@ 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. - **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. - **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.1D.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 (F2F5) — 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 ## Done
<!-- move [x] items here with date + note if the active list grows long --> <!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-28 — Dashboard overview (`app/dashboard/page.tsx`)
- **Replaced the component-showcase placeholder with a real stats dashboard.** 7 `StatCard` tiles (Low Stock Alerts, Stock On-Hand, Pending Approval POs, Pending GRNs, Open Requisitions, Open Counts, Active RFQs), all wired to the new `GET /dashboard/stats` (`lib/api/dashboard.ts`, `types/dashboard.ts`) — see `Backend/PROGRESS.md`'s matching 2026-07-28 entry for the endpoint itself. Each tile links to its source list page.
- **Stock Valuation by Warehouse** — `BarChart` over `stats.stockValuationByWarehouse`, warehouse codes resolved via `warehousesApi.list()`.
- **Stock Movement Trend** — `LineChart`, 14-day In/Out totals bucketed client-side from `GET /stock/ledger?from=...&pageSize=200`. **Falls back to a hardcoded sample series (`SAMPLE_TREND_IN`/`OUT`) when the real ledger has no activity in that window**, so the chart isn't a flat zero line on a fresh/demo database — real data always wins when present. (An equivalent fallback was added to the Recent Stock Movements table during this pass and then explicitly removed at the user's request — that table shows only real data + an empty state.)
- **Recent Stock Movements** — table of the latest 5 ledger entries; shows `#itemId` rather than the item SKU, deliberately, to avoid a hard dependency on `GET /items` (see the bug below).
- **`StatCard` (`components/ui/stat-card.tsx`) fixed to use theme tokens** — it previously hardcoded `bg-white`/`text-slate-900`/`text-indigo-600`/`ring-black/5`, which was invisible-on-dark once the Dark/Vibrant themes existed. Now `bg-card`/`text-foreground`/`text-primary`/`ring-foreground/10`.
- **Bug found — `GET /items` 500s on every call** (`column i.SalePrice does not exist`) — this is why the dashboard and the movements table avoid `itemsApi` entirely. Root cause + fix status tracked in `Backend/PROGRESS.md`'s 2026-07-28 entry; **not yet fixed** as of this entry.
- **Chart color gotcha (found and fixed twice this session):** passing a CSS custom property or `color-mix()` string (e.g. `"var(--color-primary)"`) as a Chart.js `borderColor`/`backgroundColor` silently renders **black**, because a `<canvas>` 2D context cannot resolve CSS variables — it's not a themeable value, it's an invalid string that falls back to the default. Every chart on this page uses real static hex colors instead (`#6366f1`, `#22c55e`, `#ef4444`).
- **Verified:** `tsc --noEmit` clean throughout. Runtime verification blocked for most of this session by the dev backend running under an active Visual Studio debug session — killing the process externally just triggers VS's own auto-relaunch of the **stale** build (observed repeatedly; confirmed via process start-time checks), so `dotnet build`/`dotnet ef` against the live `bin/`/`obj/` failed on file locks. Worked around by building to an isolated `-o` output directory to verify compilation without touching the locked live build; **actually deploying a rebuild still requires stopping debugging inside Visual Studio itself** (not just closing a console window) — this blocked full end-to-end verification of `/dashboard/stats` until the user did that.
### 2026-07-22 — Item fixed sale price + GRN off-PO items / inline create ### 2026-07-22 — Item fixed sale price + GRN off-PO items / inline create
- **Item sale-price toggle** (`app/dashboard/products/new/page.tsx`). New "Fixed price / Use stock value" segmented toggle (default **stock**). **Stock** sends `salePrice: null` on every created item. **Fixed** reveals a top "fix value" input that pre-fills a per-variant **Sale price** column (`priceFor(key) = pricesByKey[key] ?? fixValue`, so editing a row overrides only it while the rest follow the shared value); submit is blocked until **every** generated variant has a price `> 0` (`validateVariantPrices` in `lib/validations/master-data.ts`). Each variant's price rides its own `POST /items` in the existing non-transactional create loop. `types/master-data.ts`: `salePrice` added to `CreateItemRequest` (optional) and `Item`/`ItemListItem` (`number|null`). - **Item sale-price toggle** (`app/dashboard/products/new/page.tsx`). New "Fixed price / Use stock value" segmented toggle (default **stock**). **Stock** sends `salePrice: null` on every created item. **Fixed** reveals a top "fix value" input that pre-fills a per-variant **Sale price** column (`priceFor(key) = pricesByKey[key] ?? fixValue`, so editing a row overrides only it while the rest follow the shared value); submit is blocked until **every** generated variant has a price `> 0` (`validateVariantPrices` in `lib/validations/master-data.ts`). Each variant's price rides its own `POST /items` in the existing non-transactional create loop. `types/master-data.ts`: `salePrice` added to `CreateItemRequest` (optional) and `Item`/`ItemListItem` (`number|null`).
- **GRN off-PO items + inline create** (`app/dashboard/receiving/grn/new/page.tsx`). "Add line" is now shown in **both** PO and direct mode — an added PO-mode line has `poLineId: null` (editable item/UOM, `unitCost` required) and the server receives it as a direct line. New **"New item"** button opens `/dashboard/products/new` in a new browser tab (`window.open(..., "_blank", "noopener,noreferrer")` — the first new-tab pattern in the app), and a **refresh** icon (`refreshItems`) re-pulls `GET /items?status=Active` so the new item is selectable without reloading the in-progress GRN. Existing `validateLine` covers off-PO lines unchanged. - **GRN off-PO items + inline create** (`app/dashboard/receiving/grn/new/page.tsx`). "Add line" is now shown in **both** PO and direct mode — an added PO-mode line has `poLineId: null` (editable item/UOM, `unitCost` required) and the server receives it as a direct line. New **"New item"** button opens `/dashboard/products/new` in a new browser tab (`window.open(..., "_blank", "noopener,noreferrer")` — the first new-tab pattern in the app), and a **refresh** icon (`refreshItems`) re-pulls `GET /items?status=Active` so the new item is selectable without reloading the in-progress GRN. Existing `validateLine` covers off-PO lines unchanged.
+251 -292
View File
@@ -1,325 +1,284 @@
"use client" "use client"
import * as React from "react" import { useEffect, useMemo, useState } from "react"
import { CheckCircle2, DollarSign, Package, Plus, ShoppingCart, Users } from "lucide-react" import Link from "next/link"
import {
AlertTriangle,
BadgeDollarSign,
Boxes,
Clock,
ClipboardList,
ListChecks,
PackageCheck,
ScrollText,
Send,
} from "lucide-react"
import { dashboardApi } from "@/lib/api/dashboard"
import { stockApi } from "@/lib/api/stock"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { RecentOrdersTable } from "@/components/dashboard/recent-orders-table" import { DashboardStats } from "@/types/dashboard"
import { Button } from "@/components/ui/button" import { LedgerEntry } from "@/types/stock"
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker" import { Warehouse } from "@/types/master-data"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogContent,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { toast } from "@/components/ui/toast" import { Skeleton } from "@/components/ui/skeleton"
import LineChart from "@/components/ui/line-chart" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import BarChart from "@/components/ui/bar-chart" import BarChart from "@/components/ui/bar-chart"
import PieChart from "@/components/ui/pie-chart" import LineChart from "@/components/ui/line-chart"
const indigoButton = const TREND_DAYS = 14
"bg-primary/10 text-primary hover:bg-primary/20 focus-visible:ring-primary/40"
function isoDate(d: Date) {
return d.toISOString().slice(0, 10)
}
// Shown only when the last TREND_DAYS days have no real ledger activity, so the
// chart isn't a flat zero line before there's any real movement to plot.
const SAMPLE_TREND_IN = [42, 58, 35, 70, 64, 30, 20, 85, 46, 55, 38, 62, 48, 72]
const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58]
export default function DashboardPage() { export default function DashboardPage() {
const [date, setDate] = React.useState<Date>() const [stats, setStats] = useState<DashboardStats | null>(null)
const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>() const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const from = new Date()
from.setDate(from.getDate() - (TREND_DAYS - 1))
Promise.all([
dashboardApi.stats(),
stockApi.ledger({ page: 1, pageSize: 5 }),
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200 }),
warehousesApi.list(),
])
.then(([statsRes, ledger, trendRes, warehousesRes]) => {
if (cancelled) return
setStats(statsRes)
setMovements(ledger.items)
setTrend(trendRes.items)
setWarehouses(warehousesRes.items)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
// Last TREND_DAYS days, oldest first, each bucket summing In/Out qty for that date.
const movementTrend = useMemo(() => {
const days: string[] = []
const cursor = new Date()
cursor.setDate(cursor.getDate() - (TREND_DAYS - 1))
for (let i = 0; i < TREND_DAYS; i++) {
days.push(isoDate(cursor))
cursor.setDate(cursor.getDate() + 1)
}
const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" }))
if (!trend || trend.length === 0) {
return { labels, inData: SAMPLE_TREND_IN, outData: SAMPLE_TREND_OUT }
}
const inByDay = new Map(days.map((d) => [d, 0]))
const outByDay = new Map(days.map((d) => [d, 0]))
for (const entry of trend) {
const day = entry.createdAt.slice(0, 10)
const bucket = entry.direction === "In" ? inByDay : outByDay
if (bucket.has(day)) bucket.set(day, (bucket.get(day) ?? 0) + entry.qtyBase)
}
return {
labels,
inData: days.map((d) => inByDay.get(d) ?? 0),
outData: days.map((d) => outByDay.get(d) ?? 0),
}
}, [trend])
const loaded = stats && movements && trend && warehouses
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<div> <div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p> <h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
<div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
{/* Basic */}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Product Detail</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{/* With ellipsis */}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbEllipsis />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Edit</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</div> </div>
<div> {error && (
<p className="mb-2 text-sm font-semibold text-muted-foreground">Variants</p> <div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
<div className="flex flex-wrap gap-2"> {error}
<Button variant="default">Default</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="success">Success</Button>
<Button variant="warning">Warning</Button>
<Button variant="info">Info</Button>
<Button variant="link">Link</Button>
</div>
</div> </div>
)}
<div> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
<p className="mb-2 text-sm font-semibold text-muted-foreground">Sizes</p> {loaded ? (
<div className="flex flex-wrap items-center gap-3">
<Button size="xs" className={indigoButton}>
Extra Small
</Button>
<Button size="sm" className={indigoButton}>
Small
</Button>
<Button size="default" className={indigoButton}>
Default
</Button>
<Button size="lg" className={indigoButton}>
Large
</Button>
<Button size="lg" className={cn(indigoButton, "h-12 px-8 text-base")}>
Extra Large
</Button>
<Button size="icon-sm" className={indigoButton} aria-label="Add (small)">
<Plus />
</Button>
<Button size="icon" className={indigoButton} aria-label="Add">
<Plus />
</Button>
<Button size="icon-lg" className={indigoButton} aria-label="Add (large)">
<Plus />
</Button>
<Button
size="icon-lg"
className={cn(indigoButton, "size-12")}
aria-label="Add (extra large)"
>
<Plus className="size-6" />
</Button>
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Date Picker</p>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Single date</span>
<DatePicker
value={date}
onChange={setDate}
placeholder="Select a date"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Date range</span>
<DateRangePicker
value={range}
onChange={setRange}
placeholder="Select date range"
/>
</div>
</div>
{(date || range?.from) && (
<p className="mt-2 text-xs text-muted-foreground">
{date && <>Selected: <span className="font-medium text-foreground">{date.toLocaleDateString()}</span></>}
{range?.from && (
<> <>
{date && " · "} <Link href="/dashboard/stock/reorder-alerts">
Range: <span className="font-medium text-foreground">{range.from.toLocaleDateString()}</span> <StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
{range.to && <> <span className="font-medium text-foreground">{range.to.toLocaleDateString()}</span></>} </Link>
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
</Link>
<Link href="/dashboard/procurement/purchase-orders">
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
</Link>
<Link href="/dashboard/procurement/requisitions">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
</Link>
<Link href="/dashboard/stock/counts">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
</Link>
<Link href="/dashboard/procurement/rfqs">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
</Link>
</> </>
)} ) : (
</p> !error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)} )}
</div> </div>
<div> <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<p className="mb-2 text-sm font-semibold text-muted-foreground">Modal</p> <div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<Dialog> <h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<DialogTrigger render={<Button variant="outline">Open Modal</Button>} /> <BadgeDollarSign className="size-4 shrink-0 text-primary" />
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-md"> Stock Valuation by Warehouse
<DialogHeader className="items-center gap-3 px-2 pt-4 text-center sm:px-4"> </h2>
<div className="flex size-14 items-center justify-center rounded-full bg-success/10"> <Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
<CheckCircle2 className="size-7 text-success" /> View details
</div> </Link>
<DialogTitle className="text-lg font-bold">Order confirmed</DialogTitle>
<DialogDescription>
Your order has been placed successfully and is now being processed.
</DialogDescription>
</DialogHeader>
<div className="flex justify-center px-2 pb-2 sm:px-4">
<Button size="lg" className="w-full sm:w-auto sm:px-10">
Continue
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
<div> {loaded ? (
<p className="mb-2 text-sm font-semibold text-muted-foreground">Toast</p> (stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="flex flex-wrap gap-2"> <div className="h-52 sm:h-64">
<Button variant="outline" onClick={() => toast({ title: "Default toast message" })}>
Default
</Button>
<Button variant="success" onClick={() => toast.success("Success!", "Your changes have been saved.")}>
Success
</Button>
<Button variant="destructive" onClick={() => toast.error("Error", "Something went wrong. Please try again.")}>
Error
</Button>
<Button variant="warning" onClick={() => toast.warning("Warning", "This action cannot be undone.")}>
Warning
</Button>
<Button variant="info" onClick={() => toast.info("Info", "Your session will expire in 5 minutes.")}>
Info
</Button>
<Button
variant="secondary"
onClick={() =>
toast({
title: "With Action",
description: "Do you want to undo this change?",
actionLabel: "Undo",
onAction: () => toast.success("Undone!", "Change has been reverted."),
})
}
>
With Action
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Alert Dialogs</p>
<div className="flex flex-wrap gap-2">
<AlertDialog>
<AlertDialogTrigger render={<Button variant="info">Info</Button>} />
<AlertDialogContent
variant="info"
title="Update available"
description="A new version is ready. Reload the page to apply the latest changes."
confirmLabel="Reload"
onConfirm={() => toast.info("Reloading...", "Applying the latest update.")}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="success">Success</Button>} />
<AlertDialogContent
variant="success"
title="Order confirmed"
description="Your order has been placed successfully and is now being processed."
confirmLabel="Continue"
cancelLabel="View order"
onConfirm={() => toast.success("Done!", "Redirecting to dashboard.")}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="warning">Warning</Button>} />
<AlertDialogContent
variant="warning"
title="Unsaved changes"
description="You have unsaved changes. Leaving this page will discard them."
confirmLabel="Leave anyway"
onConfirm={() => toast.warning("Changes discarded")}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="destructive">Delete</Button>} />
<AlertDialogContent
variant="destructive"
title="Delete record?"
description="This action is permanent and cannot be undone. All associated data will be removed."
confirmLabel="Delete"
onConfirm={() => toast.error("Deleted", "The record has been permanently removed.")}
/>
</AlertDialog>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Total Revenue" value={45231.89} icon={DollarSign} />
<StatCard label="Orders" value={1284} icon={ShoppingCart} />
<StatCard label="Customers" value={892} icon={Users} />
<StatCard label="Low Stock Items" value={16} icon={Package} />
</div>
<RecentOrdersTable />
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Sales (Line)</h3>
<div className="h-56">
<LineChart
labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]}
datasets={[{ label: "Sales", data: [120, 200, 150, 220, 180, 260] }]}
/>
</div>
</div>
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Revenue (Bar)</h3>
<div className="h-56">
<BarChart <BarChart
labels={["Q1", "Q2", "Q3", "Q4"]} labels={stats.stockValuationByWarehouse.map(
datasets={[{ label: "Revenue", data: [30000, 42000, 36000, 48000], backgroundColor: "var(--color-primary)" }]} (w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1",
},
]}
/> />
</div> </div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div> </div>
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200"> <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3> <div className="mb-4 flex items-center gap-2">
<div className="h-56"> <ScrollText className="size-4 shrink-0 text-primary" />
<PieChart labels={["A","B","C"]} data={[45, 30, 25]} /> <h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div> </div>
{loaded ? (
<div className="h-52 sm:h-64">
<LineChart
labels={movementTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div> </div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<ScrollText className="size-4 shrink-0 text-primary" />
Recent Stock Movements
</h2>
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View all
</Link>
</div>
{loaded ? (
movements.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
<TableCell className="text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
)
) : (
!error && (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 rounded-lg" />
))}
</div>
)
)}
</div> </div>
</div> </div>
) )
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react" import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -269,9 +269,6 @@ export default function PurchaseOrderDetailPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{po.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{po.docNo}</h1>
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders" import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
@@ -221,9 +221,6 @@ function NewPurchaseOrderContent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1> <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> <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 { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft } from "lucide-react" import {} from "lucide-react"
import { purchaseReturnsApi } from "@/lib/api/purchase-returns" import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
@@ -156,9 +156,6 @@ function NewPurchaseReturnContent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Return</h1> <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> <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 { useEffect, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react" import { FileText, Send, ShoppingCart } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
@@ -74,9 +74,6 @@ export default function RequisitionDetailPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{requisition.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{requisition.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
@@ -103,9 +103,6 @@ export default function NewRequisitionPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">New Requisition</h1> <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> <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 { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, ShoppingCart } from "lucide-react" import { ShoppingCart } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs" import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -158,9 +158,6 @@ export default function RfqDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs" import { rfqsApi } from "@/lib/api/rfqs"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
@@ -149,9 +149,6 @@ function NewRfqContent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">New RFQ</h1> <h1 className="text-2xl font-bold text-foreground">New RFQ</h1>
<p className="text-base text-muted-foreground"> <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&apos;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 &amp; 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 &amp; 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 &amp; 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>
)
}
@@ -0,0 +1,333 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation"
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, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"
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,
type RunHeaderData,
type RunStageData,
} from "@/components/production/RunStageNode"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { StageDrawer } from "./StageDrawer"
import { RunActions } from "./RunActions"
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 nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeComponent }
/**
* 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, 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 load = useCallback(() => {
setLoadError(null)
productionRunsApi
.get(runId)
.then(({ data }) => setRun(data))
.catch((err) => setLoadError(errorMessage(err)))
}, [runId])
useEffect(() => {
if (Number.isFinite(runId)) load()
}, [runId, load])
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.
})
}, [])
const { nodes, edges } = useMemo(
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run]
)
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 selectedStage: RunStage | null =
run?.stages.find((s) => s.runStageId === selectedStageId) ?? null
const onNodeClick: NodeMouseHandler = useCallback((_, node) => {
if (node.type !== "runStage") return
setSelectedStageId(Number(node.id))
}, [])
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">{loadError}</p>
<Button variant="outline" onClick={() => router.push("/dashboard/production/runs")}>
<ArrowLeft className="size-4" />
Back to runs
</Button>
</div>
)
}
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
variant="ghost"
className="w-fit px-2 text-muted-foreground hover:text-foreground"
onClick={() => router.push("/dashboard/production/runs")}
>
<ArrowLeft className="size-4" />
Back to runs
</Button>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 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="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(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">
<RotateCcw className="size-3.5" />
Rework #{run.reworkCount}
</Badge>
)}
</div>
<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">Target {run.targetQty.toLocaleString()}</span>
<span className="text-sm text-muted-foreground">
Created {new Date(run.createdAt).toLocaleDateString()}
{run.completedAt && <> · Completed {new Date(run.completedAt).toLocaleDateString()}</>}
</span>
</div>
</div>
<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}% 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">
<div
className="h-full rounded-full transition-all"
style={{ width: `${progressPercent}%`, backgroundColor: STAGE_STATUS_COLOR.Approved }}
/>
</div>
</div>
<RunActions run={run} items={items} reasonCodes={reasonCodes} onActed={load} />
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10">
<StageStatusLegend />
</div>
<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
panOnDrag
zoomOnScroll
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
fitView
proOptions={{ hideAttribution: true }}
>
<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>
)
}
@@ -0,0 +1,448 @@
"use client"
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 { 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"
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"
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 PAGE_SIZE = 25
type StatusFilter = ProductionRunStatus | "All"
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()
// 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 [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 [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)
// 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)
function openStartDialog() {
setStartTemplateId(null)
setTargetQty("")
setStartWarehouseId(null)
setOutputBinId(null)
setFormError("")
setOpen(true)
}
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)
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)
}
}
const hasFilters = query.length > 0 || status !== "All" || templateId !== null || warehouseId !== null
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1>
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Start a run</DialogTitle>
<DialogDescription>Fine-tune per-stage quantities afterward on the run itself.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!formError && !startTemplate}>
<FieldLabel>Template</FieldLabel>
<Select<number> value={startTemplateId} onValueChange={setStartTemplateId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder={startableTemplates.length === 0 ? "No active templates" : "Pick a template"} />
</SelectTrigger>
<SelectContent>
{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</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 && startWarehouseId === null}>
<FieldLabel>Warehouse</FieldLabel>
<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>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.name} · {w.code}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<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>
{startTemplate && targetQtyNum > 0 && (
<div className="rounded-lg border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
Every stage&apos;s planned inputs and outputs are scaled from the template&apos;s per-batch figures against
this target. The authoritative numbers come back with the run and stay editable until each stage starts.
</div>
)}
<FieldError errors={[formError ? { message: formError } : undefined]} />
</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}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleStartRun} disabled={submitting}>
{submitting ? "Starting…" : "Start run"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no…"
className="h-14 w-full pl-11 text-base"
aria-label="Search runs"
/>
</div>
<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>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
<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>
{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"); setPage(1) }}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</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>
{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">
{hasFilters ? "No runs match your search/filter." : "No runs yet."}
</p>
</div>
) : (
<>
<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"
>
<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>
</button>
)
})}
</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>
)
}
@@ -0,0 +1,137 @@
import { memo, useRef } from "react"
import { NodeResizer, type NodeProps } from "@xyflow/react"
import { RotateCw, X } from "lucide-react"
import { AnnotationData } from "./types"
type AnnotationNodeData = AnnotationData & {
onLabelChange?: (label: string) => void
onRotationChange?: (rotation: number) => void
onDelete?: () => void
}
// `className` supplies its own position utility (e.g. "absolute -top-2.5 -right-2.5" or
// "static") — not baked in here, so callers that already sit inside a positioned flex
// row (LineNode's rotate/delete pair) aren't fighting a hardcoded `absolute`.
function DeleteHandle({ onDelete, className }: { onDelete?: () => void; className?: string }) {
return (
<button
type="button"
aria-label="Delete"
title="Delete"
className={`nodrag flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90 ${className ?? ""}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onDelete?.()
}}
>
<X className="size-3" />
</button>
)
}
/**
* Free-floating group/label box. Purely visual — no Handles, so it can never be an edge
* endpoint, and it's excluded from every graph check (see StageNode for the real stage card).
* Rendered behind stage nodes: the page prepends new boxes to the nodes array, and React
* Flow paints later array entries on top.
*/
function BoxNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) {
return (
<div className="relative size-full rounded-xl border-2 border-dashed border-muted-foreground/30 bg-muted/40">
<NodeResizer isVisible={selected} minWidth={160} minHeight={100} lineClassName="!border-primary" handleClassName="!size-2.5 !border-primary !bg-card" />
{selected && data.onDelete && <DeleteHandle onDelete={data.onDelete} className="absolute -top-2.5 -right-2.5" />}
<input
defaultValue={data.label}
disabled={!data.onLabelChange}
onChange={(e) => data.onLabelChange?.(e.target.value)}
placeholder="Group label…"
className="nodrag m-2 w-[calc(100%-1rem)] rounded-md bg-transparent px-1.5 py-1 text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60 focus:bg-card"
/>
</div>
)
}
/**
* Thin resizable divider bar, optionally labeled (e.g. "Phase 1"), and rotatable by dragging
* the small handle that appears above it once selected. The resize outline/handles and the
* rotate handle itself rotate together with the bar — they all live in one rotated wrapper —
* so the selection box always matches the bar's visual angle. Note: NodeResizer computes its
* drag deltas in unrotated screen space, so resizing while significantly rotated will feel a
* little off; acceptable here since this is a lightweight annotation, not precision CAD.
* `wrapperRef` (the outer, unrotated element) is what the rotate math measures from, so the
* center point stays stable regardless of the current angle.
*/
function LineNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) {
const wrapperRef = useRef<HTMLDivElement>(null)
const rotation = data.rotation ?? 0
return (
<div ref={wrapperRef} className="relative size-full">
<div className="relative size-full" style={{ transform: `rotate(${rotation}deg)` }}>
<NodeResizer
isVisible={selected}
minWidth={80}
minHeight={4}
maxHeight={4}
lineClassName="!border-primary"
handleClassName="!size-2.5 !border-primary !bg-card"
/>
{selected && (data.onRotationChange || data.onDelete) && (
<div className="absolute -top-7 left-1/2 flex -translate-x-1/2 items-center gap-1.5">
{data.onRotationChange && (
<button
type="button"
aria-label="Rotate line"
title="Drag to rotate"
className="nodrag flex size-5 cursor-grab items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm active:cursor-grabbing"
onPointerDown={(e) => {
e.stopPropagation()
const handle = e.currentTarget
handle.setPointerCapture(e.pointerId)
const onMove = (ev: PointerEvent) => {
const rect = wrapperRef.current?.getBoundingClientRect()
if (!rect) return
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
// atan2 is 0° pointing right; +90 so "handle straight up" reads as 0° rotation.
const angle = Math.atan2(ev.clientY - cy, ev.clientX - cx) * (180 / Math.PI) + 90
data.onRotationChange?.(Math.round(angle))
}
const onUp = () => {
handle.releasePointerCapture(e.pointerId)
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}}
>
<RotateCw className="size-3" />
</button>
)}
{data.onDelete && <DeleteHandle onDelete={data.onDelete} className="static" />}
</div>
)}
<div className="flex size-full flex-col items-center justify-center">
<div className="h-0.5 w-full rounded-full bg-muted-foreground/40" />
</div>
</div>
<input
defaultValue={data.label}
disabled={!data.onLabelChange}
onChange={(e) => data.onLabelChange?.(e.target.value)}
placeholder="Label (optional)"
className="nodrag absolute -bottom-6 left-1/2 w-24 -translate-x-1/2 rounded-md bg-transparent px-1 text-center text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/50 focus:bg-card"
/>
</div>
)
}
export default memo(BoxNode)
export const LineNodeComponent = memo(LineNode)
@@ -0,0 +1,439 @@
"use client"
import { Plus, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils"
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"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
export interface UpstreamOutputOption {
stageKey: string
stageName: 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({
data,
isTerminal,
upstreamOptions,
items,
uoms,
readOnly,
onChange,
onDelete,
onClose,
}: {
data: StageNodeData
isTerminal: boolean
upstreamOptions: UpstreamOutputOption[]
items: ItemListItem[]
uoms: Uom[]
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
onClose: () => void
}) {
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,
{ localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, uomId: null, qtyPerBatch: 1 },
],
})
}
function removeInput(localId: string) {
onChange({ inputs: data.inputs.filter((i) => i.localId !== localId) })
}
/**
* 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, { key: newKey(), itemId: null, name: "", uomId: null, qtyPerBatch: 1 }],
})
}
function removeOutput(key: string) {
onChange({ outputs: data.outputs.filter((o) => o.key !== key) })
}
/** 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.localId !== localId) return f
const next = { ...f, ...patch }
if (patch.label !== undefined) next.key = slugify(patch.label) || f.key
return next
}),
})
}
function addField() {
onChange({
fieldDefs: [
...data.fieldDefs,
{ localId: newLocalId(), key: "", label: "", type: "Text", options: [], required: false },
],
})
}
function removeField(localId: string) {
onChange({ fieldDefs: data.fieldDefs.filter((f) => f.localId !== localId) })
}
return (
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
<X className="size-5" />
</button>
</div>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
</Field>
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
</Field>
<Field>
<FieldLabel>Estimated minutes</FieldLabel>
<Input
type="number"
min={0}
value={data.estimatedMinutes}
disabled={readOnly}
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
/>
</Field>
{/* Inputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Inputs</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<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.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<Select<StageInputSource>
value={input.source}
onValueChange={(v) => v && changeInputSource(input.localId, v)}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
</SelectContent>
</Select>
{!readOnly && (
<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>
<div className="flex flex-col gap-2">
{input.source === "Stock" ? (
<Select<number>
value={input.itemId}
onValueChange={(v) => v && pickInputItem(input, v)}
>
<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} · {i.sku}
</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>
</div>
{/* Outputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal finished good)</span>}
</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<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.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} 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} · {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={output.name}
disabled={readOnly}
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.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" />
</button>
)}
</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>
</div>
{/* Custom fields */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Custom fields</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addField}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<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.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.localId, { label: e.target.value })}
placeholder="Label"
className="h-8 flex-1 text-sm"
/>
{!readOnly && (
<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<CustomFieldType>
value={field.type}
onValueChange={(v) => v && updateField(field.localId, { type: v })}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Switch
size="sm"
checked={field.required}
disabled={readOnly}
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
/>
<span className="text-xs text-muted-foreground">Required</span>
</div>
</div>
{field.type === "Select" && (
<Input
value={field.options.join(", ")}
disabled={readOnly}
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"
/>
)}
</div>
))}
</div>
</div>
{!readOnly && (
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
<Trash2 className="size-4" />
Delete stage
</Button>
)}
</div>
</div>
)
}
@@ -0,0 +1,68 @@
import { memo } from "react"
import { Handle, Position, type NodeProps } from "@xyflow/react"
import { ArrowRight, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { StageNodeData } from "./types"
/**
* Stage card (docs/21-FRONTEND-PHASE2.md §2): name, role label chip, estimated minutes,
* input count → output count. Selecting it opens the stage editor panel (handled by the
* parent page via onNodeClick, not here) — the same delete affordance also lives there
* ("Delete stage" button); this inline × is a faster path once a stage is already selected.
*/
function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
const disconnected = data.disconnected
return (
<div
className={cn(
"relative w-56 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
// `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"
)}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
{selected && data.onDelete && (
<button
type="button"
aria-label="Delete stage"
title="Delete stage"
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
onClick={(e) => {
e.stopPropagation()
data.onDelete?.()
}}
>
<X className="size-3" />
</button>
)}
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-bold text-foreground">{data.name || "Untitled stage"}</p>
{data.roleLabel && (
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{data.roleLabel}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{data.estimatedMinutes} min</p>
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{data.inputs.length} in</span>
<ArrowRight className="size-3" />
<span>{data.outputs.length} out</span>
</div>
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div>
)
}
export default memo(StageNode)
@@ -0,0 +1,810 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background,
Controls,
MiniMap,
ReactFlow,
type Connection,
type Edge,
type Node,
type NodeChange,
type EdgeChange,
type NodeMouseHandler,
} 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 { 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 } from "./StageEditorPanel"
import { AnnotationData, StageNodeData, newKey, newLocalId } from "./types"
const nodeTypes = { stage: StageNode, box: AnnotationBoxNode, line: LineNodeComponent }
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: 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[] = graph.edges.map((e) => ({
id: `e${e.edgeId}`,
source: e.parentKey,
target: e.childKey,
}))
return { nodes: [...annotations, ...stages], edges }
}
/** 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)
const queue = nodes.filter((n) => inDegree.get(n.id) === 0).map((n) => n.id)
let visited = 0
while (queue.length > 0) {
const id = queue.shift()!
visited++
for (const e of edges.filter((e) => e.source === id)) {
const next = (inDegree.get(e.target) ?? 0) - 1
inDegree.set(e.target, next)
if (next === 0) queue.push(e.target)
}
}
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()
// "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)
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` 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)),
[]
)
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
)
const onConnect = useCallback(
(connection: Connection) => {
if (locked) return
if (connection.source === connection.target) {
toast.error("Can't connect a stage to itself")
return
}
const duplicate = edges.some((e) => e.source === connection.source && e.target === connection.target)
if (duplicate) {
toast.error("These stages are already connected")
return
}
setEdges((eds) => addEdge(connection, eds))
},
[edges, locked]
)
const onNodeClick: NodeMouseHandler = useCallback((_, node) => setSelectedNodeId(node.id), [])
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.
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 = 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
setNodes((nds) => [
...nds,
{
id,
type: "stage",
position: { x: existingStages.length > 0 ? maxX + 280 : 40, y },
data: {
name: "New stage",
roleLabel: "",
estimatedMinutes: 15,
inputs: [],
outputs: [],
fieldDefs: [],
} satisfies StageNodeData,
},
])
setSelectedNodeId(id)
}
// 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))
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId))
setSelectedNodeId((id) => (id === nodeId ? null : id))
}
function addAnnotation(kind: "box" | "line") {
const size = kind === "box" ? DEFAULT_BOX : DEFAULT_LINE
setNodes((nds) => [
{
id: `ann-${newLocalId()}`,
type: kind,
position: kind === "box" ? { x: 40, y: 40 } : { x: 60, y: 300 },
...size,
data: { label: "" } satisfies AnnotationData,
},
...nds,
])
}
// 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.
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 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(() => {
const terminalIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.source === n.id)).map((n) => n.id))
const entryIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.target === n.id)).map((n) => n.id))
const disconnectedIds = new Set(
stageNodes.length > 1
? stageNodes.filter((n) => !edges.some((e) => e.source === n.id || e.target === n.id)).map((n) => n.id)
: []
)
const hasCycle = detectCycle(stageNodes, edges)
return { terminalIds, entryIds, disconnectedIds, hasCycle }
}, [stageNodes, edges])
/** 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 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, fromOutputKey: null } : i)),
})
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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edges])
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 (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 (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) {
const data = node.data as StageNodeData
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, code, name])
const selectedNode = stageNodes.find((n) => n.id === selectedNodeId)
const upstreamOptions: UpstreamOutputOption[] = useMemo(() => {
if (!selectedNode) return []
const parentIds = edges.filter((e) => e.target === selectedNode.id).map((e) => e.source)
return parentIds.flatMap((parentId) => {
const parent = nodes.find((n) => n.id === parentId)
if (!parent) return []
const parentData = parent.data as StageNodeData
return parentData.outputs.map((o) => ({
stageKey: parent.id,
stageName: parentData.name,
outputKey: o.key,
outputName: o.name || "(unnamed output)",
}))
})
}, [selectedNode, edges, nodes])
const displayNodes = useMemo(
() =>
nodes.map((n) =>
n.type === "stage"
? {
...n,
data: {
...n.data,
disconnected: analysis.disconnectedIds.has(n.id),
focused: focusedKeys.has(n.id),
onDelete: locked ? undefined : () => deleteNode(n.id),
},
}
: {
...n,
data: {
...n.data,
onLabelChange: locked ? undefined : (label: string) => updateNodeData(n.id, { label }),
onRotationChange: locked ? undefined : (rotation: number) => updateNodeData(n.id, { rotation }),
onDelete: locked ? undefined : () => deleteNode(n.id),
},
}
),
[nodes, analysis.disconnectedIds, focusedKeys] // eslint-disable-line react-hooks/exhaustive-deps
)
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
}
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">
<div>
<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 flex-wrap items-center gap-2">
{!locked && (
<>
<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" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{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" />
{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 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" />
<span>{issue}</span>
</div>
))}
</div>
)}
<div className="flex min-h-0 flex-1 gap-4">
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{mounted && (
<ReactFlow
nodes={displayNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={locked ? undefined : onNodesChange}
onEdgesChange={locked ? undefined : onEdgesChange}
onNodesDelete={locked ? undefined : onNodesDelete}
onConnect={locked ? undefined : onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodesDraggable={!locked}
nodesConnectable={!locked}
elementsSelectable
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
fitView
>
<Background />
<Controls showInteractive={!locked} />
<MiniMap pannable zoomable />
</ReactFlow>
)}
</div>
{selectedNode && (
<StageEditorPanel
data={selectedNode.data as StageNodeData}
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
onDelete={() => deleteNode(selectedNode.id)}
onClose={() => setSelectedNodeId(null)}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,87 @@
// 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`.
import { CustomFieldType, StageInputSource } from "@/types/production"
/** `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: CustomFieldType
/** Only sent when `type` is `Select`. */
options: string[]
required: boolean
}
export interface StageNodeData extends Record<string, unknown> {
name: string
roleLabel: string
estimatedMinutes: number
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
}
/**
* 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. */
rotation?: number
}
@@ -0,0 +1,313 @@
"use client"
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 { 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,
type LineHeaderData,
type LineStageData,
} from "@/components/production/ProductionLineNodes"
import { Button } 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"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
type StatusFilter = TemplateStatus | "All"
const PAGE_SIZE = 25
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
const ROW_HEIGHT = 150
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: ProductionTemplateSummary[]): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
templates.forEach((t, row) => {
const y = row * ROW_HEIGHT
nodes.push({
id: `h${t.templateId}`,
type: "lineHeader",
position: { x: 0, y },
data: {
templateId: t.templateId,
code: t.code,
name: t.name,
status: t.status,
activeRunCount: t.activeRunCount,
} satisfies LineHeaderData,
draggable: false,
})
// 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,
type: "lineStage",
position: { x: STAGE_START_X + i * STAGE_GAP_X, y: y + 22 },
data: { templateId: t.templateId, name: stageName } satisfies LineStageData,
draggable: false,
})
edges.push({
id: `e-${stageId}`,
source: i === 0 ? `h${t.templateId}` : `s${t.templateId}-${i - 1}`,
target: stageId,
})
})
})
return { nodes, edges }
}
export default function ProductionTemplatesPage() {
const router = useRouter()
const { resolvedTheme } = useTheme()
// 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 [errors, setErrors] = useState<{ code?: string; name?: string }>({})
// 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), [])
// 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 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])
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
router.push(`/dashboard/production/templates/${templateId}`)
}
function openCreateDialog() {
setCode("")
setName("")
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 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)
router.push(
`/dashboard/production/templates/new?code=${encodeURIComponent(code.trim())}&name=${encodeURIComponent(name.trim())}`,
)
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1>
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New template</DialogTitle>
<DialogDescription>
Name it, then build its stage graph. It&apos;s saved once the graph is valid.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<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={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
<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)}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate}>
Open builder
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search by code or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search templates"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
{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">
{hasFilters ? "No templates match your search/filter." : "No templates yet."}
</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>
{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>
)
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, Save } from "lucide-react" import { AlertTriangle, Save } from "lucide-react"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
@@ -183,9 +183,6 @@ export default function ItemDetailPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1> <h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands" import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -156,15 +156,10 @@ export default function BrandsPage() {
return ( return (
<div className="flex flex-col gap-6"> <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 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>
<h1 className="text-2xl font-bold text-foreground">Brands</h1> <h1 className="text-2xl font-bold text-foreground">Brands</h1>
<p className="text-base text-muted-foreground">Manage product brands.</p> <p className="text-base text-muted-foreground">Manage product brands.</p>
</div> </div>
</div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} /> <DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
@@ -239,21 +234,21 @@ export default function BrandsPage() {
{!error && brands !== null && brands.length > 0 && ( {!error && brands !== null && brands.length > 0 && (
<> <>
<Table className="text-base"> <Table className="text-base">
<TableHeader className="bg-indigo-50"> <TableHeader>
<TableRow className="hover:bg-indigo-50"> <TableRow>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} /> <SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /> <SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} /> <SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} /> <SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead> <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -364,7 +359,7 @@ function SortableHeader({
return ( return (
<button <button
type="button" type="button"
className="flex items-center gap-1 hover:text-indigo-900" className="flex items-center gap-1 hover:text-foreground"
onClick={onClick} onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`} aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
> >
@@ -376,7 +371,7 @@ function SortableHeader({
<ArrowDown className="size-3.5" /> <ArrowDown className="size-3.5" />
) )
) : ( ) : (
<ArrowUpDown className="size-3.5 text-indigo-700/40" /> <ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)} )}
</button> </button>
) )
@@ -1,19 +1,17 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react" import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories" import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data" import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, SubCategory } from "@/types/master-data" import { Category, SubCategory } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -113,9 +111,6 @@ export default function CategorySubCategoriesPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1> <h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
@@ -171,13 +166,13 @@ export default function CategorySubCategoriesPage() {
{!error && subCategories !== null && subCategories.length > 0 && ( {!error && subCategories !== null && subCategories.length > 0 && (
<Table className="text-base"> <Table className="text-base">
<TableHeader className="bg-indigo-50"> <TableHeader>
<TableRow className="hover:bg-indigo-50"> <TableRow>
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead> <TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead> <TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead> <TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead> <TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead> <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -155,15 +155,10 @@ export default function CategoriesPage() {
return ( return (
<div className="flex flex-col gap-6"> <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 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>
<h1 className="text-2xl font-bold text-foreground">Categories</h1> <h1 className="text-2xl font-bold text-foreground">Categories</h1>
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p> <p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
</div> </div>
</div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} /> <DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
@@ -238,21 +233,21 @@ export default function CategoriesPage() {
{!error && categories !== null && categories.length > 0 && ( {!error && categories !== null && categories.length > 0 && (
<> <>
<Table className="text-base"> <Table className="text-base">
<TableHeader className="bg-indigo-50"> <TableHeader>
<TableRow className="hover:bg-indigo-50"> <TableRow>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} /> <SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /> <SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} /> <SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700"> <TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} /> <SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead> </TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead> <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -371,7 +366,7 @@ function SortableHeader({
return ( return (
<button <button
type="button" type="button"
className="flex items-center gap-1 hover:text-indigo-900" className="flex items-center gap-1 hover:text-foreground"
onClick={onClick} onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`} aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
> >
@@ -383,7 +378,7 @@ function SortableHeader({
<ArrowDown className="size-3.5" /> <ArrowDown className="size-3.5" />
) )
) : ( ) : (
<ArrowUpDown className="size-3.5 text-indigo-700/40" /> <ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)} )}
</button> </button>
) )
@@ -1,18 +1,16 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { itemTypesApi } from "@/lib/api/item-types" import { itemTypesApi } from "@/lib/api/item-types"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateItemTypeName } from "@/lib/validations/master-data" import { validateItemTypeName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { ItemType } from "@/types/master-data" import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -110,9 +108,6 @@ export default function ItemTypesPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1> <h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
@@ -174,13 +169,13 @@ export default function ItemTypesPage() {
{!error && itemTypes !== null && itemTypes.length > 0 && ( {!error && itemTypes !== null && itemTypes.length > 0 && (
<Table className="text-base"> <Table className="text-base">
<TableHeader className="bg-indigo-50"> <TableHeader>
<TableRow className="hover:bg-indigo-50"> <TableRow>
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead> <TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead> <TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead> <TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead> <TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead> <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, X } from "lucide-react" import { Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
@@ -254,9 +254,6 @@ export default function NewItemPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">New Item</h1> <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> <p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
@@ -532,14 +529,14 @@ export default function NewItemPage() {
{variants.length > 0 && ( {variants.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader className="bg-indigo-50"> <TableHeader>
<TableRow className="hover:bg-indigo-50"> <TableRow>
{activeCategories.map((cat) => ( {activeCategories.map((cat) => (
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead> <TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))} ))}
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead> <TableHead className="h-11 px-3 text-sm">SKU</TableHead>
{priceMode === "fixed" && ( {priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm text-indigo-700">Sale price</TableHead> <TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
)} )}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the {/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN. Item contract and no initial-receipt flow — stock arrives via a GRN.
@@ -1,15 +1,12 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { Package } from "lucide-react"
import { ArrowLeft, Package } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config" import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductConfig } from "@/types/master-data" import { ProductConfig } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -70,9 +67,6 @@ export default function ProductSettingsPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1> <h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
@@ -1,16 +1,14 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { Plus, Ruler } from "lucide-react"
import { ArrowLeft, Plus, Ruler } from "lucide-react"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data" import { validateUomName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Uom } from "@/types/master-data" import { Uom } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -59,9 +57,6 @@ export default function UomsPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1> <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> <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 { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react" import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -118,9 +118,6 @@ export default function GrnDetailPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react" import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders" import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
@@ -303,9 +303,6 @@ export default function NewGrnPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">New GRN</h1> <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> <p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
@@ -446,7 +443,7 @@ export default function NewGrnPage() {
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead> <TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</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-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead> <TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
@@ -454,7 +451,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">Disc %</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</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-28 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Hold status</TableHead> <TableHead className="h-12 w-56 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-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" /> <TableHead className="h-12 w-10 px-3" />
</TableRow> </TableRow>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, Save } from "lucide-react" import { AlertTriangle, Save } from "lucide-react"
import { navApi } from "@/lib/api/nav" import { navApi } from "@/lib/api/nav"
import { rolesApi } from "@/lib/api/roles" import { rolesApi } from "@/lib/api/roles"
@@ -132,9 +132,6 @@ export default function RoleDetailPage() {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1> <h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Save } from "lucide-react" import { Save } from "lucide-react"
import { rolesApi } from "@/lib/api/roles" import { rolesApi } from "@/lib/api/roles"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
@@ -83,9 +83,6 @@ export default function UserDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1> <h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, CheckCircle2, Plus, Trash2 } from "lucide-react" import { CheckCircle2, Plus, Trash2 } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -123,9 +123,6 @@ export default function NewAdjustmentPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">New Adjustment</h1> <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> <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 { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, SlidersHorizontal } from "lucide-react" import { Plus, SlidersHorizontal } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -39,9 +39,6 @@ export default function AdjustmentsListPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1> <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> <p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
@@ -2,15 +2,14 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import { CheckCircle2, ClipboardCheck, Save } from "lucide-react"
import { ArrowLeft, CheckCircle2, ClipboardCheck, Save } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts" import { stockCountsApi } from "@/lib/api/stock-counts"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PostCountResponse, StockCount } from "@/types/stock" import { PostCountResponse, StockCount } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -91,9 +90,6 @@ export default function CountDetailPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{count.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{count.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft } from "lucide-react" import {} from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts" import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -82,9 +82,6 @@ export default function NewCountPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">New Count</h1> <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> <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 { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, ClipboardList, Plus } from "lucide-react" import { ClipboardList, Plus } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts" import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -36,9 +36,6 @@ export default function CountsListPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">Stock Counts</h1> <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> <p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
@@ -2,17 +2,15 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, PackageSearch, Search } from "lucide-react" import { PackageSearch, Search } from "lucide-react"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { OnHand } from "@/types/stock" import { OnHand } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -59,9 +57,6 @@ export default function StockEnquiryPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">Stock Enquiry</h1> <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> <p className="text-base text-muted-foreground">On-hand, available, on-hold, and in-transit by item and warehouse (FR-STK-12).</p>
@@ -1,8 +1,7 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { ArrowLeft, ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
@@ -13,7 +12,7 @@ import { LedgerEntry } from "@/types/stock"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -81,9 +80,6 @@ export default function StockLedgerPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">Stock Ledger</h1> <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> <p className="text-base text-muted-foreground">Immutable, append-only movement journal (FR-STK-01).</p>
@@ -1,18 +1,16 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import { AlertTriangle, CheckCircle2 } from "lucide-react"
import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ReorderAlert } from "@/types/stock" import { ReorderAlert } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -59,9 +57,6 @@ export default function ReorderAlertsPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">Reorder Alerts</h1> <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> <p className="text-base text-muted-foreground">Items at or below their reorder point (FR-STK-10).</p>
@@ -2,15 +2,13 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import { CheckCircle2, PackageCheck, Truck } from "lucide-react"
import { ArrowLeft, CheckCircle2, PackageCheck, Truck } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock" import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -85,9 +83,6 @@ export default function TransferDetailPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{transfer.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{transfer.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -138,9 +138,6 @@ export default function NewTransferPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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>
<h1 className="text-2xl font-bold text-foreground">New Transfer</h1> <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> <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 { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, ArrowLeftRight, Plus } from "lucide-react" import { ArrowLeftRight, Plus } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -36,9 +36,6 @@ export default function TransfersListPage() {
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">Stock Transfers</h1> <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> <p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>

Some files were not shown because too many files have changed in this diff Show More