Compare commits
9 Commits
ui-fixers
...
96f81cb03a
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f81cb03a | |||
| cb3f1e5cde | |||
| c3fef88bcf | |||
| 7d6e597389 | |||
| 415ac94ab2 | |||
| 4561ef7ba8 | |||
| b12adebaa0 | |||
| 0b95d6f1cd | |||
| 8484601494 |
@@ -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.2–D.3).</summary>
|
||||||
|
[Route("api/v1/production-runs")]
|
||||||
|
public sealed class ProductionRunsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IProductionRunService _runs;
|
||||||
|
|
||||||
|
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query,
|
||||||
|
[FromQuery] ProductionRunStatus? status,
|
||||||
|
[FromQuery] int? templateId,
|
||||||
|
[FromQuery] int? warehouseId,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{runId:int}")]
|
||||||
|
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _runs.GetAsync(runId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _runs.CreateAsync(request, ct);
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
|
||||||
|
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
|
||||||
|
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
|
||||||
|
|
||||||
|
// --- stage actions (docs/30 §D.3) ---------------------------------------
|
||||||
|
//
|
||||||
|
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
|
||||||
|
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
|
||||||
|
// the status guards instead: a double-fire finds the stage already moved on and gets a
|
||||||
|
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
|
||||||
|
// deviation from §D.3's "idempotency-key honored".
|
||||||
|
|
||||||
|
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
|
||||||
|
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<StartStageResultDto>> Start(
|
||||||
|
int runId, int runStageId,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
|
||||||
|
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<RunStageDto>> Complete(
|
||||||
|
int runId, int runStageId, [FromBody] CompleteStageRequest request,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
|
||||||
|
|
||||||
|
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
|
||||||
|
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<ApproveStageResultDto>> Approve(
|
||||||
|
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
|
||||||
|
|
||||||
|
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
|
||||||
|
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<TransferResultDto>> Transfer(
|
||||||
|
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
|
||||||
|
|
||||||
|
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
|
||||||
|
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
|
||||||
|
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
|
||||||
|
|
||||||
|
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
|
||||||
|
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
|
||||||
|
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||||
|
|
||||||
|
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
|
||||||
|
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
|
||||||
|
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
|
||||||
|
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||||
|
|
||||||
|
[HttpPost("{runId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<CancelRunResultDto>> Cancel(
|
||||||
|
int runId, [FromBody] CancelRunRequest request,
|
||||||
|
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _runs.CancelAsync(runId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Production;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
||||||
|
[Route("api/v1/production-templates")]
|
||||||
|
public sealed class ProductionTemplatesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IProductionTemplateService _templates;
|
||||||
|
|
||||||
|
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _templates.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{templateId:int}")]
|
||||||
|
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _templates.GetAsync(templateId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<TemplateGraphDto>> Create(
|
||||||
|
[FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _templates.CreateAsync(request, ct);
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{templateId:int}")]
|
||||||
|
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<TemplateGraphDto>> Update(
|
||||||
|
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{templateId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(
|
||||||
|
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _templates.SetStatusAsync(templateId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,4 +14,7 @@ public static class DocumentTypes
|
|||||||
public const string Adjustment = "ADJ";
|
public const string 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 >= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
|
||||||
|
/// </summary>
|
||||||
|
public class RunStageInput
|
||||||
|
{
|
||||||
|
public int RunInputId { get; set; }
|
||||||
|
|
||||||
|
public int RunStageId { get; set; }
|
||||||
|
public RunStage? RunStage { get; set; }
|
||||||
|
|
||||||
|
public StageInputSource Source { get; set; }
|
||||||
|
|
||||||
|
public int? ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
|
||||||
|
public int? FromRunOutputId { get; set; }
|
||||||
|
public RunStageOutput? FromRunOutput { get; set; }
|
||||||
|
|
||||||
|
public int UomId { get; set; }
|
||||||
|
public Uom? Uom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
|
||||||
|
public decimal PlannedQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
|
||||||
|
/// <c>max(0, PlannedQty − ConsumedQty)</c> and adds to these, so a rework restart
|
||||||
|
/// with an unchanged planned quantity consumes nothing and one with a raised planned
|
||||||
|
/// quantity consumes only the delta (FR-MFG-16).
|
||||||
|
/// </summary>
|
||||||
|
public decimal ConsumedQty { get; set; }
|
||||||
|
public decimal ConsumedValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
|
||||||
|
public decimal DeliveredQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
|
||||||
|
public decimal ReturnedQty { get; set; }
|
||||||
|
public decimal ReturnedValue { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
|
||||||
|
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
|
||||||
|
/// Model: docs/30 Part C.
|
||||||
|
/// <para><b>Available to transfer is derived, never stored:</b>
|
||||||
|
/// <c>ProducedQty − ScrappedQty − TransferredQty</c>. Every transfer path checks it and
|
||||||
|
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
|
||||||
|
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
|
||||||
|
/// write-off ledger entry is posted (FR-MFG-11).</para>
|
||||||
|
/// </summary>
|
||||||
|
public class RunStageOutput
|
||||||
|
{
|
||||||
|
public int RunOutputId { get; set; }
|
||||||
|
|
||||||
|
public int RunStageId { get; set; }
|
||||||
|
public RunStage? RunStage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
|
||||||
|
public int? ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int UomId { get; set; }
|
||||||
|
public Uom? Uom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
|
||||||
|
public decimal PlannedQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
|
||||||
|
public decimal ProducedQty { get; set; }
|
||||||
|
|
||||||
|
public decimal ScrappedQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mandatory when <see cref="ScrappedQty"/> > 0, context <c>Production</c> (FR-MFG-11).</summary>
|
||||||
|
public int? ScrapReasonCodeId { get; set; }
|
||||||
|
public ReasonCode? ScrapReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
|
||||||
|
public decimal TransferredQty { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A parent → child arrow on the template canvas. The edge set must form a DAG with
|
||||||
|
/// at least one entry stage and exactly one terminal stage; that is enforced in
|
||||||
|
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
|
||||||
|
/// Model: docs/30 Part C.
|
||||||
|
/// </summary>
|
||||||
|
public class StageEdge
|
||||||
|
{
|
||||||
|
public int EdgeId { get; set; }
|
||||||
|
|
||||||
|
public int TemplateId { get; set; }
|
||||||
|
public ProductionTemplate? Template { get; set; }
|
||||||
|
|
||||||
|
public int ParentStageId { get; set; }
|
||||||
|
public TemplateStage? ParentStage { get; set; }
|
||||||
|
|
||||||
|
public int ChildStageId { get; set; }
|
||||||
|
public TemplateStage? ChildStage { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
|
||||||
|
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
|
||||||
|
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
|
||||||
|
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
|
||||||
|
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
|
||||||
|
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
|
||||||
|
/// internal WIP and never touches stock or the ledger.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Model: docs/30 Part C.
|
||||||
|
/// </summary>
|
||||||
|
public class StageInput
|
||||||
|
{
|
||||||
|
public int InputId { get; set; }
|
||||||
|
|
||||||
|
public int StageId { get; set; }
|
||||||
|
public TemplateStage? Stage { get; set; }
|
||||||
|
|
||||||
|
public StageInputSource Source { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
|
||||||
|
public int? ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
|
||||||
|
public int? FromOutputId { get; set; }
|
||||||
|
public StageOutput? FromOutput { get; set; }
|
||||||
|
|
||||||
|
public int UomId { get; set; }
|
||||||
|
public Uom? Uom { get; set; }
|
||||||
|
|
||||||
|
public decimal QtyPerBatch { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
|
||||||
|
/// <b>internal WIP only</b> — <see cref="ItemId"/> is null, no stock and no ledger row
|
||||||
|
/// is ever written for them. The terminal stage is the exception: it has exactly one
|
||||||
|
/// output and that output <b>must</b> reference a real Item (the finished good), which
|
||||||
|
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
|
||||||
|
/// </summary>
|
||||||
|
public class StageOutput
|
||||||
|
{
|
||||||
|
public int OutputId { get; set; }
|
||||||
|
|
||||||
|
public int StageId { get; set; }
|
||||||
|
public TemplateStage? Stage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
|
||||||
|
public int? ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int UomId { get; set; }
|
||||||
|
public Uom? Uom { get; set; }
|
||||||
|
|
||||||
|
public decimal QtyPerBatch { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
|
||||||
|
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
|
||||||
|
/// custom field definitions. Model: docs/30 Part C.
|
||||||
|
/// </summary>
|
||||||
|
public class TemplateStage
|
||||||
|
{
|
||||||
|
public int StageId { get; set; }
|
||||||
|
|
||||||
|
public int TemplateId { get; set; }
|
||||||
|
public ProductionTemplate? Template { get; set; }
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
|
||||||
|
public string? RoleLabel { get; set; }
|
||||||
|
|
||||||
|
public int EstimatedMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
|
||||||
|
public decimal PosX { get; set; }
|
||||||
|
public decimal PosY { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
|
||||||
|
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
|
||||||
|
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
|
||||||
|
/// hold canonical JSON.
|
||||||
|
/// </summary>
|
||||||
|
public string FieldDefs { get; set; } = "[]";
|
||||||
|
|
||||||
|
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
|
||||||
|
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
|
||||||
|
/// Lives inside the <c>field_defs</c> jsonb rather than a column, but is modelled as an
|
||||||
|
/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
|
||||||
|
/// <see cref="Select"/> is the only type that reads <c>options</c>.
|
||||||
|
/// </summary>
|
||||||
|
public enum CustomFieldType
|
||||||
|
{
|
||||||
|
Text,
|
||||||
|
Number,
|
||||||
|
Checkbox,
|
||||||
|
Date,
|
||||||
|
Select
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
|
||||||
|
/// <see cref="InProgress"/> and reaches exactly one final state: it completes at the
|
||||||
|
/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
|
||||||
|
/// stock return (FR-MFG-17). Stored as a string.
|
||||||
|
/// </summary>
|
||||||
|
public enum ProductionRunStatus
|
||||||
|
{
|
||||||
|
InProgress,
|
||||||
|
Completed,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
|
||||||
|
/// <code>
|
||||||
|
/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
|
||||||
|
/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
|
||||||
|
/// InProgress ──complete (produced/scrap/fields)──▶ Done
|
||||||
|
/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
|
||||||
|
/// </code>
|
||||||
|
/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
|
||||||
|
/// rework transition back into this set and is recorded as a
|
||||||
|
/// <see cref="RunStageEventType"/> instead. Stored as a string.
|
||||||
|
/// </summary>
|
||||||
|
public enum ProductionStageStatus
|
||||||
|
{
|
||||||
|
Waiting,
|
||||||
|
Ready,
|
||||||
|
InProgress,
|
||||||
|
Done,
|
||||||
|
Approved
|
||||||
|
}
|
||||||
@@ -5,5 +5,8 @@ public enum ReasonContext
|
|||||||
{
|
{
|
||||||
Adjustment,
|
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);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Production;
|
||||||
|
|
||||||
|
// Production run contract (docs/30-BACKEND-PHASE2.md §D.2–D.3).
|
||||||
|
//
|
||||||
|
// Statuses are never client-settable (02-SECURITY §B.6): a run's status and every stage
|
||||||
|
// status move only through the stage-action endpoints. Requests here carry quantities and
|
||||||
|
// references, nothing else.
|
||||||
|
|
||||||
|
// --- responses ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).</summary>
|
||||||
|
public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
|
||||||
|
|
||||||
|
/// <summary>Row on the run board (docs/30 §D.2 <c>GET /production-runs</c>).</summary>
|
||||||
|
public sealed record RunSummaryDto(
|
||||||
|
int RunId, string DocNo, int TemplateId, string TemplateName,
|
||||||
|
decimal TargetQty, ProductionRunStatus Status, int ReworkCount,
|
||||||
|
StageSummaryDto StageSummary, int WarehouseId,
|
||||||
|
int? FinishedItemId, string? FinishedItemName,
|
||||||
|
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The run cost pool (FR-MFG-13). Always derived from the stage inputs, never stored —
|
||||||
|
/// surfaced so the UI can preview the finished unit cost before approving the terminal.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
|
||||||
|
|
||||||
|
public sealed record RunStageInputDto(
|
||||||
|
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
|
||||||
|
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
|
||||||
|
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced − scrapped −
|
||||||
|
/// transferred (FR-MFG-12) — and never stored.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RunStageOutputDto(
|
||||||
|
int RunOutputId, int? ItemId, string Name, int UomId,
|
||||||
|
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
|
||||||
|
decimal TransferredQty, decimal AvailableToTransfer);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One stage of a run. <c>IsTerminal</c> (no outbound edge) and <c>IsEntry</c> (no inbound
|
||||||
|
/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
|
||||||
|
/// <c>ActualMinutes</c> is the elapsed whole minutes once the stage has finished, null while
|
||||||
|
/// it is still running (FR-MFG-19). <c>FieldValues</c> is the raw jsonb captured at complete,
|
||||||
|
/// passed through verbatim.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RunStageDto(
|
||||||
|
int RunStageId, int? TemplateStageId, string Name, string? RoleLabel,
|
||||||
|
int EstimatedMinutes, decimal PosX, decimal PosY,
|
||||||
|
ProductionStageStatus Status, bool IsTerminal, bool IsEntry,
|
||||||
|
DateTime? ActualStartAt, DateTime? ActualEndAt, int? ActualMinutes,
|
||||||
|
IReadOnlyList<FieldDefDto> FieldDefs, JsonElement? FieldValues,
|
||||||
|
IReadOnlyList<RunStageInputDto> Inputs, IReadOnlyList<RunStageOutputDto> Outputs);
|
||||||
|
|
||||||
|
public sealed record RunEdgeDto(int RunEdgeId, int ParentRunStageId, int ChildRunStageId);
|
||||||
|
|
||||||
|
public sealed record RunEventDto(
|
||||||
|
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
|
||||||
|
JsonElement? Payload, int UserId, DateTime CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
|
||||||
|
public sealed record RunGraphDto(
|
||||||
|
int RunId, string DocNo, int TemplateId, string TemplateName,
|
||||||
|
int WarehouseId, int? OutputBinId,
|
||||||
|
decimal TargetQty, decimal ScaleFactor,
|
||||||
|
ProductionRunStatus Status, int ReworkCount, int? CancelReasonCodeId,
|
||||||
|
CostPoolDto CostPool,
|
||||||
|
IReadOnlyList<RunStageDto> Stages, IReadOnlyList<RunEdgeDto> Edges, IReadOnlyList<RunEventDto> Events,
|
||||||
|
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
|
||||||
|
|
||||||
|
// --- requests ----------------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed class CreateRunRequest
|
||||||
|
{
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int TemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).</summary>
|
||||||
|
[Range(0.0001, double.MaxValue)]
|
||||||
|
public decimal TargetQty { get; set; }
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional bin for the finished goods; must belong to <see cref="WarehouseId"/>.</summary>
|
||||||
|
public int? OutputBinId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
|
||||||
|
/// (<c>409 STAGE_NOT_EDITABLE</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateStageQuantitiesRequest
|
||||||
|
{
|
||||||
|
public List<StageQuantityLine> Inputs { get; set; } = new();
|
||||||
|
public List<StageQuantityLine> Outputs { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StageQuantityLine
|
||||||
|
{
|
||||||
|
/// <summary>The <c>runInputId</c> or <c>runOutputId</c> being adjusted.</summary>
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue)]
|
||||||
|
public decimal PlannedQty { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Production;
|
||||||
|
|
||||||
|
// Stage-action contract (docs/30-BACKEND-PHASE2.md §D.3). Every action is transactional,
|
||||||
|
// guards on the stage's current status, and returns the refreshed stage plus whatever
|
||||||
|
// stock effects it caused.
|
||||||
|
|
||||||
|
// --- start -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>One FIFO layer a stage start drew from, at that layer's cost.</summary>
|
||||||
|
public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
|
||||||
|
|
||||||
|
/// <summary>What a single Stock input consumed at start (FR-MFG-10).</summary>
|
||||||
|
public sealed record ConsumedInputDto(
|
||||||
|
int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList<ConsumedLayerDto> ConsumedLayers);
|
||||||
|
|
||||||
|
public sealed record StartStageResultDto(
|
||||||
|
int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
|
||||||
|
IReadOnlyList<ConsumedInputDto> Consumed, IReadOnlyList<int> LedgerRefs,
|
||||||
|
RunStageDto Stage);
|
||||||
|
|
||||||
|
// --- complete ----------------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed class CompleteStageRequest
|
||||||
|
{
|
||||||
|
[Required, MinLength(1)]
|
||||||
|
public List<CompleteOutputLine> Outputs { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Values for the stage's custom fields, keyed by <c>fieldDefs[].key</c>. Every field
|
||||||
|
/// marked required must be present and non-empty (<c>400 REQUIRED_FIELD_MISSING</c>).
|
||||||
|
/// </summary>
|
||||||
|
public JsonElement? FieldValues { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CompleteOutputLine
|
||||||
|
{
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int RunOutputId { get; set; }
|
||||||
|
|
||||||
|
[Range(0, double.MaxValue)]
|
||||||
|
public decimal ProducedQty { get; set; }
|
||||||
|
|
||||||
|
[Range(0, double.MaxValue)]
|
||||||
|
public decimal ScrappedQty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mandatory once <see cref="ScrappedQty"/> > 0; must be a Production reason.</summary>
|
||||||
|
public int? ScrapReasonCodeId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- approve / transfer ------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One WIP hand-off from a parent output to a child input. Deliveries route by
|
||||||
|
/// <c>fromRunOutputId</c>, not by edge — see the note on <c>RunEdge</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TransferDto(
|
||||||
|
int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
|
||||||
|
decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
|
||||||
|
|
||||||
|
/// <summary>The finished-goods layer a terminal approve created (FR-MFG-13).</summary>
|
||||||
|
public sealed record ReceiptDto(
|
||||||
|
int LayerId, int ItemId, int WarehouseId, int? BinId,
|
||||||
|
decimal QtyReceived, decimal UnitCost, decimal Value);
|
||||||
|
|
||||||
|
public sealed record ApproveStageResultDto(
|
||||||
|
int RunStageId, ProductionStageStatus Status, ProductionRunStatus RunStatus,
|
||||||
|
IReadOnlyList<TransferDto> Transfers,
|
||||||
|
ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList<int> LedgerRefs,
|
||||||
|
RunStageDto Stage);
|
||||||
|
|
||||||
|
public sealed class ApproveStageRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Optional partial transfers. Omitted or empty means transfer the full available
|
||||||
|
/// quantity of every output (FR-MFG-12).
|
||||||
|
/// </summary>
|
||||||
|
public List<TransferLine> Transfers { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TransferLine
|
||||||
|
{
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int RunOutputId { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue)]
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional explicit target. When an output feeds several child inputs the server
|
||||||
|
/// otherwise fills them in <c>runInputId</c> order; naming one removes the ambiguity.
|
||||||
|
/// </summary>
|
||||||
|
public int? RunInputId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TransferRemainderRequest
|
||||||
|
{
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int RunOutputId { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue)]
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
|
||||||
|
public int? RunInputId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TransferResultDto(
|
||||||
|
int RunStageId, IReadOnlyList<TransferDto> Transfers, RunStageDto Stage);
|
||||||
|
|
||||||
|
// --- leftover return ---------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed class ReturnLeftoverRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Quantity to return, in the item's <b>base</b> UOM — the same unit
|
||||||
|
/// <c>consumedQty</c>/<c>returnedQty</c> are stored in, since the return posts straight
|
||||||
|
/// to stock. Cannot exceed consumed − already returned.
|
||||||
|
/// </summary>
|
||||||
|
[Range(0.0001, double.MaxValue)]
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mandatory, and must be a Production-context reason (FR-MFG-14).</summary>
|
||||||
|
public int? ReasonCodeId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProductionCreatedLayerDto(int LayerId, decimal UnitCost);
|
||||||
|
|
||||||
|
public sealed record ReturnLeftoverResultDto(
|
||||||
|
int RunInputId, decimal ReturnedQty, decimal ReturnedValue,
|
||||||
|
ProductionCreatedLayerDto CreatedLayer, IReadOnlyList<int> LedgerRefs, CostPoolDto CostPool);
|
||||||
|
|
||||||
|
// --- rejection / rework ------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed class RejectRequest
|
||||||
|
{
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).</summary>
|
||||||
|
public sealed record PulledBackDto(
|
||||||
|
int RunInputId, int ParentRunStageId, int ParentRunOutputId,
|
||||||
|
decimal Qty, ProductionStageStatus PriorParentStatus, ProductionStageStatus ParentStatus);
|
||||||
|
|
||||||
|
public sealed record RejectIntakeResultDto(
|
||||||
|
int RunStageId, ProductionStageStatus Status,
|
||||||
|
IReadOnlyList<PulledBackDto> PulledBack, RunGraphDto Run);
|
||||||
|
|
||||||
|
public sealed record TerminalRejectResultDto(
|
||||||
|
int RunStageId, int ReworkCount, RunGraphDto Run);
|
||||||
|
|
||||||
|
// --- cancel ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed class CancelRunRequest
|
||||||
|
{
|
||||||
|
public int? ReasonCodeId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CancelReturnDto(int ItemId, decimal Qty, decimal UnitCost, int LayerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrapped output quantities written off by a cancel. Recorded on the event only — scrap
|
||||||
|
/// sits on outputs, which never entered stock, so there is nothing to return (FR-MFG-17).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
|
||||||
|
|
||||||
|
public sealed record CancelRunResultDto(
|
||||||
|
int RunId, ProductionRunStatus Status,
|
||||||
|
IReadOnlyList<CancelReturnDto> Returns,
|
||||||
|
IReadOnlyList<ScrapWriteOffDto> ScrappedWrittenOff,
|
||||||
|
IReadOnlyList<int> LedgerRefs);
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Production;
|
||||||
|
|
||||||
|
// Production template contract (docs/30-BACKEND-PHASE2.md §D.1).
|
||||||
|
//
|
||||||
|
// The `Key` vocabulary: every stage and every output carries a client-facing string key
|
||||||
|
// alongside its database id. On a GET the key IS the stringified id; on a save the client
|
||||||
|
// echoes those keys back for rows it kept and mints "tmp-<uuid>" keys for rows it just
|
||||||
|
// drew. Edges and Upstream inputs then reference stages/outputs *by key* only, which is
|
||||||
|
// what lets one payload shape — and one validator — serve both POST (nothing has an id
|
||||||
|
// yet) and PUT (most things do).
|
||||||
|
|
||||||
|
// --- responses ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Row on the template list (docs/30 §D.1 <c>GET /production-templates</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>StageNames</c> is an addition to the documented shape. The template overview is a
|
||||||
|
/// canvas showing every template as a production line with its stages left-to-right
|
||||||
|
/// (docs/21 §1), so it needs the names for every listed row — without them the client would
|
||||||
|
/// have to fetch each template's full graph just to label the boxes. Ordered by stage id,
|
||||||
|
/// matching the graph endpoint.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record TemplateSummaryDto(
|
||||||
|
int TemplateId, string Code, string Name, EntityStatus Status,
|
||||||
|
int StageCount, IReadOnlyList<string> StageNames,
|
||||||
|
int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>One custom field definition, serialized verbatim into the stage's <c>field_defs</c> jsonb.</summary>
|
||||||
|
public sealed record FieldDefDto(
|
||||||
|
string Key, string Label, CustomFieldType Type, IReadOnlyList<string>? Options, bool Required);
|
||||||
|
|
||||||
|
public sealed record StageInputDto(
|
||||||
|
int InputId, StageInputSource Source, int? ItemId,
|
||||||
|
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
|
||||||
|
|
||||||
|
public sealed record StageOutputDto(
|
||||||
|
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
|
||||||
|
|
||||||
|
public sealed record TemplateStageDto(
|
||||||
|
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
|
||||||
|
decimal PosX, decimal PosY, IReadOnlyList<FieldDefDto> FieldDefs,
|
||||||
|
IReadOnlyList<StageInputDto> Inputs, IReadOnlyList<StageOutputDto> Outputs);
|
||||||
|
|
||||||
|
public sealed record TemplateEdgeDto(
|
||||||
|
int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
|
||||||
|
/// meaning whatsoever, and invisible to the graph validator.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CanvasAnnotationDto(
|
||||||
|
string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
|
||||||
|
string? Label, decimal? Rotation);
|
||||||
|
|
||||||
|
/// <summary>Full graph (docs/30 §D.1 <c>GET /production-templates/{id}</c>).</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>ActiveRunCount</c> and <c>Annotations</c> are additions to the documented shape.
|
||||||
|
/// The first is what puts the builder into its edit-locked state (docs/21 §2) and mirrors
|
||||||
|
/// the condition <c>UpdateAsync</c> enforces — without it the builder would need a second
|
||||||
|
/// request to the list endpoint just to know whether to disable itself.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record TemplateGraphDto(
|
||||||
|
int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
|
||||||
|
IReadOnlyList<TemplateStageDto> Stages, IReadOnlyList<TemplateEdgeDto> Edges,
|
||||||
|
IReadOnlyList<CanvasAnnotationDto> Annotations, int ActiveRunCount,
|
||||||
|
int CreatedBy, DateTime CreatedAt);
|
||||||
|
|
||||||
|
// --- requests ----------------------------------------------------------------
|
||||||
|
// Narrow by design (02-SECURITY §B.6): no status, no ids, no createdBy, no timestamps.
|
||||||
|
// POST and PUT share this shape; PUT replaces the whole graph.
|
||||||
|
|
||||||
|
public sealed class SaveTemplateRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(30, MinimumLength = 1)]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, StringLength(150, MinimumLength = 1)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[Required, MinLength(1)]
|
||||||
|
public List<SaveStageRequest> Stages { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Empty is legal — a single-stage template is both entry and terminal.</summary>
|
||||||
|
public List<SaveEdgeRequest> Edges { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canvas boxes/lines. Capped because this is free-form client state going into a jsonb
|
||||||
|
/// column — a hand-rolled request should not be able to store an unbounded blob.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(200)]
|
||||||
|
public List<CanvasAnnotationDto> Annotations { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SaveStageRequest
|
||||||
|
{
|
||||||
|
/// <summary>Existing stage id as a string, or a client-minted <c>tmp-*</c> key for a new stage.</summary>
|
||||||
|
[Required, StringLength(60, MinimumLength = 1)]
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, StringLength(150, MinimumLength = 1)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(60)]
|
||||||
|
public string? RoleLabel { get; set; }
|
||||||
|
|
||||||
|
[Range(0, 1_000_000)]
|
||||||
|
public int EstimatedMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Canvas coordinates, stored verbatim and never interpreted server-side.</summary>
|
||||||
|
public decimal PosX { get; set; }
|
||||||
|
public decimal PosY { get; set; }
|
||||||
|
|
||||||
|
public List<FieldDefDto> FieldDefs { get; set; } = new();
|
||||||
|
public List<SaveInputRequest> Inputs { get; set; } = new();
|
||||||
|
public List<SaveOutputRequest> Outputs { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SaveInputRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public StageInputSource Source { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Required when <see cref="Source"/> is Stock; must be null when Upstream.</summary>
|
||||||
|
public int? ItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Required when <see cref="Source"/> is Upstream; must name an output of a direct parent.</summary>
|
||||||
|
[StringLength(60)]
|
||||||
|
public string? FromOutputKey { get; set; }
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int UomId { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue)]
|
||||||
|
public decimal QtyPerBatch { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SaveOutputRequest
|
||||||
|
{
|
||||||
|
/// <summary>Existing output id as a string, or a <c>tmp-*</c> key. Referenced by Upstream inputs.</summary>
|
||||||
|
[Required, StringLength(60, MinimumLength = 1)]
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Required on the terminal stage's single output; must be null on every other output.</summary>
|
||||||
|
public int? ItemId { get; set; }
|
||||||
|
|
||||||
|
[Required, StringLength(150, MinimumLength = 1)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int UomId { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue)]
|
||||||
|
public decimal QtyPerBatch { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SaveEdgeRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(60, MinimumLength = 1)]
|
||||||
|
public string ParentKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, StringLength(60, MinimumLength = 1)]
|
||||||
|
public string ChildKey { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Body of <c>PATCH /production-templates/{id}/status</c> (docs/30 §D.1).</summary>
|
||||||
|
public sealed class UpdateTemplateStatusRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public EntityStatus Status { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
// Manufacturing / Production Lines (docs/30 Part C). All eleven tables live in this one
|
||||||
|
// file, following the StockConfiguration.cs precedent of grouping an aggregate's
|
||||||
|
// configurations together.
|
||||||
|
//
|
||||||
|
// Cascade shape (deliberate): the two aggregate roots cascade to their own children —
|
||||||
|
// template → stages/edges → inputs/outputs, run → stages/edges/events → inputs/outputs.
|
||||||
|
// Every *cross* reference is Restrict, because making them cascade would give EF two
|
||||||
|
// delete paths to the same table and the model validator rejects that. The consequence
|
||||||
|
// is an ordering rule for the service layer: when replacing a graph, delete edges before
|
||||||
|
// stages and inputs before outputs.
|
||||||
|
|
||||||
|
public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration<ProductionTemplate>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProductionTemplate> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("production_templates");
|
||||||
|
builder.HasKey(t => t.TemplateId);
|
||||||
|
|
||||||
|
builder.Property(t => t.Code).IsRequired().HasMaxLength(30);
|
||||||
|
builder.HasIndex(t => t.Code).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(t => t.Name).IsRequired().HasMaxLength(150);
|
||||||
|
builder.Property(t => t.Description).HasMaxLength(500);
|
||||||
|
|
||||||
|
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
|
||||||
|
// Same jsonb-as-string mapping as TemplateStage.FieldDefs and AuditLog.ChangeSet: a
|
||||||
|
// typed/owned mapping would make AuditScribe emit audit rows for the nested entries.
|
||||||
|
builder.Property(t => t.Annotations).HasColumnType("jsonb");
|
||||||
|
|
||||||
|
builder.Property(t => t.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
|
builder.Property(t => t.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasIndex(t => t.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TemplateStageConfiguration : IEntityTypeConfiguration<TemplateStage>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TemplateStage> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("template_stages");
|
||||||
|
builder.HasKey(s => s.StageId);
|
||||||
|
|
||||||
|
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
|
||||||
|
builder.Property(s => s.RoleLabel).HasMaxLength(60);
|
||||||
|
builder.Property(s => s.PosX).HasPrecision(18, 4);
|
||||||
|
builder.Property(s => s.PosY).HasPrecision(18, 4);
|
||||||
|
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
|
||||||
|
|
||||||
|
builder.HasOne(s => s.Template).WithMany(t => t.Stages)
|
||||||
|
.HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StageEdgeConfiguration : IEntityTypeConfiguration<StageEdge>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<StageEdge> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("stage_edges");
|
||||||
|
builder.HasKey(e => e.EdgeId);
|
||||||
|
|
||||||
|
builder.HasOne(e => e.Template).WithMany(t => t.Edges)
|
||||||
|
.HasForeignKey(e => e.TemplateId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(e => e.ParentStage).WithMany()
|
||||||
|
.HasForeignKey(e => e.ParentStageId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(e => e.ChildStage).WithMany()
|
||||||
|
.HasForeignKey(e => e.ChildStageId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// One arrow per ordered pair; self-loops and cycles are rejected by the validator.
|
||||||
|
builder.HasIndex(e => new { e.ParentStageId, e.ChildStageId }).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInput>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<StageInput> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("stage_inputs");
|
||||||
|
builder.HasKey(i => i.InputId);
|
||||||
|
|
||||||
|
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
|
||||||
|
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(i => i.FromOutput).WithMany()
|
||||||
|
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StageOutputConfiguration : IEntityTypeConfiguration<StageOutput>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<StageOutput> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("stage_outputs");
|
||||||
|
builder.HasKey(o => o.OutputId);
|
||||||
|
|
||||||
|
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
|
||||||
|
builder.Property(o => o.QtyPerBatch).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(o => o.Stage).WithMany(s => s.Outputs)
|
||||||
|
.HasForeignKey(o => o.StageId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProductionRunConfiguration : IEntityTypeConfiguration<ProductionRun>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProductionRun> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("production_runs");
|
||||||
|
builder.HasKey(r => r.RunId);
|
||||||
|
|
||||||
|
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
|
||||||
|
builder.HasIndex(r => r.DocNo).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(r => r.TargetQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(r => r.ScaleFactor).HasPrecision(18, 6);
|
||||||
|
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(r => r.CreatedAt).IsRequired();
|
||||||
|
builder.Property(r => r.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(r => r.Template).WithMany(t => t.Runs)
|
||||||
|
.HasForeignKey(r => r.TemplateId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(r => r.OutputBin).WithMany().HasForeignKey(r => r.OutputBinId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(r => r.CancelReason).WithMany().HasForeignKey(r => r.CancelReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Run board filters (docs/30 §D.2) and the template edit-lock count (FR-MFG-06).
|
||||||
|
builder.HasIndex(r => r.Status);
|
||||||
|
builder.HasIndex(r => new { r.TemplateId, r.Status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunStageConfiguration : IEntityTypeConfiguration<RunStage>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RunStage> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("run_stages");
|
||||||
|
builder.HasKey(s => s.RunStageId);
|
||||||
|
|
||||||
|
builder.Property(s => s.Name).IsRequired().HasMaxLength(150);
|
||||||
|
builder.Property(s => s.RoleLabel).HasMaxLength(60);
|
||||||
|
builder.Property(s => s.PosX).HasPrecision(18, 4);
|
||||||
|
builder.Property(s => s.PosY).HasPrecision(18, 4);
|
||||||
|
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
|
||||||
|
builder.Property(s => s.FieldValues).HasColumnType("jsonb");
|
||||||
|
builder.Property(s => s.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(s => s.Run).WithMany(r => r.Stages)
|
||||||
|
.HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// SetNull, not Restrict: a template edit may delete a stage that completed or
|
||||||
|
// cancelled runs still point at. Everything needed to render such a run is copied
|
||||||
|
// onto this row, so losing the provenance link is the intended trade (FR-MFG-06).
|
||||||
|
builder.HasOne(s => s.TemplateStage).WithMany()
|
||||||
|
.HasForeignKey(s => s.TemplateStageId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
builder.HasIndex(s => new { s.RunId, s.Status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunEdgeConfiguration : IEntityTypeConfiguration<RunEdge>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RunEdge> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("run_edges");
|
||||||
|
builder.HasKey(e => e.RunEdgeId);
|
||||||
|
|
||||||
|
builder.HasOne(e => e.Run).WithMany(r => r.Edges)
|
||||||
|
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(e => e.ParentRunStage).WithMany()
|
||||||
|
.HasForeignKey(e => e.ParentRunStageId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(e => e.ChildRunStage).WithMany()
|
||||||
|
.HasForeignKey(e => e.ChildRunStageId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasIndex(e => new { e.ParentRunStageId, e.ChildRunStageId }).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunStageInput>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RunStageInput> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("run_stage_inputs");
|
||||||
|
builder.HasKey(i => i.RunInputId);
|
||||||
|
|
||||||
|
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
|
||||||
|
builder.Property(i => i.DeliveredQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(i => i.ReturnedQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(i => i.ReturnedValue).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(i => i.RunStage).WithMany(s => s.Inputs)
|
||||||
|
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(i => i.FromRunOutput).WithMany()
|
||||||
|
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Transfers route by the source output, so this is the hot lookup.
|
||||||
|
builder.HasIndex(i => i.FromRunOutputId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunStageOutputConfiguration : IEntityTypeConfiguration<RunStageOutput>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RunStageOutput> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("run_stage_outputs");
|
||||||
|
builder.HasKey(o => o.RunOutputId);
|
||||||
|
|
||||||
|
builder.Property(o => o.Name).IsRequired().HasMaxLength(150);
|
||||||
|
builder.Property(o => o.PlannedQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(o => o.ProducedQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(o => o.ScrappedQty).HasPrecision(18, 4);
|
||||||
|
builder.Property(o => o.TransferredQty).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(o => o.RunStage).WithMany(s => s.Outputs)
|
||||||
|
.HasForeignKey(o => o.RunStageId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(o => o.ScrapReason).WithMany().HasForeignKey(o => o.ScrapReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunStageEventConfiguration : IEntityTypeConfiguration<RunStageEvent>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RunStageEvent> builder)
|
||||||
|
{
|
||||||
|
// Append-only history: the app never updates or deletes these rows.
|
||||||
|
builder.ToTable("run_stage_events");
|
||||||
|
builder.HasKey(e => e.EventId);
|
||||||
|
|
||||||
|
builder.Property(e => e.EventType).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(e => e.Note).HasMaxLength(500);
|
||||||
|
builder.Property(e => e.Payload).HasColumnType("jsonb");
|
||||||
|
builder.Property(e => e.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(e => e.Run).WithMany(r => r.Events)
|
||||||
|
.HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// SetNull rather than Cascade: the run-level cascade above already removes these
|
||||||
|
// rows, and a second cascade path (run → stage → event) is what EF rejects.
|
||||||
|
builder.HasOne(e => e.RunStage).WithMany(s => s.Events)
|
||||||
|
.HasForeignKey(e => e.RunStageId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// The run-detail timeline reads the whole run's history in one ordered pass.
|
||||||
|
builder.HasIndex(e => new { e.RunId, e.EventId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ public static class DataSeeder
|
|||||||
("WRONG", "Wrong Item", ReasonContext.Return),
|
("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");
|
||||||
|
|||||||
@@ -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 §4–5)
|
// Stock core + goods receipt (docs/11 §4–5)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.2–D.3, FR-MFG-08..19). Every stock-affecting
|
||||||
|
/// action runs inside a single <c>ExecuteInTransactionAsync</c> scope and consumes stock
|
||||||
|
/// only through <c>IFifoCostingService</c> (NFR-02/NFR-05).
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductionRunService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<RunSummaryDto>> ListAsync(
|
||||||
|
PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
|
||||||
|
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
|
||||||
|
/// <c>PRD-…</c> document number, and leaves entry stages Ready with the rest Waiting.
|
||||||
|
/// </summary>
|
||||||
|
Task<ETagged<RunGraphDto>> CreateAsync(CreateRunRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-run quantity override on a stage that has not started
|
||||||
|
/// (<c>409 STAGE_NOT_EDITABLE</c> once it has). Re-evaluates the stage's readiness,
|
||||||
|
/// since raising an upstream input's planned quantity can un-ready it.
|
||||||
|
/// </summary>
|
||||||
|
Task<RunStageDto> UpdateStageQuantitiesAsync(
|
||||||
|
int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>Ready → InProgress</c> (FR-MFG-10). FIFO-consumes every Stock input from the run
|
||||||
|
/// warehouse in one transaction and stamps <c>actualStartAt</c>.
|
||||||
|
/// <para>Consumes <c>max(0, plannedBase − consumedQty)</c> per input, so a rework
|
||||||
|
/// restart with an unchanged planned quantity consumes nothing and one with a raised
|
||||||
|
/// planned quantity consumes only the delta (FR-MFG-16).</para>
|
||||||
|
/// </summary>
|
||||||
|
Task<StartStageResultDto> StartStageAsync(int runId, int runStageId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>InProgress → Done</c> (FR-MFG-11). Records produced and scrapped quantities per
|
||||||
|
/// output plus the custom field values. A re-complete after a rework <b>overwrites</b>
|
||||||
|
/// the previous figures rather than adding to them.
|
||||||
|
/// </summary>
|
||||||
|
Task<RunStageDto> CompleteStageAsync(
|
||||||
|
int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>Done → Approved</c> (FR-MFG-12/13). Non-terminal: hands WIP to the children,
|
||||||
|
/// defaulting to the full available quantity. Terminal: posts the production receipt —
|
||||||
|
/// a finished-goods layer costed at <c>costPool / goodQty</c> — and completes the run.
|
||||||
|
/// </summary>
|
||||||
|
Task<ApproveStageResultDto> ApproveStageAsync(
|
||||||
|
int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Later partial transfer of a remainder held on an already-Approved stage (FR-MFG-12),
|
||||||
|
/// never exceeding produced − scrapped − already transferred.
|
||||||
|
/// </summary>
|
||||||
|
Task<TransferResultDto> TransferAsync(
|
||||||
|
int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns unconsumed material to stock before the receipt closes the pool (FR-MFG-14).
|
||||||
|
/// The inbound layer is created at the <b>weighted cost actually consumed</b> for that
|
||||||
|
/// input, so the move is cost-preserving and the pool reduces by exactly what leaves it.
|
||||||
|
/// <c>409 RUN_COST_CLOSED</c> once the run has completed.
|
||||||
|
/// </summary>
|
||||||
|
Task<ReturnLeftoverResultDto> ReturnLeftoverAsync(
|
||||||
|
int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downstream reject (FR-MFG-15): this stage rejects the work it received, its delivering
|
||||||
|
/// parents revert <c>Approved → InProgress</c> with their transferred quantities pulled
|
||||||
|
/// back, and this stage returns to Waiting. Consumed stock stays consumed.
|
||||||
|
/// </summary>
|
||||||
|
Task<RejectIntakeResultDto> RejectIntakeAsync(
|
||||||
|
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminal reject (FR-MFG-16): resets the whole run to its starting stages, increments
|
||||||
|
/// <c>reworkCount</c> and snapshots the discarded figures into the event history.
|
||||||
|
/// Already-consumed material remains in the cost pool.
|
||||||
|
/// </summary>
|
||||||
|
Task<TerminalRejectResultDto> RejectTerminalAsync(
|
||||||
|
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels an in-progress run (FR-MFG-17). Net consumed-and-not-returned stock goes back
|
||||||
|
/// at its consumed weighted cost; scrapped output quantities are written off on the event.
|
||||||
|
/// <c>409 RUN_NOT_CANCELLABLE</c> for a completed run.
|
||||||
|
/// </summary>
|
||||||
|
Task<CancelRunResultDto> CancelAsync(int runId, CancelRunRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Production;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production template CRUD and graph validation (docs/30 §D.1, FR-MFG-01..07).
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductionTemplateService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<TemplateSummaryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the whole graph. Requires the current row version (<c>If-Match</c>) and is
|
||||||
|
/// refused with <c>409 TEMPLATE_IN_USE</c> while any run of this template is
|
||||||
|
/// InProgress — edit-lock stands in for versioning (FR-MFG-06).
|
||||||
|
/// </summary>
|
||||||
|
Task<ETagged<TemplateGraphDto>> UpdateAsync(
|
||||||
|
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Activate/deactivate (FR-MFG-01). Deliberately <b>not</b> edit-locked: deactivating is
|
||||||
|
/// the "never delete a referenced master" path (FR-MD-08) and only stops <i>new</i> runs
|
||||||
|
/// being started, so it must stay available while runs are in flight.
|
||||||
|
/// </summary>
|
||||||
|
Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
|
||||||
|
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
|
||||||
|
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
|
||||||
|
/// one place that bridges the two.</para>
|
||||||
|
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
|
||||||
|
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
|
||||||
|
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
|
||||||
|
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
|
||||||
|
/// mis-cost the run).</para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IUomConverter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
|
||||||
|
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
|
||||||
|
/// conversion is defined for the item from that UOM to its base.
|
||||||
|
/// </summary>
|
||||||
|
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||||
|
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
|
||||||
|
/// production stage input declares a quantity; its cost comes from the FIFO layers it
|
||||||
|
/// consumes, not from the document).
|
||||||
|
/// </summary>
|
||||||
|
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Production;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a template stage graph (FR-MFG-02, FR-MFG-04, FR-MFG-05) and throws the
|
||||||
|
/// matching <c>422 GRAPH_*</c> domain error on the first violation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>This is an <b>algorithm, not a service</b> — pure, synchronous, no database and
|
||||||
|
/// no DI, in the spirit of <c>FifoCostingService</c> being the one place FIFO lives. It
|
||||||
|
/// deliberately has no interface: <c>Services/Interfaces/</c> exists for things that get
|
||||||
|
/// injected, and registering this would buy nothing.</para>
|
||||||
|
/// <para>It works entirely in <b>keys</b>, never database ids, so the identical code path
|
||||||
|
/// runs for a POST (where nothing has an id yet) and a PUT (where most things do). All
|
||||||
|
/// database-dependent checks — does this item exist, is it Active, does this UOM exist —
|
||||||
|
/// stay in <c>ProductionTemplateService</c> so this stays free of I/O.</para>
|
||||||
|
/// <para>Checks run cheapest-first and fail fast, and every message names the offending
|
||||||
|
/// stage or edge so the canvas can focus it (docs/21 §2).</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ProductionGraphValidator
|
||||||
|
{
|
||||||
|
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
|
||||||
|
|
||||||
|
public sealed record OutputDraft(string Key, string Name, int? ItemId);
|
||||||
|
|
||||||
|
public sealed record StageDraft(
|
||||||
|
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
|
||||||
|
|
||||||
|
public readonly record struct EdgeDraft(string ParentKey, string ChildKey);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throws on the first rule violation; returns silently for a valid graph.
|
||||||
|
/// </summary>
|
||||||
|
public static void Validate(IReadOnlyList<StageDraft> stages, IReadOnlyList<EdgeDraft> edges)
|
||||||
|
{
|
||||||
|
// 1 — structural hygiene. These are all blocked in the canvas at draw time
|
||||||
|
// (docs/21 §2); this is the server backstop for a hand-rolled request.
|
||||||
|
if (stages.Count == 0)
|
||||||
|
throw Invalid("A template must have at least one stage.");
|
||||||
|
|
||||||
|
var stageByKey = new Dictionary<string, StageDraft>(StringComparer.Ordinal);
|
||||||
|
foreach (var s in stages)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s.Key))
|
||||||
|
throw Invalid("Every stage requires a key.");
|
||||||
|
if (!stageByKey.TryAdd(s.Key, s))
|
||||||
|
throw Invalid($"Duplicate stage key '{s.Key}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output keys are unique across the whole template, not just within a stage —
|
||||||
|
// an Upstream input names one by key alone, so a collision would be ambiguous.
|
||||||
|
var outputOwner = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var s in stages)
|
||||||
|
foreach (var o in s.Outputs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(o.Key))
|
||||||
|
throw Invalid($"Every output of stage '{s.Name}' requires a key.");
|
||||||
|
if (!outputOwner.TryAdd(o.Key, s.Key))
|
||||||
|
throw Invalid($"Duplicate output key '{o.Key}' (stage '{s.Name}').");
|
||||||
|
}
|
||||||
|
|
||||||
|
var edgeSet = new HashSet<(string, string)>();
|
||||||
|
foreach (var e in edges)
|
||||||
|
{
|
||||||
|
if (!stageByKey.ContainsKey(e.ParentKey) || !stageByKey.ContainsKey(e.ChildKey))
|
||||||
|
throw Invalid($"Edge '{e.ParentKey}' → '{e.ChildKey}' references a stage that is not in the payload.");
|
||||||
|
if (string.Equals(e.ParentKey, e.ChildKey, StringComparison.Ordinal))
|
||||||
|
throw Invalid($"Stage '{stageByKey[e.ParentKey].Name}' cannot connect to itself.");
|
||||||
|
if (!edgeSet.Add((e.ParentKey, e.ChildKey)))
|
||||||
|
throw Invalid($"Duplicate edge '{stageByKey[e.ParentKey].Name}' → '{stageByKey[e.ChildKey].Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var children = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
|
||||||
|
var parents = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
|
||||||
|
foreach (var (parent, child) in edgeSet)
|
||||||
|
{
|
||||||
|
children[parent].Add(child);
|
||||||
|
parents[child].Add(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 — cycle detection by Kahn's algorithm. If the toposort can't reach every
|
||||||
|
// stage, the unreached set is exactly the stages trapped in (or downstream of)
|
||||||
|
// a cycle, which is what the client highlights.
|
||||||
|
var indegree = stages.ToDictionary(s => s.Key, s => parents[s.Key].Count, StringComparer.Ordinal);
|
||||||
|
var queue = new Queue<string>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||||
|
var sorted = 0;
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var key = queue.Dequeue();
|
||||||
|
sorted++;
|
||||||
|
foreach (var child in children[key])
|
||||||
|
if (--indegree[child] == 0)
|
||||||
|
queue.Enqueue(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sorted != stages.Count)
|
||||||
|
{
|
||||||
|
var trapped = indegree.Where(kv => kv.Value > 0).Select(kv => stageByKey[kv.Key].Name);
|
||||||
|
throw new DomainException(ErrorCodes.GraphCycle,
|
||||||
|
$"The stage graph contains a cycle involving: {string.Join(", ", trapped)}.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 — exactly one terminal stage (multiple starts may converge, but the line must
|
||||||
|
// end in one place, because that single stage is what receives the finished good).
|
||||||
|
var terminals = stages.Where(s => children[s.Key].Count == 0).ToList();
|
||||||
|
if (terminals.Count != 1)
|
||||||
|
throw new DomainException(ErrorCodes.GraphTerminalCount,
|
||||||
|
terminals.Count == 0
|
||||||
|
? "The stage graph has no final stage."
|
||||||
|
: $"The stage graph must converge to exactly one final stage, but {terminals.Count} have no outgoing connection: {string.Join(", ", terminals.Select(t => t.Name))}.",
|
||||||
|
422);
|
||||||
|
|
||||||
|
var terminal = terminals[0];
|
||||||
|
var entries = stages.Where(s => parents[s.Key].Count == 0).ToList();
|
||||||
|
|
||||||
|
// 4 — connectivity. One traversal each way subsumes all three of "no disconnected
|
||||||
|
// stages", "every stage reachable from an entry" and "every stage reaches the
|
||||||
|
// terminal": an isolated stage simply appears in neither set. A lone stage is both
|
||||||
|
// an entry and the terminal, so it falls out correctly with no special case.
|
||||||
|
//
|
||||||
|
// Kept as defence in depth, but note it is unreachable once checks 2 and 3 pass:
|
||||||
|
// in an acyclic graph every stage is reachable from some source, and if exactly one
|
||||||
|
// stage lacks an outbound edge then every stage necessarily reaches it. An isolated
|
||||||
|
// stage therefore surfaces as GRAPH_TERMINAL_COUNT (it is a second terminal), which
|
||||||
|
// is the better message anyway because it names both offenders. Verified by the M2
|
||||||
|
// smoke test, which asserts that behaviour explicitly.
|
||||||
|
var fromEntry = Reach(entries.Select(s => s.Key), children);
|
||||||
|
var toTerminal = Reach([terminal.Key], parents);
|
||||||
|
|
||||||
|
var stranded = stages.Where(s => !fromEntry.Contains(s.Key) || !toTerminal.Contains(s.Key)).ToList();
|
||||||
|
if (stranded.Count > 0)
|
||||||
|
throw new DomainException(ErrorCodes.GraphDisconnected,
|
||||||
|
$"Every stage must sit on a path from a starting stage to '{terminal.Name}', but these do not: {string.Join(", ", stranded.Select(s => s.Name))}.",
|
||||||
|
422);
|
||||||
|
|
||||||
|
// 5 — input sources (FR-MFG-04). An Upstream input may only draw from an output of
|
||||||
|
// a DIRECT parent: allowing a grandparent's output would mean WIP skipping a stage.
|
||||||
|
foreach (var s in stages)
|
||||||
|
{
|
||||||
|
var allowed = parents[s.Key]
|
||||||
|
.SelectMany(p => stageByKey[p].Outputs.Select(o => o.Key))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var input in s.Inputs)
|
||||||
|
{
|
||||||
|
if (input.Source == StageInputSource.Upstream)
|
||||||
|
{
|
||||||
|
if (input.ItemId is not null)
|
||||||
|
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||||
|
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream and cannot also reference an item.", 422);
|
||||||
|
if (string.IsNullOrWhiteSpace(input.FromOutputKey))
|
||||||
|
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||||
|
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream but names no source output.", 422);
|
||||||
|
if (!allowed.Contains(input.FromOutputKey))
|
||||||
|
{
|
||||||
|
var owner = outputOwner.TryGetValue(input.FromOutputKey, out var ownerKey)
|
||||||
|
? $"'{stageByKey[ownerKey].Name}' is not a direct parent of '{s.Name}'"
|
||||||
|
: $"output '{input.FromOutputKey}' does not exist";
|
||||||
|
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||||
|
$"Input {input.Index + 1} of stage '{s.Name}' must draw from a direct parent's output — {owner}.", 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (input.ItemId is null)
|
||||||
|
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||||
|
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and requires an item.", 422);
|
||||||
|
if (!string.IsNullOrWhiteSpace(input.FromOutputKey))
|
||||||
|
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||||
|
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6 — outputs (FR-MFG-05). The terminal stage produces exactly one real Item (the
|
||||||
|
// finished good the receipt creates a layer for); every other output is internal
|
||||||
|
// WIP and must stay item-less, or it would imply stock that never exists.
|
||||||
|
if (terminal.Outputs.Count != 1)
|
||||||
|
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
|
||||||
|
$"The final stage '{terminal.Name}' must have exactly one output, but has {terminal.Outputs.Count}.", 422);
|
||||||
|
|
||||||
|
if (terminal.Outputs[0].ItemId is null)
|
||||||
|
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
|
||||||
|
$"The output of the final stage '{terminal.Name}' must reference the finished item.", 422);
|
||||||
|
|
||||||
|
foreach (var s in stages.Where(s => !ReferenceEquals(s, terminal)))
|
||||||
|
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
|
||||||
|
throw Invalid(
|
||||||
|
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
|
||||||
|
private static HashSet<string> Reach(IEnumerable<string> roots, Dictionary<string, List<string>> next)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(roots, StringComparer.Ordinal);
|
||||||
|
var queue = new Queue<string>(seen);
|
||||||
|
while (queue.Count > 0)
|
||||||
|
foreach (var n in next[queue.Dequeue()])
|
||||||
|
if (seen.Add(n))
|
||||||
|
queue.Enqueue(n);
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Structural violations the client already prevents and docs/30 §D.4 assigns no
|
||||||
|
/// dedicated code to — still 422, still carrying a message that names the culprit.
|
||||||
|
/// </summary>
|
||||||
|
private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Production;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one serializer for every jsonb column in the manufacturing module —
|
||||||
|
/// <c>template_stages.field_defs</c>, <c>run_stages.field_defs</c>/<c>field_values</c>
|
||||||
|
/// and <c>run_stage_events.payload</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Those columns hold a pre-serialized <c>string</c> rather than a mapped POCO,
|
||||||
|
/// following the <c>AuditLog.ChangeSet</c> precedent. Beyond consistency this keeps
|
||||||
|
/// <c>AuditScribe</c> honest: an owned/typed jsonb mapping would surface the nested
|
||||||
|
/// objects as their own change-tracker entries and scatter spurious audit rows across
|
||||||
|
/// the new tables.</para>
|
||||||
|
/// <para>Everything is written through here so a column can only ever contain canonical
|
||||||
|
/// JSON — the raw client string is never stored, which means a malformed
|
||||||
|
/// <c>fieldDefs</c> can't reach the database and can't later break a run created from
|
||||||
|
/// that template.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ProductionJson
|
||||||
|
{
|
||||||
|
/// <summary>Matches the API's own wire format (docs/11 §1.3): camelCase, enums as strings.</summary>
|
||||||
|
public static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
Converters = { new JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a stored column back. Returns <paramref name="fallback"/> for null/blank so a
|
||||||
|
/// caller never has to null-check; a parse failure is a genuine data-integrity problem
|
||||||
|
/// and is allowed to throw.
|
||||||
|
/// </summary>
|
||||||
|
public static T Deserialize<T>(string? json, T fallback)
|
||||||
|
=> string.IsNullOrWhiteSpace(json) ? fallback : JsonSerializer.Deserialize<T>(json, Options) ?? fallback;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Production;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Production;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07).
|
||||||
|
/// Graph rules live in <see cref="ProductionGraphValidator"/>; everything needing the
|
||||||
|
/// database (referential checks, the edit-lock, reconciliation) lives here.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductionTemplateService : IProductionTemplateService
|
||||||
|
{
|
||||||
|
private readonly IRepository<ProductionTemplate> _templates;
|
||||||
|
private readonly IRepository<TemplateStage> _stages;
|
||||||
|
private readonly IRepository<StageInput> _inputs;
|
||||||
|
private readonly IRepository<StageOutput> _outputs;
|
||||||
|
private readonly IRepository<StageEdge> _edges;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<Uom> _uoms;
|
||||||
|
private readonly IRepository<ProductionRun> _runs;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public ProductionTemplateService(
|
||||||
|
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
|
||||||
|
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
|
||||||
|
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
|
||||||
|
IUnitOfWork uow, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_templates = templates;
|
||||||
|
_stages = stages;
|
||||||
|
_inputs = inputs;
|
||||||
|
_outputs = outputs;
|
||||||
|
_edges = edges;
|
||||||
|
_items = items;
|
||||||
|
_uoms = uoms;
|
||||||
|
_runs = runs;
|
||||||
|
_uow = uow;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<TemplateSummaryDto>> ListAsync(
|
||||||
|
PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _templates.Query().AsNoTracking();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status is not null) q = q.Where(t => t.Status == status);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
|
||||||
|
// Projected to an anonymous type first, then mapped client-side: EF Core 10 cannot
|
||||||
|
// translate a record constructor sitting alongside aggregates and a collection
|
||||||
|
// projection (same limitation recorded for WarehouseValuationDto, 2026-07-28).
|
||||||
|
var rows = await q
|
||||||
|
.OrderBy(t => t.Code)
|
||||||
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
t.TemplateId,
|
||||||
|
t.Code,
|
||||||
|
t.Name,
|
||||||
|
t.Status,
|
||||||
|
StageCount = t.Stages.Count,
|
||||||
|
Stages = t.Stages.Select(s => new { s.StageId, s.Name }).ToList(),
|
||||||
|
Edges = t.Edges.Select(e => new { e.ParentStageId, e.ChildStageId }).ToList(),
|
||||||
|
// Drives the canvas edit-lock banner (docs/21 §2) and mirrors the
|
||||||
|
// condition UpdateAsync enforces server-side.
|
||||||
|
ActiveRunCount = t.Runs.Count(r => r.Status == ProductionRunStatus.InProgress),
|
||||||
|
t.CreatedBy,
|
||||||
|
t.CreatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var items = rows.Select(t => new TemplateSummaryDto(
|
||||||
|
t.TemplateId, t.Code, t.Name, t.Status, t.StageCount,
|
||||||
|
FlowOrderedNames(
|
||||||
|
t.Stages.Select(s => (s.StageId, s.Name)).ToList(),
|
||||||
|
t.Edges.Select(e => (e.ParentStageId, e.ChildStageId)).ToList()),
|
||||||
|
t.ActiveRunCount, t.CreatedBy, t.CreatedAt)).ToList();
|
||||||
|
|
||||||
|
return PagedResponse<TemplateSummaryDto>.Create(items, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stage names in <b>flow order</b> — upstream first — for the overview canvas, which
|
||||||
|
/// draws each template as a line left to right (docs/21 §1).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Ordering by <c>StageId</c> would be insertion order, which routinely puts the terminal
|
||||||
|
/// stage first and renders the line backwards. Kahn's algorithm over the edge set gives the
|
||||||
|
/// real sequence, tie-broken by stage id so parallel branches come out deterministically.
|
||||||
|
/// Falls back to id order if the graph somehow contains a cycle, so a listing can never
|
||||||
|
/// fail because of bad data.
|
||||||
|
/// </remarks>
|
||||||
|
private static List<string> FlowOrderedNames(
|
||||||
|
List<(int StageId, string Name)> stages, List<(int ParentStageId, int ChildStageId)> edges)
|
||||||
|
{
|
||||||
|
var nameById = stages.ToDictionary(s => s.StageId, s => s.Name);
|
||||||
|
var children = stages.ToDictionary(s => s.StageId, _ => new List<int>());
|
||||||
|
var indegree = stages.ToDictionary(s => s.StageId, _ => 0);
|
||||||
|
|
||||||
|
foreach (var (parent, child) in edges)
|
||||||
|
{
|
||||||
|
if (!children.ContainsKey(parent) || !indegree.ContainsKey(child)) continue;
|
||||||
|
children[parent].Add(child);
|
||||||
|
indegree[child]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedSet keeps the frontier in id order, so the output is stable run to run.
|
||||||
|
var frontier = new SortedSet<int>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||||
|
var ordered = new List<string>(stages.Count);
|
||||||
|
|
||||||
|
while (frontier.Count > 0)
|
||||||
|
{
|
||||||
|
var next = frontier.Min;
|
||||||
|
frontier.Remove(next);
|
||||||
|
ordered.Add(nameById[next]);
|
||||||
|
|
||||||
|
foreach (var child in children[next])
|
||||||
|
if (--indegree[child] == 0)
|
||||||
|
frontier.Add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered.Count == stages.Count
|
||||||
|
? ordered
|
||||||
|
: stages.OrderBy(s => s.StageId).Select(s => s.Name).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var template = await LoadGraphQuery().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.TemplateId == templateId, ct);
|
||||||
|
|
||||||
|
if (template is null) return null;
|
||||||
|
|
||||||
|
// Counted separately rather than Included: the graph query already fans out over four
|
||||||
|
// collections, and adding Runs would multiply those rows again for a single integer.
|
||||||
|
var activeRuns = await _runs.Query().AsNoTracking()
|
||||||
|
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, ct);
|
||||||
|
|
||||||
|
return new ETagged<TemplateGraphDto>(ToGraphDto(template, activeRuns), template.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var code = request.Code.Trim();
|
||||||
|
if (await _templates.Query().AnyAsync(t => t.Code == code, ct))
|
||||||
|
throw new ConflictException($"A production template with code '{code}' already exists.");
|
||||||
|
|
||||||
|
var template = new ProductionTemplate
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Description = request.Description?.Trim(),
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedBy = _currentUser.AuditUserId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await ValidateAsync(request, ct);
|
||||||
|
|
||||||
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
await _templates.AddAsync(template, token);
|
||||||
|
BuildGraph(template, request);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return await RequireGraphAsync(template.TemplateId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<TemplateGraphDto>> UpdateAsync(
|
||||||
|
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await ValidateAsync(request, ct);
|
||||||
|
|
||||||
|
var code = request.Code.Trim();
|
||||||
|
if (await _templates.Query().AnyAsync(t => t.Code == code && t.TemplateId != templateId, ct))
|
||||||
|
throw new ConflictException($"A production template with code '{code}' already exists.");
|
||||||
|
|
||||||
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
var template = await LoadGraphQuery().FirstOrDefaultAsync(t => t.TemplateId == templateId, token)
|
||||||
|
?? throw new NotFoundException($"Production template {templateId} was not found.");
|
||||||
|
|
||||||
|
if (template.RowVersion != expectedRowVersion)
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict,
|
||||||
|
"The template was modified by another request.", 412);
|
||||||
|
|
||||||
|
// FR-MFG-06: edit-lock instead of versioning. Checked inside the transaction to
|
||||||
|
// keep the window small; the residual race is benign because a run copies
|
||||||
|
// everything it needs at creation and never reads the template again.
|
||||||
|
var activeRuns = await _runs.Query().AsNoTracking()
|
||||||
|
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, token);
|
||||||
|
if (activeRuns > 0)
|
||||||
|
throw new DomainException(ErrorCodes.TemplateInUse,
|
||||||
|
$"This template has {activeRuns} run(s) in progress and cannot be edited until they finish.", 409);
|
||||||
|
|
||||||
|
template.Code = code;
|
||||||
|
template.Name = request.Name.Trim();
|
||||||
|
template.Description = request.Description?.Trim();
|
||||||
|
|
||||||
|
// Two passes, deliberately. Tearing the old graph down and flushing before
|
||||||
|
// rebuilding removes any dependence on how EF happens to order a mixed batch of
|
||||||
|
// inserts and deletes — which matters because stage_inputs → stage_outputs is a
|
||||||
|
// Restrict FK and stage_edges carries a unique (parent, child) index.
|
||||||
|
var keptStages = TearDownGraph(template, request);
|
||||||
|
await _uow.SaveChangesAsync(token);
|
||||||
|
|
||||||
|
BuildGraph(template, request, keptStages);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return await RequireGraphAsync(templateId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var template = await _templates.Query().FirstOrDefaultAsync(t => t.TemplateId == templateId, ct)
|
||||||
|
?? throw new NotFoundException($"Production template {templateId} was not found.");
|
||||||
|
|
||||||
|
template.Status = status;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- graph reconciliation ------------------------------------------------
|
||||||
|
|
||||||
|
private IQueryable<ProductionTemplate> LoadGraphQuery() =>
|
||||||
|
_templates.Query()
|
||||||
|
.Include(t => t.Stages).ThenInclude(s => s.Inputs)
|
||||||
|
.Include(t => t.Stages).ThenInclude(s => s.Outputs)
|
||||||
|
.Include(t => t.Edges);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the pure graph rules then the referential ones. Item/UOM existence needs the
|
||||||
|
/// database, so it can't live in the validator — but it has to run before any write.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ValidateAsync(SaveTemplateRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ProductionGraphValidator.Validate(
|
||||||
|
request.Stages.Select(s => new ProductionGraphValidator.StageDraft(
|
||||||
|
s.Key, s.Name,
|
||||||
|
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(),
|
||||||
|
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(),
|
||||||
|
request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList());
|
||||||
|
|
||||||
|
var itemIds = request.Stages
|
||||||
|
.SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId)))
|
||||||
|
.OfType<int>().Distinct().ToList();
|
||||||
|
|
||||||
|
if (itemIds.Count > 0)
|
||||||
|
{
|
||||||
|
var found = await _items.Query().AsNoTracking()
|
||||||
|
.Where(i => itemIds.Contains(i.ItemId))
|
||||||
|
.Select(i => new { i.ItemId, i.Status })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList();
|
||||||
|
if (missing.Count > 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Item(s) {string.Join(", ", missing)} do not exist.", 422);
|
||||||
|
|
||||||
|
var inactive = found.Where(f => f.Status != EntityStatus.Active).Select(f => f.ItemId).ToList();
|
||||||
|
if (inactive.Count > 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
var uomIds = request.Stages
|
||||||
|
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
|
||||||
|
.Distinct().ToList();
|
||||||
|
|
||||||
|
var knownUoms = await _uoms.Query().AsNoTracking()
|
||||||
|
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
|
||||||
|
|
||||||
|
var missingUoms = uomIds.Except(knownUoms).ToList();
|
||||||
|
if (missingUoms.Count > 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||||
|
|
||||||
|
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
|
||||||
|
// client renders off. Unknown kinds would round-trip fine but draw nothing.
|
||||||
|
var badKinds = request.Annotations
|
||||||
|
.Select(a => a.Kind)
|
||||||
|
.Where(k => k is not ("box" or "line"))
|
||||||
|
.Distinct().ToList();
|
||||||
|
if (badKinds.Count > 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Unknown canvas annotation kind(s): {string.Join(", ", badKinds)}. Expected 'box' or 'line'.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes everything the incoming payload replaces and returns the surviving stages
|
||||||
|
/// by key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Inputs and outputs are always replaced wholesale — nothing outside the template
|
||||||
|
/// references them, because a run copies into its own <c>run_stage_input</c>/
|
||||||
|
/// <c>run_stage_output</c> rows. Stages are <b>diffed</b>, not replaced, because
|
||||||
|
/// <c>run_stages.TemplateStageId</c> points at them; a stage that disappears from the
|
||||||
|
/// payload is deleted and that FK is set null for any historical run (see
|
||||||
|
/// <c>RunStageConfiguration</c>).
|
||||||
|
/// </remarks>
|
||||||
|
private Dictionary<string, TemplateStage> TearDownGraph(ProductionTemplate template, SaveTemplateRequest request)
|
||||||
|
{
|
||||||
|
var payloadKeys = request.Stages.Select(s => s.Key).ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var stage in template.Stages)
|
||||||
|
{
|
||||||
|
foreach (var input in stage.Inputs.ToList()) _inputs.Remove(input);
|
||||||
|
foreach (var output in stage.Outputs.ToList()) _outputs.Remove(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
|
||||||
|
var doomed = new List<TemplateStage>();
|
||||||
|
foreach (var stage in template.Stages.ToList())
|
||||||
|
{
|
||||||
|
var key = stage.StageId.ToString();
|
||||||
|
if (payloadKeys.Contains(key)) kept[key] = stage;
|
||||||
|
else doomed.Add(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edges are diffed rather than replaced: stage_edges has a unique (parent, child)
|
||||||
|
// index, and dropping then re-adding an unchanged edge in the same round trip can
|
||||||
|
// trip it depending on statement order.
|
||||||
|
var wanted = request.Edges
|
||||||
|
.Select(e => (Parent: ResolveKeptId(kept, e.ParentKey), Child: ResolveKeptId(kept, e.ChildKey)))
|
||||||
|
.Where(e => e.Parent is not null && e.Child is not null)
|
||||||
|
.Select(e => (e.Parent!.Value, e.Child!.Value))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// Edges must go before stages. StageEdge.ParentStage/ChildStage are required
|
||||||
|
// relationships, so deleting a stage that a still-live edge points at makes EF throw
|
||||||
|
// "the association ... has been severed". Any edge touching a doomed stage is
|
||||||
|
// necessarily absent from `wanted` — the payload cannot reference a stage it dropped
|
||||||
|
// — so this ordering never orphans an edge the caller wanted to keep.
|
||||||
|
foreach (var edge in template.Edges.ToList())
|
||||||
|
if (!wanted.Contains((edge.ParentStageId, edge.ChildStageId)))
|
||||||
|
_edges.Remove(edge);
|
||||||
|
|
||||||
|
foreach (var stage in doomed)
|
||||||
|
_stages.Remove(stage);
|
||||||
|
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ResolveKeptId(Dictionary<string, TemplateStage> kept, string key)
|
||||||
|
=> kept.TryGetValue(key, out var stage) ? stage.StageId : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materialises the payload onto the template.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything is wired through <b>navigation properties</b> rather than foreign-key
|
||||||
|
/// ints, so EF resolves the ids itself during a single <c>SaveChanges</c>. That is what
|
||||||
|
/// lets a brand-new Upstream input point at a brand-new output without an intermediate
|
||||||
|
/// save to materialise generated keys — outputs are built in the first pass precisely
|
||||||
|
/// so the second pass has the entity instances to hand.
|
||||||
|
/// </remarks>
|
||||||
|
private void BuildGraph(
|
||||||
|
ProductionTemplate template, SaveTemplateRequest request,
|
||||||
|
Dictionary<string, TemplateStage>? keptStages = null)
|
||||||
|
{
|
||||||
|
// Replaced wholesale — annotations are opaque client state, not part of the graph, so
|
||||||
|
// there is nothing to diff and nothing else can reference them.
|
||||||
|
template.Annotations = request.Annotations.Count == 0
|
||||||
|
? null
|
||||||
|
: ProductionJson.Serialize(request.Annotations);
|
||||||
|
|
||||||
|
var stagesByKey = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
|
||||||
|
var outputsByKey = new Dictionary<string, StageOutput>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Pass 1 — stages and their outputs.
|
||||||
|
foreach (var s in request.Stages)
|
||||||
|
{
|
||||||
|
if (keptStages is null || !keptStages.TryGetValue(s.Key, out var stage))
|
||||||
|
{
|
||||||
|
stage = new TemplateStage { Template = template };
|
||||||
|
template.Stages.Add(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.Name = s.Name.Trim();
|
||||||
|
stage.RoleLabel = string.IsNullOrWhiteSpace(s.RoleLabel) ? null : s.RoleLabel.Trim();
|
||||||
|
stage.EstimatedMinutes = s.EstimatedMinutes;
|
||||||
|
stage.PosX = s.PosX;
|
||||||
|
stage.PosY = s.PosY;
|
||||||
|
stage.FieldDefs = ProductionJson.Serialize(s.FieldDefs);
|
||||||
|
|
||||||
|
stagesByKey[s.Key] = stage;
|
||||||
|
|
||||||
|
foreach (var o in s.Outputs)
|
||||||
|
{
|
||||||
|
var output = new StageOutput
|
||||||
|
{
|
||||||
|
Stage = stage,
|
||||||
|
ItemId = o.ItemId,
|
||||||
|
Name = o.Name.Trim(),
|
||||||
|
UomId = o.UomId,
|
||||||
|
QtyPerBatch = o.QtyPerBatch
|
||||||
|
};
|
||||||
|
stage.Outputs.Add(output);
|
||||||
|
outputsByKey[o.Key] = output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2 — inputs, which may reference any output built above.
|
||||||
|
foreach (var s in request.Stages)
|
||||||
|
{
|
||||||
|
var stage = stagesByKey[s.Key];
|
||||||
|
foreach (var i in s.Inputs)
|
||||||
|
{
|
||||||
|
stage.Inputs.Add(new StageInput
|
||||||
|
{
|
||||||
|
Stage = stage,
|
||||||
|
Source = i.Source,
|
||||||
|
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
|
||||||
|
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
|
||||||
|
UomId = i.UomId,
|
||||||
|
QtyPerBatch = i.QtyPerBatch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3 — edges not already present.
|
||||||
|
var existing = template.Edges
|
||||||
|
.Select(e => (e.ParentStageId, e.ChildStageId)).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var e in request.Edges)
|
||||||
|
{
|
||||||
|
var parent = stagesByKey[e.ParentKey];
|
||||||
|
var child = stagesByKey[e.ChildKey];
|
||||||
|
if (parent.StageId != 0 && child.StageId != 0 && existing.Contains((parent.StageId, child.StageId)))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
template.Edges.Add(new StageEdge
|
||||||
|
{
|
||||||
|
Template = template,
|
||||||
|
ParentStage = parent,
|
||||||
|
ChildStage = child
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mapping -------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task<ETagged<TemplateGraphDto>> RequireGraphAsync(int templateId, CancellationToken ct)
|
||||||
|
=> await GetAsync(templateId, ct)
|
||||||
|
?? throw new NotFoundException($"Production template {templateId} was not found.");
|
||||||
|
|
||||||
|
private static TemplateGraphDto ToGraphDto(ProductionTemplate t, int activeRunCount)
|
||||||
|
{
|
||||||
|
// The key a client sends back is just the id as a string; computing it here keeps
|
||||||
|
// that contract in exactly one place.
|
||||||
|
var outputKeyById = t.Stages
|
||||||
|
.SelectMany(s => s.Outputs)
|
||||||
|
.ToDictionary(o => o.OutputId, o => o.OutputId.ToString());
|
||||||
|
|
||||||
|
var stages = t.Stages
|
||||||
|
.OrderBy(s => s.StageId)
|
||||||
|
.Select(s => new TemplateStageDto(
|
||||||
|
s.StageId, s.StageId.ToString(), s.Name, s.RoleLabel, s.EstimatedMinutes, s.PosX, s.PosY,
|
||||||
|
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
|
||||||
|
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
|
||||||
|
i.InputId, i.Source, i.ItemId, i.FromOutputId,
|
||||||
|
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
|
||||||
|
i.UomId, i.QtyPerBatch)).ToList(),
|
||||||
|
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
|
||||||
|
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var edges = t.Edges
|
||||||
|
.OrderBy(e => e.EdgeId)
|
||||||
|
.Select(e => new TemplateEdgeDto(
|
||||||
|
e.EdgeId, e.ParentStageId, e.ChildStageId,
|
||||||
|
e.ParentStageId.ToString(), e.ChildStageId.ToString()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new TemplateGraphDto(
|
||||||
|
t.TemplateId, t.Code, t.Name, t.Description, t.Status, stages, edges,
|
||||||
|
ProductionJson.Deserialize<List<CanvasAnnotationDto>>(t.Annotations, []),
|
||||||
|
activeRunCount, t.CreatedBy, t.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,7 +121,8 @@ public sealed class FifoCostingService : IFifoCostingService
|
|||||||
public async Task<StockLedger> PostLedgerAsync(
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 15–22-char names — `SourceDocType` is `varchar(10)` on both `stock_ledger` and `journal_entry_stubs`, and widening it would have been a second Phase-1 schema change.
|
||||||
|
- **Three additions to Part C:** `RUN_EDGE` (a run must own its edges or a later template edit rewrites completed-run history), `RUN_STAGE.pos_x/pos_y` (the run canvas renders from them), `RUN_STAGE_EVENT.run_id` + nullable `run_stage_id` (run-level events, single-query timeline). `RUN_STAGE.template_stage_id` made nullable + `SET NULL` so a template stays editable after runs complete.
|
||||||
|
- **UOM conversion is unspecified in docs/30** but essential. Contract consequence: `plannedQty` is in the input's declared UOM while `consumed*`/`returned*` are in the item's **base** UOM.
|
||||||
|
- **`Idempotency-Key` accepted and ignored**, matching `GrnService.ConfirmAsync`. Status guards are the replay story; `RunStage.RowVersion` (xmin) prevents two concurrent terminal approves double-posting a receipt.
|
||||||
|
- **FR-MFG-17's "− scrapped" is not computable** at the input level (scrap lives on outputs, in output UOM). Scrap never entered stock, so nothing is deducted; scrapped quantities are recorded on the cancel event instead.
|
||||||
|
- **`GRAPH_DISCONNECTED` is unreachable** once cycle + terminal-count pass; kept as defence in depth. An isolated stage surfaces as `GRAPH_TERMINAL_COUNT`.
|
||||||
|
- **Edit-lock TOCTOU accepted** — checked inside the transaction, but under READ COMMITTED a run could still be created against a template mid-edit. Benign: runs copy everything at creation and never re-read the template.
|
||||||
|
|
||||||
|
**Not done this pass (tracked, not silently skipped):**
|
||||||
|
- [x] **Frontend wiring** (`docs/21-FRONTEND-PHASE2.md` §8) — done in the same session; see `Frontend/PROGRESS.md` §§11–13. Not browser-verified (same AuthHex blocker).
|
||||||
|
- **Batch/serial-tracked finished goods** — guarded with a 422, and that guard is unexercised (no tracked item in the dev DB).
|
||||||
|
- **`NAV:production` permission** is not seeded; the sidebar still relies on the `bypassCodes` stopgap (same as `procurement`/`hrm`).
|
||||||
|
- **No automated test project** — verification is the `Backend/smoke/` scripts, per house practice.
|
||||||
|
|
||||||
|
> ### 2026-07-30 — Dev-database repair + a drift audit worth repeating
|
||||||
|
> **`users."Email"` was missing from the database** while present in the entity and the model snapshot, so `ShadowUserClaimsTransformation`'s JIT insert failed with `42703` on **every authenticated request** — surfacing to callers as a confusing `InvalidOperationException: Sequence contains no elements`. `GET /items` and everything else 500'd. Fixed by the hand-written migration `RepairUserEmailColumn` (idempotent `ADD COLUMN IF NOT EXISTS` + the unique index, matching `UserConfiguration`'s `HasMaxLength(320)`).
|
||||||
|
>
|
||||||
|
> **Root cause — four migrations recorded as applied with zero operations:** `ini2`, `initial2`, `chages`, `chages1` each advanced the model snapshot without emitting any DDL. Anything added to the model in those windows exists in the snapshot but never reached the database.
|
||||||
|
>
|
||||||
|
> **Method note (this is the reusable part):** a scaffolded probe migration coming back **empty proves only `model == snapshot`, never `snapshot == database`** — which is exactly how this hid. The real audit was `dotnet ef dbcontext script` (which renders the *current model*) diffed against `information_schema.columns`.
|
||||||
|
>
|
||||||
|
> **Still outstanding — not fixed here, deliberately:** the same four empty migrations mean **all 25 HRM tables (`hr_*`) exist in the model and snapshot but not in this database**, so every HRM endpoint fails. Creating 25 tables of another module as a side effect of manufacturing work would be worse than reporting it; it needs its own repair migration and its own verification.
|
||||||
|
>
|
||||||
|
> **Also worth knowing:** `.gitignore:38` is `**/Migrations/`, so **no migration in this repo is version-controlled** — `AddManufacturingPhase2` and `RepairUserEmailColumn` exist only on the machine that created them. Anyone else must regenerate them.
|
||||||
|
|
||||||
|
> ### 2026-07-30 (later) — Three server-side additions the frontend wiring needed
|
||||||
|
> All three are amended into `docs/30` as built. None changes an existing endpoint's behaviour.
|
||||||
|
>
|
||||||
|
> - **`TemplateGraphDto.activeRunCount`** — the builder derives its edit-locked state from it. Counted with its own scalar query rather than an `Include`, because the graph query already fans out over four collections and adding `Runs` would multiply those rows again for one integer.
|
||||||
|
> - **`production_templates."Annotations"` (jsonb)** + `SaveTemplateRequest.Annotations`, migration **`AddTemplateCanvasAnnotations`** (exactly one `AddColumn`, applied and verified). The builder canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so **every save would have silently discarded the user's layout**. Stored through `ProductionJson` like every other jsonb column, so the column can only ever hold canonical JSON; `List<CanvasAnnotationDto>` capped at 200 by `[MaxLength]`, and `Kind` validated to `box`/`line` in `ValidateAsync` because nothing else constrains free-form client state going into jsonb. Deliberately invisible to `ProductionGraphValidator` — annotations carry no graph semantics.
|
||||||
|
> - **Wholesale replacement is the flip side and is now pinned by an assertion:** a PUT that omits `annotations` clears them. `m2_templates.py` asserts preserve → clear → restore explicitly, because silent data loss is worse than an error.
|
||||||
|
>
|
||||||
|
> **Smoke suite: extended but NOT re-run.** `m2_templates.py` gained 10 assertions (annotation round-trip incl. geometry/label/rotation, `activeRunCount` on the graph, unknown-kind rejection, and the clear/restore pair). **These are unverified.** AuthHex cannot issue a token — its configured MySQL host `187.127.102.190:3306` is unreachable from this machine (`MySqlConnector … Connect Timeout expired` on `POST /api/user`), and the localhost alternative in its `appsettings.json` is commented out. `dotnet build` is clean and the migration applied cleanly, but the last full green run of the suite (312/312) predates these additions.
|
||||||
|
>
|
||||||
|
> **HRM schema gap CLOSED** (by the repo owner, not this work): migrations `production` (another empty one — the fifth) and **`AddHrmTables`** now exist, the latter creating all 25 `hr_*` tables. The "still outstanding" note in the entry above is resolved; the underlying lesson about empty migrations is not.
|
||||||
|
|
||||||
|
|
||||||
## Done
|
## 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.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,351 @@
|
|||||||
|
"""M2 smoke test — production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07).
|
||||||
|
|
||||||
|
Touches no stock: templates only. Run with the API and AuthHex up:
|
||||||
|
|
||||||
|
python Backend/smoke/m2_templates.py
|
||||||
|
|
||||||
|
Shape under test is a diamond, which exercises multiple entries converging on one
|
||||||
|
terminal *and* a stage with two parents:
|
||||||
|
|
||||||
|
Cut ─┐
|
||||||
|
├─▶ Assemble (terminal, real item)
|
||||||
|
Prep ─┘
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from smoke_common import bootstrap
|
||||||
|
|
||||||
|
TEMPLATE_CODE = "SMOKE-PT-M2"
|
||||||
|
|
||||||
|
|
||||||
|
def pick_fixtures(c, chk):
|
||||||
|
"""Grab two active items and a UOM to build a realistic template from."""
|
||||||
|
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||||
|
if len(items) < 2:
|
||||||
|
sys.exit("FATAL: need at least 2 active items in the database.")
|
||||||
|
uoms = c.get("/uoms?pageSize=5").body["items"]
|
||||||
|
if not uoms:
|
||||||
|
sys.exit("FATAL: need at least 1 UOM in the database.")
|
||||||
|
return items[0]["itemId"], items[1]["itemId"], uoms[0]["uomId"]
|
||||||
|
|
||||||
|
|
||||||
|
def diamond(raw_item, finished_item, uom):
|
||||||
|
"""A valid graph: two entry stages feeding one terminal stage."""
|
||||||
|
return {
|
||||||
|
"code": TEMPLATE_CODE,
|
||||||
|
"name": "Smoke chair line",
|
||||||
|
"description": "Created by m2_templates.py",
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"key": "tmp-cut", "name": "Cut frame", "roleLabel": "Carpentry",
|
||||||
|
"estimatedMinutes": 60, "posX": 80, "posY": 120,
|
||||||
|
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||||
|
"type": "Checkbox", "required": True}],
|
||||||
|
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 8}],
|
||||||
|
"outputs": [{"key": "tmp-frame", "name": "Frame set", "uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tmp-prep", "name": "Prep cushions", "roleLabel": "Upholstery",
|
||||||
|
"estimatedMinutes": 30, "posX": 80, "posY": 320, "fieldDefs": [],
|
||||||
|
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 2}],
|
||||||
|
"outputs": [{"key": "tmp-cushion", "name": "Cushion set", "uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tmp-asm", "name": "Assemble & QA", "roleLabel": "QA",
|
||||||
|
"estimatedMinutes": 45, "posX": 560, "posY": 200, "fieldDefs": [],
|
||||||
|
"inputs": [
|
||||||
|
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||||
|
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "uomId": uom, "qtyPerBatch": 1},
|
||||||
|
],
|
||||||
|
# Terminal output must name the finished item (FR-MFG-05).
|
||||||
|
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item,
|
||||||
|
"uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"parentKey": "tmp-cut", "childKey": "tmp-asm"},
|
||||||
|
{"parentKey": "tmp-prep", "childKey": "tmp-asm"},
|
||||||
|
],
|
||||||
|
# Canvas-only decoration. No graph semantics at all — the validator never sees these,
|
||||||
|
# which is exactly what the "annotations do not affect the graph" assertion checks.
|
||||||
|
"annotations": [
|
||||||
|
{"kind": "box", "posX": 40, "posY": 60, "width": 400, "height": 320,
|
||||||
|
"label": "Sub-assembly", "rotation": None},
|
||||||
|
{"kind": "line", "posX": 480, "posY": 40, "width": 220, "height": 4,
|
||||||
|
"label": "Phase 2", "rotation": 90},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup(c):
|
||||||
|
"""
|
||||||
|
Find any template this script left behind, and clear the edit-lock if later scripts
|
||||||
|
started runs against it.
|
||||||
|
|
||||||
|
Without this the script is single-use: FR-MFG-06 refuses a PUT while any run of the
|
||||||
|
template is InProgress, so a second execution would fail its very first assertion with
|
||||||
|
409 TEMPLATE_IN_USE. Cancelling those runs is safe — they belong to the smoke suite.
|
||||||
|
"""
|
||||||
|
existing = c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||||
|
template = next((t for t in existing if t["code"] == TEMPLATE_CODE), None)
|
||||||
|
if template is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tid = template["templateId"]
|
||||||
|
if template["activeRunCount"] > 0:
|
||||||
|
reason = next((r["reasonCodeId"] for r in
|
||||||
|
c.get("/reason-codes?context=Production&pageSize=50").body["items"]
|
||||||
|
if r["code"] == "PRD-CANCEL"), None)
|
||||||
|
blocking = [r for r in c.get(f"/production-runs?templateId={tid}&status=InProgress&pageSize=200").body["items"]]
|
||||||
|
for run in blocking:
|
||||||
|
c.post(f"/production-runs/{run['runId']}/cancel",
|
||||||
|
{"reasonCodeId": reason, "note": "cancelled by m2_templates.py to clear the edit-lock"})
|
||||||
|
print(f"cancelled {len(blocking)} in-progress run(s) to release the template edit-lock")
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c, chk, args = bootstrap(__doc__)
|
||||||
|
print(f"API {args.api} · AuthHex {args.auth}")
|
||||||
|
|
||||||
|
raw_item, finished_item, uom = pick_fixtures(c, chk)
|
||||||
|
print(f"fixtures: rawItem={raw_item} finishedItem={finished_item} uom={uom}")
|
||||||
|
|
||||||
|
stale = cleanup(c)
|
||||||
|
if stale:
|
||||||
|
print(f"note: reusing/overwriting existing template {stale} ({TEMPLATE_CODE})")
|
||||||
|
|
||||||
|
payload = diamond(raw_item, finished_item, uom)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- create
|
||||||
|
chk.section("1. Create a valid diamond graph")
|
||||||
|
if stale:
|
||||||
|
head = c.get(f"/production-templates/{stale}")
|
||||||
|
created = c.put(f"/production-templates/{stale}", payload, if_match=head.etag)
|
||||||
|
chk.status("PUT existing template", created, 200)
|
||||||
|
else:
|
||||||
|
created = c.post("/production-templates", payload)
|
||||||
|
chk.status("POST /production-templates", created, 201)
|
||||||
|
|
||||||
|
if created.status not in (200, 201):
|
||||||
|
return chk.finish("M2")
|
||||||
|
|
||||||
|
tid = created.body["templateId"]
|
||||||
|
chk.check("ETag header present", created.etag is not None, True)
|
||||||
|
chk.check("3 stages persisted", len(created.body["stages"]), 3)
|
||||||
|
chk.check("2 edges persisted", len(created.body["edges"]), 2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- get
|
||||||
|
chk.section("2. GET round-trips ids, keys, positions and fieldDefs")
|
||||||
|
got = c.get(f"/production-templates/{tid}")
|
||||||
|
chk.status("GET /production-templates/{id}", got, 200)
|
||||||
|
g = got.body
|
||||||
|
|
||||||
|
stages = {s["name"]: s for s in g["stages"]}
|
||||||
|
chk.check("stage keys equal their ids",
|
||||||
|
all(s["key"] == str(s["stageId"]) for s in g["stages"]), True)
|
||||||
|
|
||||||
|
cut = stages["Cut frame"]
|
||||||
|
chk.check("posX survived the round trip", float(cut["posX"]), 80.0)
|
||||||
|
chk.check("fieldDefs jsonb survived", cut["fieldDefs"][0]["key"], "moisture_ok")
|
||||||
|
chk.check("fieldDef type survived as enum name", cut["fieldDefs"][0]["type"], "Checkbox")
|
||||||
|
|
||||||
|
asm = stages["Assemble & QA"]
|
||||||
|
chk.check("terminal has 2 upstream inputs",
|
||||||
|
sum(1 for i in asm["inputs"] if i["source"] == "Upstream"), 2)
|
||||||
|
chk.check("upstream inputs resolved fromOutputId",
|
||||||
|
all(i["fromOutputId"] for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||||
|
chk.check("upstream inputs expose fromOutputKey",
|
||||||
|
all(i["fromOutputKey"] for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||||
|
chk.check("terminal output carries the finished item", asm["outputs"][0]["itemId"], finished_item)
|
||||||
|
|
||||||
|
# The builder reads activeRunCount straight off the graph to decide whether to disable
|
||||||
|
# itself; without it on this response it would need a second request to the list endpoint.
|
||||||
|
chk.check("graph exposes activeRunCount", g["activeRunCount"], 0)
|
||||||
|
|
||||||
|
# Canvas annotations. Not in docs/30 Part C — added so a save can't silently discard the
|
||||||
|
# boxes and dividers the builder already draws.
|
||||||
|
chk.section("2b. Canvas annotations round-trip")
|
||||||
|
anns = {a["kind"]: a for a in g["annotations"]}
|
||||||
|
chk.check("both annotations persisted", len(g["annotations"]), 2)
|
||||||
|
chk.check("box label survived", anns.get("box", {}).get("label"), "Sub-assembly")
|
||||||
|
chk.check("box geometry survived", (float(anns["box"]["posX"]), float(anns["box"]["width"])), (40.0, 400.0))
|
||||||
|
chk.check("line rotation survived", float(anns.get("line", {}).get("rotation") or 0), 90.0)
|
||||||
|
chk.check("annotations are not stages", len(g["stages"]), 3)
|
||||||
|
|
||||||
|
bad_kind = dict(rebuild_from_get(g))
|
||||||
|
bad_kind["annotations"] = [{"kind": "circle", "posX": 0, "posY": 0, "width": 10,
|
||||||
|
"height": 10, "label": None, "rotation": None}]
|
||||||
|
chk.status("unknown annotation kind rejected",
|
||||||
|
c.put(f"/production-templates/{tid}", bad_kind, if_match=got.etag), 422)
|
||||||
|
|
||||||
|
# The keys GET hands back must be directly reusable as a PUT payload.
|
||||||
|
chk.section("3. Idempotent re-save using the server's own keys")
|
||||||
|
echo = rebuild_from_get(g)
|
||||||
|
resaved = c.put(f"/production-templates/{tid}", echo, if_match=got.etag)
|
||||||
|
chk.status("PUT echoing server keys", resaved, 200)
|
||||||
|
if resaved.status == 200:
|
||||||
|
chk.check("stage ids preserved (diffed, not recreated)",
|
||||||
|
sorted(s["stageId"] for s in resaved.body["stages"]),
|
||||||
|
sorted(s["stageId"] for s in g["stages"]))
|
||||||
|
chk.check("edge count still 2", len(resaved.body["edges"]), 2)
|
||||||
|
chk.check("annotations survived the echo re-save", len(resaved.body["annotations"]), 2)
|
||||||
|
etag = resaved.etag
|
||||||
|
|
||||||
|
# Wholesale replacement cuts both ways: a client that forgets to echo annotations back
|
||||||
|
# wipes them. Pinned explicitly because that is a silent data loss, not an error.
|
||||||
|
stripped = rebuild_from_get(g)
|
||||||
|
stripped["annotations"] = []
|
||||||
|
cleared = c.put(f"/production-templates/{tid}", stripped, if_match=etag)
|
||||||
|
chk.status("PUT omitting annotations", cleared, 200)
|
||||||
|
if cleared.status == 200:
|
||||||
|
chk.check("omitted annotations are cleared", len(cleared.body["annotations"]), 0)
|
||||||
|
restored = c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match=cleared.etag)
|
||||||
|
chk.status("PUT restoring annotations", restored, 200)
|
||||||
|
etag = restored.etag if restored.status == 200 else cleared.etag
|
||||||
|
else:
|
||||||
|
etag = got.etag
|
||||||
|
|
||||||
|
# ------------------------------------------------------- graph rejections
|
||||||
|
chk.section("4. Graph validation rejections (FR-MFG-02, FR-MFG-04, FR-MFG-05)")
|
||||||
|
|
||||||
|
cycle = rebuild_from_get(g)
|
||||||
|
cycle["edges"].append({"parentKey": key_of(g, "Assemble & QA"), "childKey": key_of(g, "Cut frame")})
|
||||||
|
chk.status("cycle", c.put(f"/production-templates/{tid}", cycle, if_match=etag), 422, "GRAPH_CYCLE")
|
||||||
|
|
||||||
|
two_term = rebuild_from_get(g)
|
||||||
|
two_term["edges"] = [e for e in two_term["edges"] if e["parentKey"] != key_of(g, "Prep cushions")]
|
||||||
|
chk.status("two terminals", c.put(f"/production-templates/{tid}", two_term, if_match=etag),
|
||||||
|
422, "GRAPH_TERMINAL_COUNT")
|
||||||
|
|
||||||
|
# An isolated stage has no outbound edge, so it is *also* a second terminal and the
|
||||||
|
# cheaper terminal-count check catches it first. That is the more useful error anyway —
|
||||||
|
# it names both offending stages. GRAPH_DISCONNECTED is unreachable in a valid-so-far
|
||||||
|
# DAG (see the note in ProductionGraphValidator) and is kept only as defence in depth.
|
||||||
|
orphan = rebuild_from_get(g)
|
||||||
|
orphan["stages"].append({
|
||||||
|
"key": "tmp-orphan", "name": "Orphan stage", "estimatedMinutes": 5,
|
||||||
|
"posX": 900, "posY": 600, "fieldDefs": [], "inputs": [],
|
||||||
|
"outputs": [{"key": "tmp-orphan-out", "name": "Nothing", "uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
})
|
||||||
|
chk.status("isolated stage (reported as a second terminal)",
|
||||||
|
c.put(f"/production-templates/{tid}", orphan, if_match=etag),
|
||||||
|
422, "GRAPH_TERMINAL_COUNT")
|
||||||
|
|
||||||
|
# Grandparent reference: Cut -> Mid -> Asm, with Asm drawing from Cut's output.
|
||||||
|
grandparent = rebuild_from_get(g)
|
||||||
|
cut_key = key_of(g, "Cut frame")
|
||||||
|
asm_key = key_of(g, "Assemble & QA")
|
||||||
|
cut_output_key = output_key_of(g, "Cut frame", "Frame set")
|
||||||
|
grandparent["stages"].append({
|
||||||
|
"key": "tmp-mid", "name": "Middle", "estimatedMinutes": 5, "posX": 320, "posY": 120,
|
||||||
|
"fieldDefs": [],
|
||||||
|
"inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
"outputs": [{"key": "tmp-mid-out", "name": "Mid part", "uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
})
|
||||||
|
grandparent["edges"] = [e for e in grandparent["edges"] if e["parentKey"] != cut_key]
|
||||||
|
grandparent["edges"] += [{"parentKey": cut_key, "childKey": "tmp-mid"},
|
||||||
|
{"parentKey": "tmp-mid", "childKey": asm_key}]
|
||||||
|
# Assemble still reads Cut's output, but Cut is now a grandparent -> invalid.
|
||||||
|
chk.status("upstream input from a grandparent",
|
||||||
|
c.put(f"/production-templates/{tid}", grandparent, if_match=etag),
|
||||||
|
422, "GRAPH_INPUT_SOURCE_INVALID")
|
||||||
|
|
||||||
|
no_item = rebuild_from_get(g)
|
||||||
|
for s in no_item["stages"]:
|
||||||
|
if s["name"] == "Assemble & QA":
|
||||||
|
s["outputs"][0]["itemId"] = None
|
||||||
|
chk.status("terminal output without an item",
|
||||||
|
c.put(f"/production-templates/{tid}", no_item, if_match=etag),
|
||||||
|
422, "TERMINAL_OUTPUT_ITEM_REQUIRED")
|
||||||
|
|
||||||
|
wip_item = rebuild_from_get(g)
|
||||||
|
for s in wip_item["stages"]:
|
||||||
|
if s["name"] == "Cut frame":
|
||||||
|
s["outputs"][0]["itemId"] = finished_item
|
||||||
|
chk.status("intermediate output claiming an item",
|
||||||
|
c.put(f"/production-templates/{tid}", wip_item, if_match=etag), 422)
|
||||||
|
|
||||||
|
bad_item = rebuild_from_get(g)
|
||||||
|
for s in bad_item["stages"]:
|
||||||
|
if s["name"] == "Cut frame":
|
||||||
|
s["inputs"][0]["itemId"] = 999_999
|
||||||
|
chk.status("stock input naming a nonexistent item",
|
||||||
|
c.put(f"/production-templates/{tid}", bad_item, if_match=etag), 422)
|
||||||
|
|
||||||
|
chk.section("5. Concurrency + status")
|
||||||
|
chk.status("PUT with no If-Match", c.request("PUT", f"/production-templates/{tid}", rebuild_from_get(g)),
|
||||||
|
428, "PRECONDITION_REQUIRED")
|
||||||
|
# Malformed vs stale are different failures. "AQAAAA==" is a well-formed 4-byte token
|
||||||
|
# (xmin = 1) that no live row will ever carry, so it reaches the service's version
|
||||||
|
# comparison and yields 412 rather than the 428 a garbage token would.
|
||||||
|
chk.status("PUT with a malformed If-Match",
|
||||||
|
c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"not-base64"'),
|
||||||
|
428, "PRECONDITION_REQUIRED")
|
||||||
|
chk.status("PUT with a stale If-Match",
|
||||||
|
c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"AQAAAA=="'),
|
||||||
|
412, "CONCURRENCY_CONFLICT")
|
||||||
|
|
||||||
|
chk.status("PATCH status -> Inactive",
|
||||||
|
c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204)
|
||||||
|
listed = c.get(f"/production-templates?q={TEMPLATE_CODE}")
|
||||||
|
row = next((t for t in listed.body["items"] if t["templateId"] == tid), None)
|
||||||
|
chk.check("list reports Inactive", row and row["status"], "Inactive")
|
||||||
|
chk.check("list reports stageCount 3", row and row["stageCount"], 3)
|
||||||
|
chk.check("list reports activeRunCount 0", row and row["activeRunCount"], 0)
|
||||||
|
|
||||||
|
# Leave it Active so the M3 run-creation smoke can start runs from it.
|
||||||
|
c.patch(f"/production-templates/{tid}/status", {"status": "Active"})
|
||||||
|
print(f"\nleft template {tid} ({TEMPLATE_CODE}) Active for the M3 smoke test")
|
||||||
|
|
||||||
|
return chk.finish("M2")
|
||||||
|
|
||||||
|
|
||||||
|
# --- helpers: turn a GET response back into a save payload -------------------
|
||||||
|
|
||||||
|
def rebuild_from_get(g: dict) -> dict:
|
||||||
|
"""Echo a fetched graph back as a save payload, reusing the server's keys."""
|
||||||
|
return {
|
||||||
|
"code": g["code"],
|
||||||
|
"name": g["name"],
|
||||||
|
"description": g.get("description"),
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"key": s["key"], "name": s["name"], "roleLabel": s.get("roleLabel"),
|
||||||
|
"estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"],
|
||||||
|
"fieldDefs": s["fieldDefs"],
|
||||||
|
"inputs": [
|
||||||
|
{"source": i["source"], "itemId": i.get("itemId"),
|
||||||
|
"fromOutputKey": i.get("fromOutputKey"),
|
||||||
|
"uomId": i["uomId"], "qtyPerBatch": i["qtyPerBatch"]}
|
||||||
|
for i in s["inputs"]
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
|
||||||
|
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]}
|
||||||
|
for o in s["outputs"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for s in g["stages"]
|
||||||
|
],
|
||||||
|
"edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in g["edges"]],
|
||||||
|
# Echoed back deliberately: annotations are replaced wholesale, so omitting them here
|
||||||
|
# would make every re-save silently clear the canvas layout notes.
|
||||||
|
"annotations": g["annotations"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def key_of(g: dict, stage_name: str) -> str:
|
||||||
|
return next(s["key"] for s in g["stages"] if s["name"] == stage_name)
|
||||||
|
|
||||||
|
|
||||||
|
def output_key_of(g: dict, stage_name: str, output_name: str) -> str:
|
||||||
|
stage = next(s for s in g["stages"] if s["name"] == stage_name)
|
||||||
|
return next(o["key"] for o in stage["outputs"] if o["name"] == output_name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""M3 smoke test — run creation, list/detail, quantity override (docs/30 §D.2, FR-MFG-08/09/18).
|
||||||
|
|
||||||
|
Still touches no stock: creating a run only copies and scales the template. Depends on the
|
||||||
|
template m2_templates.py leaves behind, so run that first:
|
||||||
|
|
||||||
|
python Backend/smoke/m2_templates.py
|
||||||
|
python Backend/smoke/m3_runs.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from smoke_common import bootstrap
|
||||||
|
|
||||||
|
TEMPLATE_CODE = "SMOKE-PT-M2"
|
||||||
|
TARGET_QTY = 50
|
||||||
|
|
||||||
|
|
||||||
|
def find_template(c):
|
||||||
|
listed = c.get(f"/production-templates?q={TEMPLATE_CODE}")
|
||||||
|
row = next((t for t in listed.body["items"] if t["code"] == TEMPLATE_CODE), None)
|
||||||
|
if not row:
|
||||||
|
sys.exit(f"FATAL: template {TEMPLATE_CODE} not found — run m2_templates.py first.")
|
||||||
|
return row["templateId"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c, chk, args = bootstrap(__doc__)
|
||||||
|
print(f"API {args.api}")
|
||||||
|
|
||||||
|
tid = find_template(c)
|
||||||
|
tpl = c.get(f"/production-templates/{tid}").body
|
||||||
|
warehouse = c.get("/warehouses?pageSize=1").body["items"][0]["warehouseId"]
|
||||||
|
# Captured before creating so the script stays re-runnable — a previous run of this
|
||||||
|
# script leaves its own InProgress run behind for M4.
|
||||||
|
runs_before = next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||||
|
if t["templateId"] == tid)["activeRunCount"]
|
||||||
|
print(f"template={tid} warehouse={warehouse} targetQty={TARGET_QTY} activeRunsBefore={runs_before}")
|
||||||
|
|
||||||
|
terminal_stage = next(s for s in tpl["stages"] if s["name"] == "Assemble & QA")
|
||||||
|
terminal_qpb = terminal_stage["outputs"][0]["qtyPerBatch"]
|
||||||
|
expected_scale = TARGET_QTY / terminal_qpb
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ create
|
||||||
|
chk.section("1. Create a run (FR-MFG-08: scale, copy, number)")
|
||||||
|
created = c.post("/production-runs", {
|
||||||
|
"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": warehouse,
|
||||||
|
})
|
||||||
|
if not chk.status("POST /production-runs", created, 201):
|
||||||
|
return chk.finish("M3")
|
||||||
|
|
||||||
|
run = created.body
|
||||||
|
rid = run["runId"]
|
||||||
|
chk.check("ETag header present", created.etag is not None, True)
|
||||||
|
chk.check("docNo uses the PRD sequence", run["docNo"].startswith("PRD-"), True)
|
||||||
|
chk.check("run starts InProgress", run["status"], "InProgress")
|
||||||
|
chk.check("reworkCount starts at 0", run["reworkCount"], 0)
|
||||||
|
chk.check("scaleFactor computed", float(run["scaleFactor"]), float(expected_scale))
|
||||||
|
chk.check("3 stages copied", len(run["stages"]), 3)
|
||||||
|
chk.check("2 edges copied", len(run["edges"]), 2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------- copy-on-create
|
||||||
|
chk.section("2. Copy-on-create carries display fields and fieldDefs (FR-MFG-06)")
|
||||||
|
stages = {s["name"]: s for s in run["stages"]}
|
||||||
|
chk.check("stage names copied", sorted(stages), ["Assemble & QA", "Cut frame", "Prep cushions"])
|
||||||
|
|
||||||
|
cut = stages["Cut frame"]
|
||||||
|
chk.check("roleLabel copied", cut["roleLabel"], "Carpentry")
|
||||||
|
chk.check("estimatedMinutes copied", cut["estimatedMinutes"], 60)
|
||||||
|
chk.check("posX copied", float(cut["posX"]), 80.0)
|
||||||
|
chk.check("fieldDefs copied", cut["fieldDefs"][0]["key"], "moisture_ok")
|
||||||
|
chk.check("fieldValues empty before complete", cut["fieldValues"], None)
|
||||||
|
chk.check("templateStageId links back", cut["templateStageId"] is not None, True)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- readiness
|
||||||
|
chk.section("3. Entry stages Ready, others Waiting (FR-MFG-09)")
|
||||||
|
chk.check("Cut frame is an entry", cut["isEntry"], True)
|
||||||
|
chk.check("Cut frame Ready", cut["status"], "Ready")
|
||||||
|
chk.check("Prep cushions Ready", stages["Prep cushions"]["status"], "Ready")
|
||||||
|
|
||||||
|
asm = stages["Assemble & QA"]
|
||||||
|
chk.check("Assemble is terminal", asm["isTerminal"], True)
|
||||||
|
chk.check("Assemble is not an entry", asm["isEntry"], False)
|
||||||
|
chk.check("Assemble Waiting", asm["status"], "Waiting")
|
||||||
|
chk.check("no stage is terminal but Cut/Prep",
|
||||||
|
[s["name"] for s in run["stages"] if s["isTerminal"]], ["Assemble & QA"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- scaling
|
||||||
|
chk.section("4. Every quantity scaled by the factor (FR-MFG-08)")
|
||||||
|
tpl_stages = {s["name"]: s for s in tpl["stages"]}
|
||||||
|
ok = True
|
||||||
|
for name, rs in stages.items():
|
||||||
|
ts = tpl_stages[name]
|
||||||
|
for ti, ri in zip(ts["inputs"], rs["inputs"]):
|
||||||
|
want = round(float(ti["qtyPerBatch"]) * expected_scale, 4)
|
||||||
|
if float(ri["plannedQty"]) != want:
|
||||||
|
ok = False
|
||||||
|
print(f" input mismatch on {name}: {ri['plannedQty']} != {want}")
|
||||||
|
for to, ro in zip(ts["outputs"], rs["outputs"]):
|
||||||
|
want = round(float(to["qtyPerBatch"]) * expected_scale, 4)
|
||||||
|
if float(ro["plannedQty"]) != want:
|
||||||
|
ok = False
|
||||||
|
print(f" output mismatch on {name}: {ro['plannedQty']} != {want}")
|
||||||
|
chk.check("all planned quantities == qtyPerBatch x scaleFactor", ok, True)
|
||||||
|
chk.check("Cut frame input scaled (8 x 50)", float(cut["inputs"][0]["plannedQty"]), 400.0)
|
||||||
|
chk.check("terminal output scaled to the target", float(asm["outputs"][0]["plannedQty"]), float(TARGET_QTY))
|
||||||
|
|
||||||
|
chk.check("consumed/delivered start at zero",
|
||||||
|
all(float(i["consumedQty"]) == 0 and float(i["deliveredQty"]) == 0
|
||||||
|
for s in run["stages"] for i in s["inputs"]), True)
|
||||||
|
chk.check("cost pool starts empty", float(run["costPool"]["net"]), 0.0)
|
||||||
|
|
||||||
|
# Upstream inputs must point at the run's own copied outputs, not the template's.
|
||||||
|
run_output_ids = {o["runOutputId"] for s in run["stages"] for o in s["outputs"]}
|
||||||
|
chk.check("upstream inputs rewired to run outputs",
|
||||||
|
all(i["fromRunOutputId"] in run_output_ids
|
||||||
|
for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- list
|
||||||
|
chk.section("5. Run board projection (FR-MFG-18)")
|
||||||
|
listed = c.get(f"/production-runs?q={run['docNo']}")
|
||||||
|
chk.status("GET /production-runs", listed, 200)
|
||||||
|
row = next((r for r in listed.body["items"] if r["runId"] == rid), None)
|
||||||
|
chk.check("run appears on the board", row is not None, True)
|
||||||
|
if row:
|
||||||
|
chk.check("stageSummary counts match",
|
||||||
|
row["stageSummary"], {"waiting": 1, "ready": 2, "inProgress": 0, "done": 0, "approved": 0})
|
||||||
|
chk.check("templateName joined", row["templateName"], tpl["name"])
|
||||||
|
chk.check("finished item surfaced", row["finishedItemId"], asm["outputs"][0]["itemId"])
|
||||||
|
chk.check("finished item name joined", row["finishedItemName"] is not None, True)
|
||||||
|
|
||||||
|
chk.check("filter by status=InProgress finds it",
|
||||||
|
any(r["runId"] == rid for r in c.get("/production-runs?status=InProgress&pageSize=200").body["items"]), True)
|
||||||
|
chk.check("filter by status=Completed excludes it",
|
||||||
|
any(r["runId"] == rid for r in c.get("/production-runs?status=Completed&pageSize=200").body["items"]), False)
|
||||||
|
chk.check("filter by templateId finds it",
|
||||||
|
any(r["runId"] == rid for r in c.get(f"/production-runs?templateId={tid}&pageSize=200").body["items"]), True)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- edit-lock
|
||||||
|
chk.section("6. Template edit-lock now that a run is InProgress (FR-MFG-06)")
|
||||||
|
head = c.get(f"/production-templates/{tid}")
|
||||||
|
locked = c.put(f"/production-templates/{tid}", {
|
||||||
|
"code": tpl["code"], "name": tpl["name"], "description": tpl.get("description"),
|
||||||
|
"stages": [
|
||||||
|
{"key": s["key"], "name": s["name"], "roleLabel": s.get("roleLabel"),
|
||||||
|
"estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"],
|
||||||
|
"fieldDefs": s["fieldDefs"],
|
||||||
|
"inputs": [{"source": i["source"], "itemId": i.get("itemId"),
|
||||||
|
"fromOutputKey": i.get("fromOutputKey"), "uomId": i["uomId"],
|
||||||
|
"qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"]],
|
||||||
|
"outputs": [{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
|
||||||
|
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} for o in s["outputs"]]}
|
||||||
|
for s in tpl["stages"]
|
||||||
|
],
|
||||||
|
"edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in tpl["edges"]],
|
||||||
|
}, if_match=head.etag)
|
||||||
|
chk.status("PUT template while a run is InProgress", locked, 409, "TEMPLATE_IN_USE")
|
||||||
|
|
||||||
|
# Deactivating must stay allowed — it only blocks NEW runs (FR-MFG-01).
|
||||||
|
chk.status("PATCH status while a run is InProgress is still allowed",
|
||||||
|
c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204)
|
||||||
|
chk.status("run creation from an Inactive template",
|
||||||
|
c.post("/production-runs", {"templateId": tid, "targetQty": 5, "warehouseId": warehouse}),
|
||||||
|
422, "TEMPLATE_INACTIVE")
|
||||||
|
c.patch(f"/production-templates/{tid}/status", {"status": "Active"})
|
||||||
|
|
||||||
|
chk.check("activeRunCount incremented by the new run",
|
||||||
|
next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||||
|
if t["templateId"] == tid)["activeRunCount"], runs_before + 1)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- quantities
|
||||||
|
chk.section("7. Per-run quantity override (FR-MFG-08)")
|
||||||
|
cut_input = cut["inputs"][0]
|
||||||
|
edited = c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities",
|
||||||
|
{"inputs": [{"id": cut_input["runInputId"], "plannedQty": 420}], "outputs": []})
|
||||||
|
chk.status("PUT quantities on a Ready stage", edited, 200)
|
||||||
|
if edited.status == 200:
|
||||||
|
chk.check("plannedQty updated", float(edited.body["inputs"][0]["plannedQty"]), 420.0)
|
||||||
|
chk.check("stage still Ready (no upstream inputs)", edited.body["status"], "Ready")
|
||||||
|
|
||||||
|
chk.status("PUT quantities naming another stage's input",
|
||||||
|
c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities",
|
||||||
|
{"inputs": [{"id": asm["inputs"][0]["runInputId"], "plannedQty": 9}], "outputs": []}),
|
||||||
|
422)
|
||||||
|
|
||||||
|
# Raising a Waiting stage's upstream input must keep it Waiting, and the edit must be
|
||||||
|
# rejected outright once a stage has started (covered in M4 once we can start one).
|
||||||
|
asm_up = next(i for i in asm["inputs"] if i["source"] == "Upstream")
|
||||||
|
bumped = c.put(f"/production-runs/{rid}/stages/{asm['runStageId']}/quantities",
|
||||||
|
{"inputs": [{"id": asm_up["runInputId"], "plannedQty": 60}], "outputs": []})
|
||||||
|
chk.status("PUT quantities on a Waiting stage", bumped, 200)
|
||||||
|
if bumped.status == 200:
|
||||||
|
chk.check("stage stays Waiting (nothing delivered)", bumped.body["status"], "Waiting")
|
||||||
|
|
||||||
|
chk.status("PUT quantities on a nonexistent stage",
|
||||||
|
c.put(f"/production-runs/{rid}/stages/999999/quantities", {"inputs": [], "outputs": []}), 404)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ events
|
||||||
|
chk.section("8. Event history records the edits")
|
||||||
|
detail = c.get(f"/production-runs/{rid}")
|
||||||
|
chk.status("GET /production-runs/{id}", detail, 200)
|
||||||
|
events = detail.body["events"]
|
||||||
|
# Two, not four: only the two successful edits are recorded. The 422 (wrong stage) and
|
||||||
|
# the 404 both throw inside ExecuteInTransactionAsync, so their event write rolls back
|
||||||
|
# with the rest of the change — history never shows an edit that did not happen.
|
||||||
|
chk.check("only successful edits are logged",
|
||||||
|
sum(1 for e in events if e["eventType"] == "QuantityEdit"), 2)
|
||||||
|
chk.check("event payload captured",
|
||||||
|
events[0]["payload"] is not None if events else False, True)
|
||||||
|
chk.check("event carries an actor", events[0]["userId"] > 0 if events else False, True)
|
||||||
|
|
||||||
|
print(f"\nleft run {rid} ({run['docNo']}) InProgress for the M4 smoke test")
|
||||||
|
return chk.finish("M3")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""M4 smoke test — stage start/complete/approve/transfer (docs/30 §D.3, FR-MFG-09..12).
|
||||||
|
|
||||||
|
FIRST STOCK-TOUCHING MILESTONE. Everything happens in a dedicated `SMOKE-PRD` warehouse so
|
||||||
|
the effects are isolated from real data and easy to inspect or clean up:
|
||||||
|
|
||||||
|
DELETE FROM stock_ledger WHERE "WarehouseId" = (SELECT "WarehouseId" FROM warehouses WHERE "Code"='SMOKE-PRD');
|
||||||
|
|
||||||
|
Self-contained — builds its own template and run, so it does not depend on M2/M3 leftovers:
|
||||||
|
|
||||||
|
python Backend/smoke/m4_stage_actions.py
|
||||||
|
|
||||||
|
Leaves the terminal stage InProgress for m5_receipt.py to complete and approve.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||||
|
|
||||||
|
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||||
|
TEMPLATE_CODE = "SMOKE-PT-M4"
|
||||||
|
TARGET_QTY = 50
|
||||||
|
SEED_RAW = 1000 # base units of the raw item
|
||||||
|
SEED_PACK = 500 # base units of the packaging item
|
||||||
|
# Deliberately awkward unit costs: the raw item is seeded in two layers at different costs
|
||||||
|
# so FIFO consumption produces a genuinely weighted value rather than a round number.
|
||||||
|
RAW_COST_1 = 2.5
|
||||||
|
RAW_COST_2 = 4.75
|
||||||
|
PACK_COST = 1.25
|
||||||
|
STATE_FILE = "m4_state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_warehouse(c):
|
||||||
|
for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]:
|
||||||
|
if w["code"] == WAREHOUSE_CODE:
|
||||||
|
return w["warehouseId"]
|
||||||
|
created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Production smoke warehouse"})
|
||||||
|
if created.status != 201:
|
||||||
|
sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}")
|
||||||
|
return created.body["warehouseId"]
|
||||||
|
|
||||||
|
|
||||||
|
def adjustment_reason(c):
|
||||||
|
codes = c.get("/reason-codes?context=Adjustment&pageSize=50").body["items"]
|
||||||
|
if not codes:
|
||||||
|
sys.exit("FATAL: no Adjustment reason codes seeded.")
|
||||||
|
return codes[0]["reasonCodeId"]
|
||||||
|
|
||||||
|
|
||||||
|
def production_reason(c, code):
|
||||||
|
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
|
||||||
|
if r["code"] == code:
|
||||||
|
return r["reasonCodeId"]
|
||||||
|
sys.exit(f"FATAL: Production reason {code} not seeded.")
|
||||||
|
|
||||||
|
|
||||||
|
def on_hand(c, item, wh):
|
||||||
|
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||||
|
|
||||||
|
|
||||||
|
def ledger_rows(c, run_id, source):
|
||||||
|
rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def seed_stock(c, wh, raw, pack, uom):
|
||||||
|
"""
|
||||||
|
Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO
|
||||||
|
consumption at start has to weight them, so `consumedValue` is a real number the
|
||||||
|
assertions can check rather than the 0.00 a positive adjustment would produce.
|
||||||
|
"""
|
||||||
|
seed_costed_stock(c, wh, [
|
||||||
|
(raw, uom, SEED_RAW * 0.4, RAW_COST_1),
|
||||||
|
(raw, uom, SEED_RAW * 0.6, RAW_COST_2),
|
||||||
|
(pack, uom, SEED_PACK, PACK_COST),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def build_template(c, raw, pack, finished, uom):
|
||||||
|
"""Cut (entry, stock input) → Assemble (terminal, upstream + a late stock input)."""
|
||||||
|
payload = {
|
||||||
|
"code": TEMPLATE_CODE,
|
||||||
|
"name": "Smoke M4 line",
|
||||||
|
"description": "Created by m4_stage_actions.py",
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"key": "tmp-cut", "name": "Cut", "roleLabel": "Carpentry",
|
||||||
|
"estimatedMinutes": 60, "posX": 80, "posY": 100,
|
||||||
|
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||||
|
"type": "Checkbox", "required": True}],
|
||||||
|
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": 8}],
|
||||||
|
"outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# A Stock input on a non-entry stage — FR-MFG-04 allows material to join late
|
||||||
|
# (packaging), which is exactly what this covers.
|
||||||
|
"key": "tmp-asm", "name": "Assemble", "roleLabel": "QA",
|
||||||
|
"estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [],
|
||||||
|
"inputs": [
|
||||||
|
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||||
|
{"source": "Stock", "itemId": pack, "uomId": uom, "qtyPerBatch": 2},
|
||||||
|
],
|
||||||
|
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished,
|
||||||
|
"uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||||
|
if t["code"] == TEMPLATE_CODE), None)
|
||||||
|
if existing:
|
||||||
|
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||||
|
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||||
|
if res.status == 409:
|
||||||
|
# An earlier smoke run is still InProgress; reuse the template as-is.
|
||||||
|
return existing["templateId"]
|
||||||
|
if res.status != 200:
|
||||||
|
sys.exit(f"FATAL: could not update the M4 template: {res.status} {res.body}")
|
||||||
|
return res.body["templateId"]
|
||||||
|
|
||||||
|
res = c.post("/production-templates", payload)
|
||||||
|
if res.status != 201:
|
||||||
|
sys.exit(f"FATAL: could not create the M4 template: {res.status} {res.body}")
|
||||||
|
return res.body["templateId"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c, chk, args = bootstrap(__doc__)
|
||||||
|
print(f"API {args.api}")
|
||||||
|
|
||||||
|
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||||
|
if len(items) < 3:
|
||||||
|
sys.exit("FATAL: need at least 3 active items.")
|
||||||
|
raw, pack, finished = items[0]["itemId"], items[1]["itemId"], items[2]["itemId"]
|
||||||
|
uom = items[0]["baseUomId"]
|
||||||
|
|
||||||
|
wh = ensure_warehouse(c)
|
||||||
|
drained = drain_stock(c, wh)
|
||||||
|
if drained:
|
||||||
|
print(f"drained {len(drained)} leftover item(s) from a previous execution")
|
||||||
|
seed_stock(c, wh, raw, pack, uom)
|
||||||
|
raw_before = on_hand(c, raw, wh)
|
||||||
|
pack_before = on_hand(c, pack, wh)
|
||||||
|
print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}")
|
||||||
|
|
||||||
|
tid = build_template(c, raw, pack, finished, uom)
|
||||||
|
created = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
|
||||||
|
if not chk.status("create the run", created, 201):
|
||||||
|
return chk.finish("M4")
|
||||||
|
|
||||||
|
run = created.body
|
||||||
|
rid = run["runId"]
|
||||||
|
cut = next(s for s in run["stages"] if s["name"] == "Cut")
|
||||||
|
asm = next(s for s in run["stages"] if s["name"] == "Assemble")
|
||||||
|
cut_id, asm_id = cut["runStageId"], asm["runStageId"]
|
||||||
|
print(f"run={rid} {run['docNo']} cut={cut_id} assemble={asm_id}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- start
|
||||||
|
chk.section("1. Stage start FIFO-consumes its stock inputs (FR-MFG-10)")
|
||||||
|
started = c.post(f"/production-runs/{rid}/stages/{cut_id}/start", idempotency_key="m4-start-cut")
|
||||||
|
if not chk.status("POST .../start", started, 200):
|
||||||
|
return chk.finish("M4")
|
||||||
|
|
||||||
|
chk.check("stage now InProgress", started.body["status"], "InProgress")
|
||||||
|
chk.check("actualStartAt stamped", started.body["actualStartAt"] is not None, True)
|
||||||
|
chk.check("one input consumed", len(started.body["consumed"]), 1)
|
||||||
|
|
||||||
|
con = started.body["consumed"][0]
|
||||||
|
chk.check("consumed the scaled quantity (8 x 50)", float(con["qty"]), 400.0)
|
||||||
|
chk.check("consumed layers reported", len(con["consumedLayers"]) >= 1, True)
|
||||||
|
chk.check("consumed value = qty x layer cost",
|
||||||
|
round(float(con["value"]), 4),
|
||||||
|
round(sum(float(l["qty"]) * float(l["unitCost"]) for l in con["consumedLayers"]), 4))
|
||||||
|
# The whole point of seeding two layers at different costs: prove the value is genuinely
|
||||||
|
# FIFO-weighted rather than zero or a single flat rate.
|
||||||
|
chk.check("consumed value is non-zero", float(con["value"]) > 0, True)
|
||||||
|
layer_costs = {float(l["unitCost"]) for l in con["consumedLayers"]}
|
||||||
|
chk.check("consumption drew from the cheaper layer first (FR-STK-03)",
|
||||||
|
min(layer_costs), RAW_COST_1)
|
||||||
|
chk.check("ledgerRefs returned", len(started.body["ledgerRefs"]), 1)
|
||||||
|
|
||||||
|
chk.check("on-hand fell by exactly the consumed qty", on_hand(c, raw, wh), raw_before - 400.0)
|
||||||
|
|
||||||
|
prdi = ledger_rows(c, rid, "PRDI")
|
||||||
|
chk.check("one PRDI ledger row", len(prdi), 1)
|
||||||
|
if prdi:
|
||||||
|
chk.check("PRDI direction is Out", prdi[0]["direction"], "Out")
|
||||||
|
chk.check("PRDI qty is base-UOM 400", float(prdi[0]["qtyBase"]), 400.0)
|
||||||
|
|
||||||
|
detail = c.get(f"/production-runs/{rid}").body
|
||||||
|
cut_now = next(s for s in detail["stages"] if s["runStageId"] == cut_id)
|
||||||
|
chk.check("consumedQty recorded on the input", float(cut_now["inputs"][0]["consumedQty"]), 400.0)
|
||||||
|
chk.check("cost pool now reflects the consumption",
|
||||||
|
float(detail["costPool"]["net"]), round(float(con["value"]), 4))
|
||||||
|
|
||||||
|
chk.section("2. Guards after starting")
|
||||||
|
chk.status("start again", c.post(f"/production-runs/{rid}/stages/{cut_id}/start"),
|
||||||
|
409, "STAGE_NOT_READY")
|
||||||
|
chk.status("start a Waiting stage", c.post(f"/production-runs/{rid}/stages/{asm_id}/start"),
|
||||||
|
409, "STAGE_NOT_READY")
|
||||||
|
chk.status("edit quantities on a started stage",
|
||||||
|
c.put(f"/production-runs/{rid}/stages/{cut_id}/quantities",
|
||||||
|
{"inputs": [{"id": cut_now["inputs"][0]["runInputId"], "plannedQty": 500}], "outputs": []}),
|
||||||
|
409, "STAGE_NOT_EDITABLE")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- complete
|
||||||
|
chk.section("3. Stage complete records produced/scrap/fields (FR-MFG-11)")
|
||||||
|
cut_out = cut_now["outputs"][0]["runOutputId"]
|
||||||
|
|
||||||
|
chk.status("complete without the required custom field",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}]}),
|
||||||
|
400, "REQUIRED_FIELD_MISSING")
|
||||||
|
|
||||||
|
chk.status("complete with scrap but no reason code",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2}],
|
||||||
|
"fieldValues": {"moisture_ok": True}}),
|
||||||
|
400, "REASON_CODE_REQUIRED")
|
||||||
|
|
||||||
|
chk.status("complete with an Adjustment-context reason",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2,
|
||||||
|
"scrapReasonCodeId": adjustment_reason(c)}],
|
||||||
|
"fieldValues": {"moisture_ok": True}}),
|
||||||
|
422)
|
||||||
|
|
||||||
|
chk.status("complete with scrapped > produced",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut_out, "producedQty": 5, "scrappedQty": 9,
|
||||||
|
"scrapReasonCodeId": production_reason(c, "PRD-SCRAP")}],
|
||||||
|
"fieldValues": {"moisture_ok": True}}),
|
||||||
|
422)
|
||||||
|
|
||||||
|
done = c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}],
|
||||||
|
"fieldValues": {"moisture_ok": True}})
|
||||||
|
chk.status("complete properly", done, 200)
|
||||||
|
if done.status == 200:
|
||||||
|
chk.check("stage now Done", done.body["status"], "Done")
|
||||||
|
chk.check("actualEndAt stamped", done.body["actualEndAt"] is not None, True)
|
||||||
|
chk.check("actualMinutes computed", done.body["actualMinutes"] is not None, True)
|
||||||
|
chk.check("producedQty recorded", float(done.body["outputs"][0]["producedQty"]), 50.0)
|
||||||
|
chk.check("availableToTransfer = produced - scrapped - transferred",
|
||||||
|
float(done.body["outputs"][0]["availableToTransfer"]), 50.0)
|
||||||
|
chk.check("fieldValues persisted", done.body["fieldValues"], {"moisture_ok": True})
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- approve
|
||||||
|
chk.section("4. Approve with a partial transfer (FR-MFG-12)")
|
||||||
|
approved = c.post(f"/production-runs/{rid}/stages/{cut_id}/approve",
|
||||||
|
{"transfers": [{"runOutputId": cut_out, "qty": 30}]})
|
||||||
|
chk.status("approve transferring 30 of 50", approved, 200)
|
||||||
|
if approved.status == 200:
|
||||||
|
chk.check("stage now Approved", approved.body["status"], "Approved")
|
||||||
|
chk.check("run still InProgress (non-terminal)", approved.body["runStatus"], "InProgress")
|
||||||
|
chk.check("no receipt on a non-terminal approve", approved.body["receipt"], None)
|
||||||
|
chk.check("one transfer reported", len(approved.body["transfers"]), 1)
|
||||||
|
t = approved.body["transfers"][0]
|
||||||
|
chk.check("transferred 30", float(t["qty"]), 30.0)
|
||||||
|
chk.check("child delivered 30", float(t["childDeliveredQty"]), 30.0)
|
||||||
|
chk.check("child still Waiting (30 < 50 planned)", t["childStatus"], "Waiting")
|
||||||
|
chk.check("remainder held on the stage",
|
||||||
|
float(approved.body["stage"]["outputs"][0]["availableToTransfer"]), 20.0)
|
||||||
|
|
||||||
|
chk.section("5. Transfer the remainder, then over-transfer (FR-MFG-12)")
|
||||||
|
chk.status("transfer 21 (more than the 20 remaining)",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||||
|
{"runOutputId": cut_out, "qty": 21}),
|
||||||
|
422, "TRANSFER_EXCEEDS_AVAILABLE")
|
||||||
|
|
||||||
|
moved = c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||||
|
{"runOutputId": cut_out, "qty": 20})
|
||||||
|
chk.status("transfer the remaining 20", moved, 200)
|
||||||
|
if moved.status == 200:
|
||||||
|
chk.check("child delivered 50", float(moved.body["transfers"][0]["childDeliveredQty"]), 50.0)
|
||||||
|
chk.check("child flipped to Ready (FR-MFG-09)", moved.body["transfers"][0]["childStatus"], "Ready")
|
||||||
|
chk.check("nothing left to transfer",
|
||||||
|
float(moved.body["stage"]["outputs"][0]["availableToTransfer"]), 0.0)
|
||||||
|
|
||||||
|
chk.status("transfer once everything is gone",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||||
|
{"runOutputId": cut_out, "qty": 1}),
|
||||||
|
422, "TRANSFER_EXCEEDS_AVAILABLE")
|
||||||
|
|
||||||
|
# -------------------------------------------------- late stock input start
|
||||||
|
chk.section("6. Start the terminal stage — a late Stock input (FR-MFG-04)")
|
||||||
|
started2 = c.post(f"/production-runs/{rid}/stages/{asm_id}/start")
|
||||||
|
chk.status("start Assemble", started2, 200)
|
||||||
|
if started2.status == 200:
|
||||||
|
chk.check("only the Stock input consumed (upstream is WIP)", len(started2.body["consumed"]), 1)
|
||||||
|
chk.check("packaging consumed 2 x 50", float(started2.body["consumed"][0]["qty"]), 100.0)
|
||||||
|
chk.check("packaging on-hand fell by 100", on_hand(c, pack, wh), pack_before - 100.0)
|
||||||
|
chk.check("no ledger row for the upstream WIP input", len(ledger_rows(c, rid, "PRDI")), 2)
|
||||||
|
|
||||||
|
chk.section("7. Insufficient stock is refused (FR-MFG-10)")
|
||||||
|
big = c.post("/production-runs", {"templateId": tid, "targetQty": 100000, "warehouseId": wh})
|
||||||
|
if big.status == 201:
|
||||||
|
big_cut = next(s for s in big.body["stages"] if s["name"] == "Cut")["runStageId"]
|
||||||
|
chk.status("start a stage needing more than on-hand",
|
||||||
|
c.post(f"/production-runs/{big.body['runId']}/stages/{big_cut}/start"),
|
||||||
|
409, "STOCK_NEGATIVE_BLOCKED")
|
||||||
|
chk.check("on-hand untouched by the failed start", on_hand(c, raw, wh), raw_before - 400.0)
|
||||||
|
else:
|
||||||
|
chk.check("could create the oversized run", big.status, 201)
|
||||||
|
|
||||||
|
chk.section("8. Event history")
|
||||||
|
events = c.get(f"/production-runs/{rid}").body["events"]
|
||||||
|
kinds = [e["eventType"] for e in events]
|
||||||
|
chk.check("Start logged twice", kinds.count("Start"), 2)
|
||||||
|
chk.check("Complete logged once", kinds.count("Complete"), 1)
|
||||||
|
chk.check("Approve logged once", kinds.count("Approve"), 1)
|
||||||
|
chk.check("Transfer logged once", kinds.count("Transfer"), 1)
|
||||||
|
chk.check("no event for the rejected actions", kinds.count("QuantityEdit"), 0)
|
||||||
|
|
||||||
|
# Hand off to M5.
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump({"runId": rid, "templateId": tid, "warehouseId": wh,
|
||||||
|
"assembleStageId": asm_id, "finishedItemId": finished,
|
||||||
|
"rawItemId": raw, "packItemId": pack}, f)
|
||||||
|
print(f"\nwrote {STATE_FILE}; run {rid} has Assemble InProgress for m5_receipt.py")
|
||||||
|
|
||||||
|
return chk.finish("M4")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""M4b smoke test — UOM conversion on production stock inputs.
|
||||||
|
|
||||||
|
This covers the single highest-risk correctness gap in the manufacturing phase.
|
||||||
|
`IFifoCostingService.ConsumeAsync` works exclusively in an item's BASE UOM, while
|
||||||
|
`STAGE_INPUT.uom_id` is a free FK — docs/30 never mentions conversion at all. Without the
|
||||||
|
shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`), a stage input declared in
|
||||||
|
"box of 12" would consume 1 base unit instead of 12 and silently mis-cost the whole run.
|
||||||
|
|
||||||
|
The dev database has no `uom_conversions` rows at all, so the non-base path was previously
|
||||||
|
unexercised by any data. This script creates a real conversion and proves:
|
||||||
|
|
||||||
|
* a stage input in a non-base UOM consumes qtyPerBatch x scaleFactor x factor base units
|
||||||
|
* the ledger records the BASE quantity, not the declared one
|
||||||
|
* an input in a UOM with no conversion defined is refused with 422 rather than mis-consumed
|
||||||
|
|
||||||
|
python Backend/smoke/m4b_uom_conversion.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from smoke_common import bootstrap
|
||||||
|
|
||||||
|
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||||
|
TEMPLATE_CODE = "SMOKE-PT-M4B"
|
||||||
|
FACTOR = 12 # 1 case = 12 base units
|
||||||
|
QTY_PER_BATCH = 3 # cases per batch
|
||||||
|
TARGET_QTY = 10 # -> scale 10 -> 30 cases -> 360 base units
|
||||||
|
SEED = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c, chk, args = bootstrap(__doc__)
|
||||||
|
print(f"API {args.api}")
|
||||||
|
|
||||||
|
# --- fixtures ---------------------------------------------------------
|
||||||
|
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||||
|
if w["code"] == WAREHOUSE_CODE), None)
|
||||||
|
if wh is None:
|
||||||
|
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||||
|
|
||||||
|
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||||
|
raw, finished = items[0], items[1]
|
||||||
|
base_uom = raw["baseUomId"]
|
||||||
|
|
||||||
|
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||||
|
case_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||||
|
if case_uom is None:
|
||||||
|
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
|
||||||
|
print(f"item={raw['itemId']} baseUom={base_uom} caseUom={case_uom} factor={FACTOR}")
|
||||||
|
|
||||||
|
# --- define the conversion -------------------------------------------
|
||||||
|
chk.section("1. Define a non-base UOM conversion for the item")
|
||||||
|
conv = c.put(f"/items/{raw['itemId']}/uom-conversions",
|
||||||
|
{"conversions": [{"fromUom": case_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||||
|
chk.status("PUT /items/{id}/uom-conversions", conv, 200)
|
||||||
|
if conv.status != 200:
|
||||||
|
return chk.finish("M4b")
|
||||||
|
chk.check("conversion stored", any(float(x["factor"]) == FACTOR for x in conv.body["conversions"]), True)
|
||||||
|
|
||||||
|
reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
|
||||||
|
c.post("/stock-adjustments", {"warehouseId": wh, "reasonCodeId": reason,
|
||||||
|
"lines": [{"itemId": raw["itemId"], "qtyDelta": SEED}]})
|
||||||
|
before = float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"])
|
||||||
|
print(f"on-hand before: {before}")
|
||||||
|
|
||||||
|
# --- template whose stock input is declared in CASES ------------------
|
||||||
|
chk.section("2. A stage input declared in the non-base UOM")
|
||||||
|
payload = {
|
||||||
|
"code": TEMPLATE_CODE, "name": "Smoke M4b conversion line",
|
||||||
|
"stages": [{
|
||||||
|
"key": "tmp-only", "name": "Pack", "estimatedMinutes": 10,
|
||||||
|
"posX": 0, "posY": 0, "fieldDefs": [],
|
||||||
|
# Declared in cases, not base units.
|
||||||
|
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||||
|
"uomId": case_uom, "qtyPerBatch": QTY_PER_BATCH}],
|
||||||
|
"outputs": [{"key": "tmp-out", "name": "Packed", "itemId": finished["itemId"],
|
||||||
|
"uomId": base_uom, "qtyPerBatch": 1}],
|
||||||
|
}],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||||
|
if t["code"] == TEMPLATE_CODE), None)
|
||||||
|
if existing:
|
||||||
|
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||||
|
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||||
|
tid = existing["templateId"] if res.status in (200, 409) else None
|
||||||
|
chk.check("template ready", tid is not None, True)
|
||||||
|
else:
|
||||||
|
res = c.post("/production-templates", payload)
|
||||||
|
chk.status("create single-stage template", res, 201)
|
||||||
|
tid = res.body["templateId"] if res.status == 201 else None
|
||||||
|
|
||||||
|
if tid is None:
|
||||||
|
return chk.finish("M4b")
|
||||||
|
|
||||||
|
# A lone stage is both the entry and the terminal — worth asserting explicitly.
|
||||||
|
run = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
|
||||||
|
chk.status("create the run", run, 201)
|
||||||
|
if run.status != 201:
|
||||||
|
return chk.finish("M4b")
|
||||||
|
|
||||||
|
stage = run.body["stages"][0]
|
||||||
|
chk.check("single stage is both entry and terminal",
|
||||||
|
(stage["isEntry"], stage["isTerminal"]), (True, True))
|
||||||
|
chk.check("single stage starts Ready", stage["status"], "Ready")
|
||||||
|
chk.check("plannedQty stays in the DECLARED uom (3 x 10 cases)",
|
||||||
|
float(stage["inputs"][0]["plannedQty"]), float(QTY_PER_BATCH * TARGET_QTY))
|
||||||
|
|
||||||
|
# --- the actual conversion assertion ---------------------------------
|
||||||
|
chk.section("3. Consumption converts cases to base units")
|
||||||
|
expected_base = QTY_PER_BATCH * TARGET_QTY * FACTOR # 3 x 10 x 12 = 360
|
||||||
|
started = c.post(f"/production-runs/{run.body['runId']}/stages/{stage['runStageId']}/start")
|
||||||
|
chk.status("start the stage", started, 200)
|
||||||
|
if started.status != 200:
|
||||||
|
return chk.finish("M4b")
|
||||||
|
|
||||||
|
con = started.body["consumed"][0]
|
||||||
|
chk.check(f"consumed {expected_base} BASE units, not {QTY_PER_BATCH * TARGET_QTY}",
|
||||||
|
float(con["qty"]), float(expected_base))
|
||||||
|
chk.check("on-hand fell by the base quantity",
|
||||||
|
float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||||
|
before - expected_base)
|
||||||
|
|
||||||
|
rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run.body['runId']}&pageSize=50").body["items"]
|
||||||
|
chk.check("one PRDI row", len(rows), 1)
|
||||||
|
if rows:
|
||||||
|
chk.check("ledger qtyBase is the converted quantity", float(rows[0]["qtyBase"]), float(expected_base))
|
||||||
|
|
||||||
|
detail = c.get(f"/production-runs/{run.body['runId']}").body
|
||||||
|
chk.check("consumedQty stored in base units",
|
||||||
|
float(detail["stages"][0]["inputs"][0]["consumedQty"]), float(expected_base))
|
||||||
|
|
||||||
|
# --- missing conversion is refused, not silently mis-consumed --------
|
||||||
|
chk.section("4. An undefined conversion is refused (422), never assumed 1:1")
|
||||||
|
third_uom = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
|
||||||
|
if u["uomId"] not in (base_uom, case_uom)), None)
|
||||||
|
if third_uom is None:
|
||||||
|
chk.check("skipped: need a third UOM", True, True)
|
||||||
|
else:
|
||||||
|
bad = dict(payload)
|
||||||
|
bad["code"] = TEMPLATE_CODE + "-BAD"
|
||||||
|
bad["stages"] = [dict(payload["stages"][0])]
|
||||||
|
bad["stages"][0] = {**payload["stages"][0],
|
||||||
|
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||||
|
"uomId": third_uom, "qtyPerBatch": 1}]}
|
||||||
|
made = c.post("/production-templates", bad)
|
||||||
|
if made.status != 201:
|
||||||
|
head = c.get(f"/production-templates?q={TEMPLATE_CODE}-BAD")
|
||||||
|
tid2 = next((t["templateId"] for t in head.body["items"]
|
||||||
|
if t["code"] == TEMPLATE_CODE + "-BAD"), None)
|
||||||
|
else:
|
||||||
|
tid2 = made.body["templateId"]
|
||||||
|
|
||||||
|
if tid2:
|
||||||
|
run2 = c.post("/production-runs", {"templateId": tid2, "targetQty": 1, "warehouseId": wh})
|
||||||
|
if run2.status == 201:
|
||||||
|
s2 = run2.body["stages"][0]["runStageId"]
|
||||||
|
chk.status("start a stage whose input UOM has no conversion",
|
||||||
|
c.post(f"/production-runs/{run2.body['runId']}/stages/{s2}/start"), 422)
|
||||||
|
else:
|
||||||
|
chk.check("could create the second run", run2.status, 201)
|
||||||
|
|
||||||
|
return chk.finish("M4b")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""M5 smoke test — terminal approve = production receipt + cost pool (FR-MFG-13).
|
||||||
|
|
||||||
|
Three things are proven here, the last two being the ones most likely to be silently wrong:
|
||||||
|
|
||||||
|
A. A terminal approve creates a finished-goods layer costed at costPool / goodQty,
|
||||||
|
posts a PRDR inbound ledger entry, and completes the run.
|
||||||
|
B. Sum-of-ledger reconciliation: PRDR.value == sum(PRDI.value) - sum(PRDL.value) EXACTLY.
|
||||||
|
This is what the `valueOverride` parameter added to PostLedgerAsync exists for — the
|
||||||
|
6 dp unit cost, multiplied out over 100+ units, drifts past the ledger's 4 dp tick.
|
||||||
|
C. A batch/serial-tracked finished item is refused rather than silently receiving
|
||||||
|
untracked stock (docs/30 defines no batch creation on receipt).
|
||||||
|
|
||||||
|
Depends on the run m4_stage_actions.py leaves with its terminal stage InProgress:
|
||||||
|
|
||||||
|
python Backend/smoke/m4_stage_actions.py
|
||||||
|
python Backend/smoke/m5_receipt.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||||
|
|
||||||
|
STATE_FILE = "m4_state.json"
|
||||||
|
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||||
|
SCRAP = 2
|
||||||
|
|
||||||
|
# Part B — the rounding case. The drift only appears when costPool / goodQty needs more than
|
||||||
|
# 6 decimal places, so the numbers are chosen so it does:
|
||||||
|
# consumed = 3.5 x 300 = 1050 base units at 1.234567 -> pool = 1296.29535
|
||||||
|
# unitCost = round(1296.29535 / 300, 6) = 4.320985 (from 4.3209845, away from zero)
|
||||||
|
# naive qty x unitCost = 300 x 4.320985 = 1296.2955 != round(pool, 4) = 1296.2954
|
||||||
|
# Without valueOverride the ledger would carry 1296.2955 and stop reconciling to the pool.
|
||||||
|
BIG_TEMPLATE_CODE = "SMOKE-PT-M5B"
|
||||||
|
BIG_TARGET = 300
|
||||||
|
BIG_QTY_PER_BATCH = 3.5
|
||||||
|
BIG_UNIT_COST = 1.234567
|
||||||
|
|
||||||
|
|
||||||
|
def on_hand(c, item, wh):
|
||||||
|
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||||
|
|
||||||
|
|
||||||
|
def ledger_sum(c, run_id, source):
|
||||||
|
rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||||
|
return sum(float(r["value"]) for r in rows), rows
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c, chk, args = bootstrap(__doc__)
|
||||||
|
print(f"API {args.api}")
|
||||||
|
|
||||||
|
if not os.path.exists(STATE_FILE):
|
||||||
|
sys.exit(f"FATAL: {STATE_FILE} missing — run m4_stage_actions.py first.")
|
||||||
|
state = json.load(open(STATE_FILE))
|
||||||
|
rid, wh = state["runId"], state["warehouseId"]
|
||||||
|
asm_id, finished = state["assembleStageId"], state["finishedItemId"]
|
||||||
|
|
||||||
|
detail = c.get(f"/production-runs/{rid}").body
|
||||||
|
asm = next((s for s in detail["stages"] if s["runStageId"] == asm_id), None)
|
||||||
|
if asm is None:
|
||||||
|
sys.exit(f"FATAL: stage {asm_id} not on run {rid}.")
|
||||||
|
if asm["status"] != "InProgress":
|
||||||
|
sys.exit(f"FATAL: expected the terminal stage InProgress, found {asm['status']} — re-run m4.")
|
||||||
|
|
||||||
|
pool_before = float(detail["costPool"]["net"])
|
||||||
|
fin_before = on_hand(c, finished, wh)
|
||||||
|
print(f"run={rid} costPool={pool_before} finishedOnHand={fin_before}")
|
||||||
|
|
||||||
|
# ------------------------------------------------- complete the terminal
|
||||||
|
chk.section("A1. Complete the terminal stage with scrap (FR-MFG-11)")
|
||||||
|
out_id = asm["outputs"][0]["runOutputId"]
|
||||||
|
scrap_reason = next(r["reasonCodeId"] for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]
|
||||||
|
if r["code"] == "PRD-SCRAP")
|
||||||
|
|
||||||
|
done = c.post(f"/production-runs/{rid}/stages/{asm_id}/complete", {
|
||||||
|
"outputs": [{"runOutputId": out_id, "producedQty": 50, "scrappedQty": SCRAP,
|
||||||
|
"scrapReasonCodeId": scrap_reason}],
|
||||||
|
})
|
||||||
|
chk.status("complete the terminal stage", done, 200)
|
||||||
|
if done.status != 200:
|
||||||
|
return chk.finish("M5")
|
||||||
|
chk.check("stage Done", done.body["status"], "Done")
|
||||||
|
chk.check("scrap recorded", float(done.body["outputs"][0]["scrappedQty"]), float(SCRAP))
|
||||||
|
chk.check("scrap reason recorded", done.body["outputs"][0]["scrapReasonCodeId"], scrap_reason)
|
||||||
|
|
||||||
|
# Scrap is absorbed into the pool, not written off (FR-MFG-11): no extra ledger row.
|
||||||
|
chk.check("scrap posts no ledger entry",
|
||||||
|
float(c.get(f"/production-runs/{rid}").body["costPool"]["net"]), pool_before)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- the receipt
|
||||||
|
chk.section("A2. Terminal approve posts the receipt and completes the run (FR-MFG-13)")
|
||||||
|
good = 50 - SCRAP
|
||||||
|
approved = c.post(f"/production-runs/{rid}/stages/{asm_id}/approve")
|
||||||
|
chk.status("approve the terminal stage", approved, 200)
|
||||||
|
if approved.status != 200:
|
||||||
|
return chk.finish("M5")
|
||||||
|
|
||||||
|
chk.check("stage Approved", approved.body["status"], "Approved")
|
||||||
|
chk.check("run Completed", approved.body["runStatus"], "Completed")
|
||||||
|
chk.check("no WIP transfers from a terminal stage", approved.body["transfers"], [])
|
||||||
|
|
||||||
|
receipt = approved.body["receipt"]
|
||||||
|
chk.check("receipt returned", receipt is not None, True)
|
||||||
|
if receipt:
|
||||||
|
chk.check("receipt is for the finished item", receipt["itemId"], finished)
|
||||||
|
chk.check("received the good quantity (produced - scrapped)",
|
||||||
|
float(receipt["qtyReceived"]), float(good))
|
||||||
|
chk.check("receipt warehouse is the run warehouse", receipt["warehouseId"], wh)
|
||||||
|
chk.check("layer created", receipt["layerId"] > 0, True)
|
||||||
|
|
||||||
|
pool = approved.body["costPool"]
|
||||||
|
chk.check("cost pool reported", pool is not None, True)
|
||||||
|
if pool and receipt:
|
||||||
|
chk.check("pool net = consumed - returned",
|
||||||
|
round(float(pool["net"]), 4),
|
||||||
|
round(float(pool["consumed"]) - float(pool["returned"]), 4))
|
||||||
|
chk.check("pool net matches the pool before approval", float(pool["net"]), pool_before)
|
||||||
|
expected_unit = round(float(pool["net"]) / float(good), 6)
|
||||||
|
chk.check("unitCost = costPool / goodQty", float(receipt["unitCost"]), expected_unit)
|
||||||
|
chk.check("receipt value = the cost pool exactly",
|
||||||
|
float(receipt["value"]), round(float(pool["net"]), 4))
|
||||||
|
|
||||||
|
chk.check("finished on-hand rose by the good quantity", on_hand(c, finished, wh), fin_before + good)
|
||||||
|
|
||||||
|
completed = c.get(f"/production-runs/{rid}").body
|
||||||
|
chk.check("completedAt stamped", completed["completedAt"] is not None, True)
|
||||||
|
chk.check("run status persisted as Completed", completed["status"], "Completed")
|
||||||
|
|
||||||
|
# ------------------------------------------------- ledger reconciliation
|
||||||
|
chk.section("A3. Ledger reconciles to the cost pool")
|
||||||
|
issued, prdi = ledger_sum(c, rid, "PRDI")
|
||||||
|
returned, _ = ledger_sum(c, rid, "PRDL")
|
||||||
|
received, prdr = ledger_sum(c, rid, "PRDR")
|
||||||
|
chk.check("one PRDR row", len(prdr), 1)
|
||||||
|
if prdr:
|
||||||
|
chk.check("PRDR direction is In", prdr[0]["direction"], "In")
|
||||||
|
chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) [the core invariant]",
|
||||||
|
round(issued - returned, 4), round(received, 4))
|
||||||
|
print(f" issued={issued} returned={returned} received={received}")
|
||||||
|
|
||||||
|
# --------------------------------------------------- closed-run guards
|
||||||
|
chk.section("A4. A completed run is closed to further action")
|
||||||
|
chk.status("approve again", c.post(f"/production-runs/{rid}/stages/{asm_id}/approve"), 409)
|
||||||
|
chk.status("start a stage on a completed run",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{state['assembleStageId']}/start"), 409)
|
||||||
|
chk.status("edit quantities on a completed run",
|
||||||
|
c.put(f"/production-runs/{rid}/stages/{asm_id}/quantities", {"inputs": [], "outputs": []}), 409)
|
||||||
|
|
||||||
|
# ------------------------------------ the rounding case (>= 100 units)
|
||||||
|
chk.section("B. Rounding: 300 units, where a 6 dp unit cost drifts past the 4 dp ledger tick")
|
||||||
|
raw = state["rawItemId"]
|
||||||
|
raw_uom = next(i["baseUomId"] for i in c.get("/items?pageSize=200").body["items"]
|
||||||
|
if i["itemId"] == raw)
|
||||||
|
finished_b = state["finishedItemId"]
|
||||||
|
|
||||||
|
# A fresh single-stage template: base-UOM input so no conversion muddies the arithmetic,
|
||||||
|
# qtyPerBatch 3.5 so the consumed quantity is NOT a multiple of the target (which is what
|
||||||
|
# forces pool / goodQty to repeat).
|
||||||
|
payload_b = {
|
||||||
|
"code": BIG_TEMPLATE_CODE, "name": "Smoke M5 rounding line",
|
||||||
|
"stages": [{
|
||||||
|
"key": "tmp-b", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0,
|
||||||
|
"fieldDefs": [],
|
||||||
|
"inputs": [{"source": "Stock", "itemId": raw, "uomId": raw_uom,
|
||||||
|
"qtyPerBatch": BIG_QTY_PER_BATCH}],
|
||||||
|
"outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b,
|
||||||
|
"uomId": raw_uom, "qtyPerBatch": 1}],
|
||||||
|
}],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
existing_b = next((t for t in c.get(f"/production-templates?q={BIG_TEMPLATE_CODE}").body["items"]
|
||||||
|
if t["code"] == BIG_TEMPLATE_CODE), None)
|
||||||
|
if existing_b:
|
||||||
|
hb = c.get(f"/production-templates/{existing_b['templateId']}")
|
||||||
|
rb = c.put(f"/production-templates/{existing_b['templateId']}", payload_b, if_match=hb.etag)
|
||||||
|
tid = existing_b["templateId"] if rb.status in (200, 409) else None
|
||||||
|
else:
|
||||||
|
rb = c.post("/production-templates", payload_b)
|
||||||
|
tid = rb.body["templateId"] if rb.status == 201 else None
|
||||||
|
|
||||||
|
if tid is None:
|
||||||
|
chk.check("create the rounding template", False, True)
|
||||||
|
else:
|
||||||
|
# Drain first, then seed at the exact unit cost the arithmetic above assumes. Without
|
||||||
|
# the drain, FIFO would consume whatever earlier scripts left behind (at their costs)
|
||||||
|
# and the pool would not match the figures this section reasons about — the assertions
|
||||||
|
# would still "pass" while testing something else entirely.
|
||||||
|
drain_stock(c, wh)
|
||||||
|
seed_costed_stock(c, wh, [(raw, raw_uom, 5000, BIG_UNIT_COST)])
|
||||||
|
|
||||||
|
big = c.post("/production-runs", {"templateId": tid, "targetQty": BIG_TARGET, "warehouseId": wh})
|
||||||
|
if big.status != 201:
|
||||||
|
chk.check(f"create the {BIG_TARGET}-unit run", big.status, 201)
|
||||||
|
else:
|
||||||
|
brid = big.body["runId"]
|
||||||
|
bstage = big.body["stages"][0]
|
||||||
|
bout = bstage["outputs"][0]["runOutputId"]
|
||||||
|
|
||||||
|
s = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/start")
|
||||||
|
chk.status("start", s, 200)
|
||||||
|
d = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": bout, "producedQty": BIG_TARGET, "scrappedQty": 0}]})
|
||||||
|
chk.status("complete", d, 200)
|
||||||
|
a = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/approve")
|
||||||
|
chk.status("approve (single stage is the terminal)", a, 200)
|
||||||
|
|
||||||
|
if a.status == 200:
|
||||||
|
bpool = float(a.body["costPool"]["net"])
|
||||||
|
brec = a.body["receipt"]
|
||||||
|
unit = float(brec["unitCost"])
|
||||||
|
naive = round(unit * BIG_TARGET, 4)
|
||||||
|
chk.check("cost pool is non-zero", bpool > 0, True)
|
||||||
|
chk.check("receipt value == cost pool exactly", float(brec["value"]), round(bpool, 4))
|
||||||
|
print(f" pool={bpool} unitCost={unit} naive qty*unit={naive}")
|
||||||
|
# This is the assertion that justifies the valueOverride parameter existing.
|
||||||
|
# If the naive product ever equals the pool, the fixture stopped exercising
|
||||||
|
# the rounding path and the test has quietly gone blind.
|
||||||
|
chk.check("naive qty x unitCost really would have drifted (fixture still valid)",
|
||||||
|
naive != round(bpool, 4), True)
|
||||||
|
|
||||||
|
bissued, _ = ledger_sum(c, brid, "PRDI")
|
||||||
|
breturned, _ = ledger_sum(c, brid, "PRDL")
|
||||||
|
breceived, _ = ledger_sum(c, brid, "PRDR")
|
||||||
|
chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) at 300 units",
|
||||||
|
round(bissued - breturned, 4), round(breceived, 4))
|
||||||
|
|
||||||
|
# ------------------------------------------- tracked finished goods
|
||||||
|
chk.section("C. A batch/serial-tracked finished item is refused")
|
||||||
|
tracked = next((i for i in c.get("/items?pageSize=200&status=Active").body["items"]
|
||||||
|
if i.get("trackingMode") in ("Batch", "Serial")), None)
|
||||||
|
if tracked is None:
|
||||||
|
chk.check("skipped: no batch/serial-tracked item in the database", True, True)
|
||||||
|
print(" NOTE: the TrackingMode guard in PostReceiptAsync is unexercised here.")
|
||||||
|
else:
|
||||||
|
print(f" using tracked item {tracked['itemId']} ({tracked['trackingMode']})")
|
||||||
|
payload = {
|
||||||
|
"code": "SMOKE-PT-TRACKED", "name": "Tracked finished good",
|
||||||
|
"stages": [{
|
||||||
|
"key": "tmp-one", "name": "Make", "estimatedMinutes": 1, "posX": 0, "posY": 0,
|
||||||
|
"fieldDefs": [], "inputs": [],
|
||||||
|
"outputs": [{"key": "tmp-o", "name": "Tracked", "itemId": tracked["itemId"],
|
||||||
|
"uomId": tracked["baseUomId"], "qtyPerBatch": 1}],
|
||||||
|
}],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
made = c.post("/production-templates", payload)
|
||||||
|
ttid = made.body["templateId"] if made.status == 201 else next(
|
||||||
|
(t["templateId"] for t in c.get("/production-templates?q=SMOKE-PT-TRACKED").body["items"]
|
||||||
|
if t["code"] == "SMOKE-PT-TRACKED"), None)
|
||||||
|
if ttid:
|
||||||
|
tr = c.post("/production-runs", {"templateId": ttid, "targetQty": 1, "warehouseId": wh})
|
||||||
|
if tr.status == 201:
|
||||||
|
st = tr.body["stages"][0]["runStageId"]
|
||||||
|
to = tr.body["stages"][0]["outputs"][0]["runOutputId"]
|
||||||
|
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/start")
|
||||||
|
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/complete",
|
||||||
|
{"outputs": [{"runOutputId": to, "producedQty": 1, "scrappedQty": 0}]})
|
||||||
|
chk.status("approve a tracked finished good",
|
||||||
|
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/approve"), 422)
|
||||||
|
|
||||||
|
return chk.finish("M5")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""M6 + M7 smoke test — leftover return, reject-intake, terminal reject, run cancel.
|
||||||
|
|
||||||
|
Covers FR-MFG-14 (leftover return at the consumed weighted cost), FR-MFG-15 (downstream
|
||||||
|
reject pulls work back to the parent), FR-MFG-16 (terminal reject resets the run for a
|
||||||
|
rework pass) and FR-MFG-17 (cancel returns net consumed stock).
|
||||||
|
|
||||||
|
Self-contained: builds its own two-stage template and three separate runs, and drains the
|
||||||
|
warehouse first so FIFO costs are known. Run after m4 so the warehouse exists:
|
||||||
|
|
||||||
|
python Backend/smoke/m4_stage_actions.py
|
||||||
|
python Backend/smoke/m6_m7_leftover_rework_cancel.py
|
||||||
|
|
||||||
|
The assertions that matter most and are easiest to get silently wrong:
|
||||||
|
* a FULL leftover return must leave returnedValue == consumedValue EXACTLY (no crumb)
|
||||||
|
* reject-intake must DECREMENT the parent's transferredQty, not zero it
|
||||||
|
* a terminal reject must PRESERVE consumedQty/consumedValue and plannedQty
|
||||||
|
* a re-complete after rework must OVERWRITE producedQty, not add to it
|
||||||
|
* a rework restart with an unchanged planned qty must consume NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||||
|
|
||||||
|
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||||
|
TEMPLATE_CODE = "SMOKE-PT-M67"
|
||||||
|
TARGET = 10
|
||||||
|
RAW_QPB = 5 # 5 raw per batch -> 50 base units at target 10
|
||||||
|
UNIT_COST = 3.0
|
||||||
|
SEED = 4000
|
||||||
|
|
||||||
|
|
||||||
|
def on_hand(c, item, wh):
|
||||||
|
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||||
|
|
||||||
|
|
||||||
|
def ledger(c, run_id, source):
|
||||||
|
return c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||||
|
|
||||||
|
|
||||||
|
def prod_reason(c, code):
|
||||||
|
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
|
||||||
|
if r["code"] == code:
|
||||||
|
return r["reasonCodeId"]
|
||||||
|
sys.exit(f"FATAL: Production reason {code} not seeded.")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_template(c, raw, finished, uom):
|
||||||
|
"""Cut (entry) -> Assemble (terminal). Cut has the stock input we return leftovers from."""
|
||||||
|
payload = {
|
||||||
|
"code": TEMPLATE_CODE, "name": "Smoke M6/M7 line",
|
||||||
|
"stages": [
|
||||||
|
{"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0,
|
||||||
|
"fieldDefs": [],
|
||||||
|
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": RAW_QPB}],
|
||||||
|
"outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]},
|
||||||
|
{"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0,
|
||||||
|
"fieldDefs": [],
|
||||||
|
"inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "uomId": uom, "qtyPerBatch": 1}],
|
||||||
|
"outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished,
|
||||||
|
"uomId": uom, "qtyPerBatch": 1}]},
|
||||||
|
],
|
||||||
|
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||||
|
}
|
||||||
|
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||||
|
if t["code"] == TEMPLATE_CODE), None)
|
||||||
|
if existing:
|
||||||
|
h = c.get(f"/production-templates/{existing['templateId']}")
|
||||||
|
r = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=h.etag)
|
||||||
|
if r.status in (200, 409):
|
||||||
|
return existing["templateId"]
|
||||||
|
sys.exit(f"FATAL: could not update the template: {r.status} {r.body}")
|
||||||
|
r = c.post("/production-templates", payload)
|
||||||
|
if r.status != 201:
|
||||||
|
sys.exit(f"FATAL: could not create the template: {r.status} {r.body}")
|
||||||
|
return r.body["templateId"]
|
||||||
|
|
||||||
|
|
||||||
|
def new_run(c, tid, wh):
|
||||||
|
r = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET, "warehouseId": wh})
|
||||||
|
if r.status != 201:
|
||||||
|
sys.exit(f"FATAL: could not create a run: {r.status} {r.body}")
|
||||||
|
cut = next(s for s in r.body["stages"] if s["name"] == "Cut")
|
||||||
|
asm = next(s for s in r.body["stages"] if s["name"] == "Assemble")
|
||||||
|
return r.body, cut, asm
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c, chk, args = bootstrap(__doc__)
|
||||||
|
print(f"API {args.api}")
|
||||||
|
|
||||||
|
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||||
|
if w["code"] == WAREHOUSE_CODE), None)
|
||||||
|
if wh is None:
|
||||||
|
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||||
|
|
||||||
|
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||||
|
raw, finished = items[0]["itemId"], items[1]["itemId"]
|
||||||
|
uom = items[0]["baseUomId"]
|
||||||
|
|
||||||
|
drain_stock(c, wh)
|
||||||
|
seed_costed_stock(c, wh, [(raw, uom, SEED, UNIT_COST)])
|
||||||
|
tid = ensure_template(c, raw, finished, uom)
|
||||||
|
consumed_units = RAW_QPB * TARGET # 50
|
||||||
|
consumed_value = consumed_units * UNIT_COST # 150.00
|
||||||
|
print(f"warehouse={wh} raw={raw} unitCost={UNIT_COST} consumesPerRun={consumed_units}")
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# M6 — leftover return
|
||||||
|
# =====================================================================
|
||||||
|
chk.section("M6-1. Partial leftover return at the consumed weighted cost (FR-MFG-14)")
|
||||||
|
run, cut, asm = new_run(c, tid, wh)
|
||||||
|
rid = run["runId"]
|
||||||
|
cut_in = cut["inputs"][0]["runInputId"]
|
||||||
|
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/start")
|
||||||
|
before = on_hand(c, raw, wh)
|
||||||
|
pool0 = float(c.get(f"/production-runs/{rid}").body["costPool"]["net"])
|
||||||
|
chk.check("cost pool after start", pool0, consumed_value)
|
||||||
|
|
||||||
|
ret = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||||
|
{"qty": 5, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
|
||||||
|
chk.status("return 5 of 50 consumed", ret, 200)
|
||||||
|
if ret.status == 200:
|
||||||
|
chk.check("returnedQty echoed", float(ret.body["returnedQty"]), 5.0)
|
||||||
|
chk.check("returned at the consumed weighted cost",
|
||||||
|
float(ret.body["createdLayer"]["unitCost"]), UNIT_COST)
|
||||||
|
chk.check("returnedValue = qty x weighted cost", float(ret.body["returnedValue"]), 15.0)
|
||||||
|
chk.check("layer id populated", ret.body["createdLayer"]["layerId"] > 0, True)
|
||||||
|
chk.check("pool reduced by the returned value",
|
||||||
|
float(ret.body["costPool"]["net"]), consumed_value - 15.0)
|
||||||
|
chk.check("on-hand rose by the returned qty", on_hand(c, raw, wh), before + 5.0)
|
||||||
|
|
||||||
|
prdl = ledger(c, rid, "PRDL")
|
||||||
|
chk.check("one PRDL row", len(prdl), 1)
|
||||||
|
if prdl:
|
||||||
|
chk.check("PRDL direction is In", prdl[0]["direction"], "In")
|
||||||
|
chk.check("PRDL has no bin (raw material, not the output bin)", prdl[0]["binId"], None)
|
||||||
|
|
||||||
|
chk.section("M6-2. Guards")
|
||||||
|
chk.status("return more than remains",
|
||||||
|
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||||
|
{"qty": 46, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||||
|
422, "LEFTOVER_EXCEEDS_CONSUMED")
|
||||||
|
chk.status("return with no reason code",
|
||||||
|
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", {"qty": 1}),
|
||||||
|
400, "REASON_CODE_REQUIRED")
|
||||||
|
adj_reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
|
||||||
|
chk.status("return with an Adjustment-context reason",
|
||||||
|
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||||
|
{"qty": 1, "reasonCodeId": adj_reason}),
|
||||||
|
422)
|
||||||
|
chk.status("return against an upstream (WIP) input",
|
||||||
|
c.post(f"/production-runs/{rid}/inputs/{asm['inputs'][0]['runInputId']}/return-leftover",
|
||||||
|
{"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||||
|
422)
|
||||||
|
|
||||||
|
chk.section("M6-3. A FULL return must net the input to exactly zero")
|
||||||
|
rest = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||||
|
{"qty": 45, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
|
||||||
|
chk.status("return the remaining 45", rest, 200)
|
||||||
|
if rest.status == 200:
|
||||||
|
chk.check("pool is exactly zero after a full return", float(rest.body["costPool"]["net"]), 0.0)
|
||||||
|
detail = c.get(f"/production-runs/{rid}").body
|
||||||
|
ci = detail["stages"][0]["inputs"][0]
|
||||||
|
chk.check("returnedQty == consumedQty exactly",
|
||||||
|
float(ci["returnedQty"]), float(ci["consumedQty"]))
|
||||||
|
chk.check("returnedValue == consumedValue exactly",
|
||||||
|
float(ci["returnedValue"]), float(ci["consumedValue"]))
|
||||||
|
|
||||||
|
# Close this run out so the RUN_COST_CLOSED guard can be checked. Each step is asserted:
|
||||||
|
# letting an intermediate call fail silently here previously made the *next* assertion
|
||||||
|
# look like the bug.
|
||||||
|
cut_out = next(s for s in detail["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
|
||||||
|
chk.status("close-out: complete Cut",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
|
||||||
|
chk.status("close-out: approve Cut",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/approve"), 200)
|
||||||
|
asm_out = next(s for s in c.get(f"/production-runs/{rid}").body["stages"]
|
||||||
|
if s["name"] == "Assemble")["outputs"][0]["runOutputId"]
|
||||||
|
chk.status("close-out: start Assemble",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/start"), 200)
|
||||||
|
chk.status("close-out: complete Assemble",
|
||||||
|
c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": asm_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
|
||||||
|
fin = c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/approve")
|
||||||
|
chk.status("close-out: approve the terminal", fin, 200)
|
||||||
|
if fin.status == 200:
|
||||||
|
chk.check("run completed even though the pool was fully returned (cost 0)",
|
||||||
|
fin.body["runStatus"], "Completed")
|
||||||
|
chk.check("zero-cost receipt still creates a layer", fin.body["receipt"]["layerId"] > 0, True)
|
||||||
|
chk.status("return a leftover after the receipt closed the pool",
|
||||||
|
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||||
|
{"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||||
|
409, "RUN_COST_CLOSED")
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# M7a — reject-intake
|
||||||
|
# =====================================================================
|
||||||
|
chk.section("M7-1. Reject-intake pulls work back to the parent (FR-MFG-15)")
|
||||||
|
run2, cut2, asm2 = new_run(c, tid, wh)
|
||||||
|
rid2 = run2["runId"]
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
|
||||||
|
d2 = c.get(f"/production-runs/{rid2}").body
|
||||||
|
cut2_out = next(s for s in d2["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||||
|
start_at = next(s for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||||
|
if s["name"] == "Cut")["actualStartAt"]
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")
|
||||||
|
|
||||||
|
after_approve = c.get(f"/production-runs/{rid2}").body
|
||||||
|
a_cut = next(s for s in after_approve["stages"] if s["name"] == "Cut")
|
||||||
|
a_asm = next(s for s in after_approve["stages"] if s["name"] == "Assemble")
|
||||||
|
chk.check("child Ready before the reject", a_asm["status"], "Ready")
|
||||||
|
chk.check("parent transferred the full quantity",
|
||||||
|
float(a_cut["outputs"][0]["transferredQty"]), float(TARGET))
|
||||||
|
|
||||||
|
rej = c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake",
|
||||||
|
{"note": "Frames warped"})
|
||||||
|
chk.status("reject-intake on the Ready child", rej, 200)
|
||||||
|
if rej.status == 200:
|
||||||
|
chk.check("rejecting stage back to Waiting", rej.body["status"], "Waiting")
|
||||||
|
chk.check("one parent pulled back", len(rej.body["pulledBack"]), 1)
|
||||||
|
pb = rej.body["pulledBack"][0]
|
||||||
|
chk.check("pulled-back qty", float(pb["qty"]), float(TARGET))
|
||||||
|
chk.check("parent was Approved", pb["priorParentStatus"], "Approved")
|
||||||
|
chk.check("parent reverted to InProgress", pb["parentStatus"], "InProgress")
|
||||||
|
|
||||||
|
r_cut = next(s for s in rej.body["run"]["stages"] if s["name"] == "Cut")
|
||||||
|
r_asm = next(s for s in rej.body["run"]["stages"] if s["name"] == "Assemble")
|
||||||
|
chk.check("parent transferredQty decremented to 0",
|
||||||
|
float(r_cut["outputs"][0]["transferredQty"]), 0.0)
|
||||||
|
chk.check("parent available to transfer restored",
|
||||||
|
float(r_cut["outputs"][0]["availableToTransfer"]), float(TARGET))
|
||||||
|
chk.check("child deliveredQty cleared", float(r_asm["inputs"][0]["deliveredQty"]), 0.0)
|
||||||
|
chk.check("parent ActualStartAt PRESERVED (FR-MFG-19)", r_cut["actualStartAt"], start_at)
|
||||||
|
chk.check("parent ActualEndAt cleared for rework", r_cut["actualEndAt"], None)
|
||||||
|
chk.check("consumed stock stays consumed",
|
||||||
|
float(r_cut["inputs"][0]["consumedQty"]), float(consumed_units))
|
||||||
|
|
||||||
|
chk.status("reject-intake again with nothing delivered",
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake", {}),
|
||||||
|
409, "STAGE_REJECT_INVALID")
|
||||||
|
|
||||||
|
chk.section("M7-2. Re-complete OVERWRITES rather than accumulating")
|
||||||
|
re_done = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||||
|
chk.status("re-complete the parent", re_done, 200)
|
||||||
|
if re_done.status == 200:
|
||||||
|
chk.check("producedQty overwritten, not doubled",
|
||||||
|
float(re_done.body["outputs"][0]["producedQty"]), float(TARGET))
|
||||||
|
re_app = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")
|
||||||
|
chk.status("re-approve the parent", re_app, 200)
|
||||||
|
if re_app.status == 200:
|
||||||
|
chk.check("child Ready again",
|
||||||
|
next(s for s in re_app.body["stage"]["outputs"]
|
||||||
|
for _ in [0])["transferredQty"] is not None, True)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# M7b — terminal reject
|
||||||
|
# =====================================================================
|
||||||
|
chk.section("M7-3. Terminal reject resets the run for a rework pass (FR-MFG-16)")
|
||||||
|
d3 = c.get(f"/production-runs/{rid2}").body
|
||||||
|
asm3 = next(s for s in d3["stages"] if s["name"] == "Assemble")
|
||||||
|
asm3_out = asm3["outputs"][0]["runOutputId"]
|
||||||
|
pool_before = float(d3["costPool"]["net"])
|
||||||
|
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/start")
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": asm3_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||||
|
|
||||||
|
trj = c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/reject",
|
||||||
|
{"note": "Final QA failed batch"})
|
||||||
|
chk.status("terminal reject", trj, 200)
|
||||||
|
if trj.status == 200:
|
||||||
|
chk.check("reworkCount incremented", trj.body["reworkCount"], 1)
|
||||||
|
run_after = trj.body["run"]
|
||||||
|
chk.check("run still InProgress", run_after["status"], "InProgress")
|
||||||
|
chk.check("completedAt still null", run_after["completedAt"], None)
|
||||||
|
|
||||||
|
t_cut = next(s for s in run_after["stages"] if s["name"] == "Cut")
|
||||||
|
t_asm = next(s for s in run_after["stages"] if s["name"] == "Assemble")
|
||||||
|
chk.check("entry stage reset to Ready", t_cut["status"], "Ready")
|
||||||
|
chk.check("non-entry stage reset to Waiting", t_asm["status"], "Waiting")
|
||||||
|
chk.check("timings cleared", (t_cut["actualStartAt"], t_cut["actualEndAt"]), (None, None))
|
||||||
|
chk.check("fieldValues cleared", t_cut["fieldValues"], None)
|
||||||
|
chk.check("producedQty cleared", float(t_cut["outputs"][0]["producedQty"]), 0.0)
|
||||||
|
chk.check("transferredQty cleared", float(t_cut["outputs"][0]["transferredQty"]), 0.0)
|
||||||
|
chk.check("deliveredQty cleared", float(t_asm["inputs"][0]["deliveredQty"]), 0.0)
|
||||||
|
|
||||||
|
# The load-bearing half of FR-MFG-16.
|
||||||
|
chk.check("plannedQty PRESERVED", float(t_cut["inputs"][0]["plannedQty"]), float(consumed_units))
|
||||||
|
chk.check("consumedQty PRESERVED", float(t_cut["inputs"][0]["consumedQty"]), float(consumed_units))
|
||||||
|
chk.check("cost pool PRESERVED across the rework", float(run_after["costPool"]["net"]), pool_before)
|
||||||
|
|
||||||
|
snap = [e for e in run_after["events"] if e["eventType"] == "TerminalReject"]
|
||||||
|
chk.check("exactly one snapshot event", len(snap), 1)
|
||||||
|
if snap:
|
||||||
|
chk.check("snapshot records the rework number", snap[0]["payload"]["reworkNumber"], 1)
|
||||||
|
chk.check("snapshot captured every stage", len(snap[0]["payload"]["stages"]), 2)
|
||||||
|
chk.check("snapshot kept the pre-reset produced figure",
|
||||||
|
any(float(o["producedQty"]) == TARGET
|
||||||
|
for s in snap[0]["payload"]["stages"] for o in s["outputs"]), True)
|
||||||
|
chk.check("reject note recorded", snap[0]["note"], "Final QA failed batch")
|
||||||
|
|
||||||
|
# This is the single behavioural rule that makes FR-MFG-16 work: a start always consumes
|
||||||
|
# max(0, plannedBase - consumedQty), never the full planned figure. After a rework the
|
||||||
|
# material is still in the pool, so re-consuming it would double-charge the run.
|
||||||
|
chk.section("M7-4. Rework restart after RAISING the planned qty consumes only the delta")
|
||||||
|
cut2_in = next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||||
|
for i in s["inputs"] if s["name"] == "Cut")["runInputId"]
|
||||||
|
raised = consumed_units + 10
|
||||||
|
chk.status("raise the planned qty on the reset (Ready) stage",
|
||||||
|
c.put(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/quantities",
|
||||||
|
{"inputs": [{"id": cut2_in, "plannedQty": raised}], "outputs": []}), 200)
|
||||||
|
|
||||||
|
oh_before = on_hand(c, raw, wh)
|
||||||
|
pool_pre = float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"])
|
||||||
|
restart = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
|
||||||
|
chk.status("restart after the raise", restart, 200)
|
||||||
|
if restart.status == 200:
|
||||||
|
chk.check("consumed ONLY the 10-unit delta, not the full 60",
|
||||||
|
float(restart.body["consumed"][0]["qty"]), 10.0)
|
||||||
|
chk.check("on-hand fell by only the delta", on_hand(c, raw, wh), oh_before - 10.0)
|
||||||
|
chk.check("consumedQty accumulated to the new planned total",
|
||||||
|
float(next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||||
|
for i in s["inputs"] if i["runInputId"] == cut2_in)["consumedQty"]),
|
||||||
|
float(raised))
|
||||||
|
chk.check("pool grew by only the delta's value",
|
||||||
|
float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"]),
|
||||||
|
pool_pre + 10.0 * UNIT_COST)
|
||||||
|
|
||||||
|
chk.section("M7-5. A second rework, restarted UNCHANGED, consumes nothing at all")
|
||||||
|
# Drive the run round again to get the entry stage back to Ready with consumed == planned.
|
||||||
|
d5 = c.get(f"/production-runs/{rid2}").body
|
||||||
|
c5 = next(s for s in d5["stages"] if s["name"] == "Cut")
|
||||||
|
a5 = next(s for s in d5["stages"] if s["name"] == "Assemble")
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": c5["outputs"][0]["runOutputId"],
|
||||||
|
"producedQty": TARGET, "scrappedQty": 0}]})
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/approve")
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/start")
|
||||||
|
c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/complete",
|
||||||
|
{"outputs": [{"runOutputId": a5["outputs"][0]["runOutputId"],
|
||||||
|
"producedQty": TARGET, "scrappedQty": 0}]})
|
||||||
|
second = c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/reject", {"note": "again"})
|
||||||
|
chk.status("second terminal reject", second, 200)
|
||||||
|
if second.status == 200:
|
||||||
|
chk.check("reworkCount now 2", second.body["reworkCount"], 2)
|
||||||
|
chk.check("two snapshot events retained",
|
||||||
|
sum(1 for e in second.body["run"]["events"] if e["eventType"] == "TerminalReject"), 2)
|
||||||
|
|
||||||
|
oh2 = on_hand(c, raw, wh)
|
||||||
|
prdi2 = len(ledger(c, rid2, "PRDI"))
|
||||||
|
again = c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/start")
|
||||||
|
chk.status("restart with planned unchanged", again, 200)
|
||||||
|
if again.status == 200:
|
||||||
|
chk.check("nothing consumed (delta is zero)", len(again.body["consumed"]), 0)
|
||||||
|
chk.check("on-hand unchanged", on_hand(c, raw, wh), oh2)
|
||||||
|
chk.check("no new PRDI ledger row", len(ledger(c, rid2, "PRDI")), prdi2)
|
||||||
|
|
||||||
|
chk.section("M7-6. Cancel returns net consumed stock (FR-MFG-17)")
|
||||||
|
run4, cut4, _ = new_run(c, tid, wh)
|
||||||
|
rid4 = run4["runId"]
|
||||||
|
in4 = cut4["inputs"][0]["runInputId"]
|
||||||
|
c.post(f"/production-runs/{rid4}/stages/{cut4['runStageId']}/start")
|
||||||
|
first = float(next(i for s in c.get(f"/production-runs/{rid4}").body["stages"]
|
||||||
|
for i in s["inputs"] if i["runInputId"] == in4)["consumedQty"])
|
||||||
|
chk.check("first start consumed the full planned qty", first, float(consumed_units))
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# M7c — cancel
|
||||||
|
# =====================================================================
|
||||||
|
oh_pre_cancel = on_hand(c, raw, wh)
|
||||||
|
cancelled = c.post(f"/production-runs/{rid4}/cancel",
|
||||||
|
{"reasonCodeId": prod_reason(c, "PRD-CANCEL"), "note": "Order cancelled"})
|
||||||
|
chk.status("cancel the run", cancelled, 200)
|
||||||
|
if cancelled.status == 200:
|
||||||
|
chk.check("run Cancelled", cancelled.body["status"], "Cancelled")
|
||||||
|
chk.check("one return posted", len(cancelled.body["returns"]), 1)
|
||||||
|
r0 = cancelled.body["returns"][0]
|
||||||
|
chk.check("returned the net consumed qty", float(r0["qty"]), float(consumed_units))
|
||||||
|
chk.check("returned at the consumed weighted cost", float(r0["unitCost"]), UNIT_COST)
|
||||||
|
chk.check("layer id populated", r0["layerId"] > 0, True)
|
||||||
|
chk.check("ledgerRefs populated", len(cancelled.body["ledgerRefs"]), 1)
|
||||||
|
chk.check("on-hand restored", on_hand(c, raw, wh), oh_pre_cancel + consumed_units)
|
||||||
|
|
||||||
|
prdc = ledger(c, rid4, "PRDC")
|
||||||
|
chk.check("one PRDC row", len(prdc), 1)
|
||||||
|
if prdc:
|
||||||
|
chk.check("PRDC direction is In", prdc[0]["direction"], "In")
|
||||||
|
chk.check("PRDC value = the exact consumed residual",
|
||||||
|
float(prdc[0]["value"]), consumed_units * UNIT_COST)
|
||||||
|
|
||||||
|
after_cancel = c.get(f"/production-runs/{rid4}").body
|
||||||
|
chk.check("cancel reason recorded", after_cancel["cancelReasonCodeId"] is not None, True)
|
||||||
|
chk.check("completedAt stays null on a cancel", after_cancel["completedAt"], None)
|
||||||
|
chk.check("pool nets to zero after the cancel return",
|
||||||
|
float(after_cancel["costPool"]["net"]), 0.0)
|
||||||
|
chk.check("cancel event logged",
|
||||||
|
any(e["eventType"] == "Cancel" for e in after_cancel["events"]), True)
|
||||||
|
|
||||||
|
chk.section("M7-7. Cancel guards")
|
||||||
|
chk.status("cancel an already-cancelled run",
|
||||||
|
c.post(f"/production-runs/{rid4}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
|
||||||
|
409, "RUN_NOT_CANCELLABLE")
|
||||||
|
chk.status("cancel a COMPLETED run",
|
||||||
|
c.post(f"/production-runs/{rid}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
|
||||||
|
409, "RUN_NOT_CANCELLABLE")
|
||||||
|
run5, _, _ = new_run(c, tid, wh)
|
||||||
|
chk.status("cancel with no reason code",
|
||||||
|
c.post(f"/production-runs/{run5['runId']}/cancel", {}), 400, "REASON_CODE_REQUIRED")
|
||||||
|
|
||||||
|
return chk.finish("M6+M7")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Run the whole manufacturing smoke suite in dependency order.
|
||||||
|
|
||||||
|
python Backend/smoke/run_all.py
|
||||||
|
|
||||||
|
Order matters: m4 creates the isolated SMOKE-PRD warehouse and drains it, m5 consumes the
|
||||||
|
run m4 leaves with its terminal stage InProgress. Each script is individually re-runnable,
|
||||||
|
but m5 deliberately refuses to run twice against an already-approved terminal stage — that
|
||||||
|
guard is what stops it silently asserting against the wrong state.
|
||||||
|
|
||||||
|
Prerequisites: ERPCore on :5224 and AuthHex on :5602 (override with ERP_SMOKE_API /
|
||||||
|
ERP_SMOKE_AUTH / ERP_SMOKE_USER / ERP_SMOKE_PASSWORD).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPTS = [
|
||||||
|
("M2 templates + graph validation", "m2_templates.py"),
|
||||||
|
("M3 run creation / board / quantities", "m3_runs.py"),
|
||||||
|
("M4 stage start / complete / approve / transfer", "m4_stage_actions.py"),
|
||||||
|
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
||||||
|
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||||
|
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
here = Path(__file__).parent
|
||||||
|
results = []
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
for label, script in SCRIPTS:
|
||||||
|
print(f"\n{'=' * 70}\n{label} ({script})\n{'=' * 70}", flush=True)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, script, *sys.argv[1:]],
|
||||||
|
cwd=here, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||||
|
)
|
||||||
|
sys.stdout.write(proc.stdout)
|
||||||
|
if proc.stderr.strip():
|
||||||
|
sys.stderr.write(proc.stderr)
|
||||||
|
|
||||||
|
passed = total = 0
|
||||||
|
for line in proc.stdout.splitlines():
|
||||||
|
m = SUMMARY.match(line.strip())
|
||||||
|
if m:
|
||||||
|
passed, total = int(m.group(2)), int(m.group(3))
|
||||||
|
results.append((label, passed, total, proc.returncode))
|
||||||
|
if proc.returncode != 0:
|
||||||
|
failed = True
|
||||||
|
|
||||||
|
print(f"\n{'=' * 70}\nSUITE SUMMARY\n{'=' * 70}")
|
||||||
|
grand_passed = grand_total = 0
|
||||||
|
for label, passed, total, rc in results:
|
||||||
|
grand_passed += passed
|
||||||
|
grand_total += total
|
||||||
|
state = "OK " if rc == 0 else "FAIL"
|
||||||
|
print(f" [{state}] {label:<48} {passed}/{total}")
|
||||||
|
print(f"\n TOTAL: {grand_passed}/{grand_total} assertions"
|
||||||
|
+ (" — ALL GREEN" if not failed else " (SUITE FAILED)"))
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Shared harness for the manufacturing smoke tests (docs/30-BACKEND-PHASE2.md).
|
||||||
|
|
||||||
|
The repo has no test project; verification is a live smoke test against local Postgres
|
||||||
|
with a real AuthHex session, with the assertion count recorded in Backend/PROGRESS.md.
|
||||||
|
These scripts make that repeatable instead of ad-hoc curl.
|
||||||
|
|
||||||
|
Auth note: ERPCore validates AuthHex's RS256 tokens offline against a statically
|
||||||
|
configured public key, so we log in to AuthHex *directly* and send the access token as a
|
||||||
|
Bearer header. That deliberately bypasses ERPCore's own /auth/login proxy, which would
|
||||||
|
otherwise need AuthHex:BaseUrl to match the port AuthHex actually listens on.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python m2_templates.py [--api URL] [--auth URL] [--user EMAIL] [--password PW]
|
||||||
|
|
||||||
|
Environment variables (ERP_SMOKE_API, ERP_SMOKE_AUTH, ERP_SMOKE_USER,
|
||||||
|
ERP_SMOKE_PASSWORD) override the defaults; command-line flags override those.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Server messages and box-drawing output contain non-cp1252 characters, and the default
|
||||||
|
# Windows console codepage would raise UnicodeEncodeError mid-report — losing exactly the
|
||||||
|
# diagnostic text a failing assertion needs to show.
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"api": "http://localhost:5224/api/v1",
|
||||||
|
"auth": "http://localhost:5602",
|
||||||
|
"user": "admin@gmail.com",
|
||||||
|
"password": "Naveen@99",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(description: str) -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description=description)
|
||||||
|
for key, default in DEFAULTS.items():
|
||||||
|
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
__slots__ = ("status", "body", "headers")
|
||||||
|
|
||||||
|
def __init__(self, status: int, body, headers: dict):
|
||||||
|
self.status = status
|
||||||
|
self.body = body
|
||||||
|
self.headers = headers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code(self):
|
||||||
|
"""The RFC 7807 domain error code, when the body carries one (docs/11 §1.8)."""
|
||||||
|
return self.body.get("code") if isinstance(self.body, dict) else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def etag(self):
|
||||||
|
return self.headers.get("ETag")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{self.status} code={self.code}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, api: str, token: str):
|
||||||
|
self.api = api.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, body=None, if_match: str | None = None,
|
||||||
|
idempotency_key: str | None = None) -> Response:
|
||||||
|
url = f"{self.api}{path}"
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
req.add_header("Authorization", f"Bearer {self.token}")
|
||||||
|
if data is not None:
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
if if_match:
|
||||||
|
req.add_header("If-Match", if_match)
|
||||||
|
if idempotency_key:
|
||||||
|
req.add_header("Idempotency-Key", idempotency_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return Response(r.status, _decode(raw), dict(r.headers))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
return Response(e.code, _decode(raw), dict(e.headers))
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
return self.request("GET", path)
|
||||||
|
|
||||||
|
def post(self, path, body=None, **kw):
|
||||||
|
return self.request("POST", path, body, **kw)
|
||||||
|
|
||||||
|
def put(self, path, body=None, **kw):
|
||||||
|
return self.request("PUT", path, body, **kw)
|
||||||
|
|
||||||
|
def patch(self, path, body=None, **kw):
|
||||||
|
return self.request("PATCH", path, body, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(raw: bytes):
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return raw.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def login(auth_url: str, identifier: str, password: str) -> str:
|
||||||
|
"""Obtain an AuthHex access token via its `{functionName, payload}` envelope."""
|
||||||
|
payload = {
|
||||||
|
"functionName": "loginUser",
|
||||||
|
"payload": {"identifier": identifier, "password": password, "deviceName": "erp-smoke"},
|
||||||
|
"reference": "",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{auth_url.rstrip('/')}/api/user",
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
envelope = json.loads(r.read())
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
sys.exit(f"FATAL: cannot reach AuthHex at {auth_url} ({e}). Start ERP_Auth_Service first.")
|
||||||
|
|
||||||
|
if not envelope.get("success") or not envelope.get("data"):
|
||||||
|
sys.exit(f"FATAL: AuthHex login failed: {envelope.get('message')!r}")
|
||||||
|
return envelope["data"]["accessToken"]
|
||||||
|
|
||||||
|
|
||||||
|
class Checker:
|
||||||
|
"""Counts assertions so the pass total can be recorded in PROGRESS.md."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.passed = 0
|
||||||
|
self.failed = 0
|
||||||
|
|
||||||
|
def check(self, label: str, actual, expected) -> bool:
|
||||||
|
ok = actual == expected
|
||||||
|
if ok:
|
||||||
|
self.passed += 1
|
||||||
|
print(f" PASS {label}")
|
||||||
|
else:
|
||||||
|
self.failed += 1
|
||||||
|
print(f" FAIL {label}\n expected: {expected!r}\n actual: {actual!r}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def status(self, label: str, response: Response, expected_status: int, expected_code: str | None = None):
|
||||||
|
ok = self.check(f"{label} -> {expected_status}", response.status, expected_status)
|
||||||
|
if expected_code is not None:
|
||||||
|
ok = self.check(f"{label} -> code {expected_code}", response.code, expected_code) and ok
|
||||||
|
if not ok and response.status >= 400:
|
||||||
|
detail = response.body.get("detail") if isinstance(response.body, dict) else response.body
|
||||||
|
print(f" server said: {detail}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def section(self, title: str):
|
||||||
|
print(f"\n--- {title} ---")
|
||||||
|
|
||||||
|
def finish(self, name: str) -> int:
|
||||||
|
total = self.passed + self.failed
|
||||||
|
print(f"\n{'=' * 60}\n{name}: {self.passed}/{total} assertions passed"
|
||||||
|
+ (f" ({self.failed} FAILED)" if self.failed else " — ALL GREEN")
|
||||||
|
+ f"\n{'=' * 60}")
|
||||||
|
return 1 if self.failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap(description: str):
|
||||||
|
"""Standard entry point: parse args, log in, return (client, checker, args)."""
|
||||||
|
args = parse_args(description)
|
||||||
|
token = login(args.auth, args.user, args.password)
|
||||||
|
return Client(args.api, token), Checker(), args
|
||||||
|
|
||||||
|
|
||||||
|
# --- stock fixtures ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Seeding matters more than it looks. A positive stock ADJUSTMENT is the obvious way to
|
||||||
|
# create on-hand, but StockMutator's inbound path costs it at *last cost*, which is 0.00
|
||||||
|
# when the item has no prior layers. Stock seeded that way makes every cost-pool assertion
|
||||||
|
# pass trivially against zeros and proves nothing. A direct (no-PO) GRN lets us state the
|
||||||
|
# unit cost explicitly, so consumption produces a real, checkable value.
|
||||||
|
|
||||||
|
|
||||||
|
def drain_stock(c, warehouse_id: int) -> list:
|
||||||
|
"""
|
||||||
|
Zero out every item's on-hand in a warehouse via one negative adjustment.
|
||||||
|
|
||||||
|
Needed because these scripts are re-runnable and FIFO is oldest-first: stock left behind
|
||||||
|
by a previous execution is consumed *before* anything seeded now. If an earlier run left
|
||||||
|
zero-cost layers (as an adjustment-based seed does), a later run's cost assertions would
|
||||||
|
silently read 0.00 and pass against nothing. Draining first makes each execution start
|
||||||
|
from a known-empty warehouse.
|
||||||
|
"""
|
||||||
|
rows = c.get(f"/stock/on-hand/list?warehouseId={warehouse_id}&pageSize=200").body["items"]
|
||||||
|
lines = [{"itemId": r["itemId"], "qtyDelta": -float(r["onHand"])}
|
||||||
|
for r in rows if float(r["onHand"]) > 0]
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
reasons = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"]
|
||||||
|
if not reasons:
|
||||||
|
sys.exit("FATAL: no Adjustment reason codes seeded.")
|
||||||
|
|
||||||
|
res = c.post("/stock-adjustments", {
|
||||||
|
"warehouseId": warehouse_id,
|
||||||
|
"reasonCodeId": reasons[0]["reasonCodeId"],
|
||||||
|
"lines": lines,
|
||||||
|
})
|
||||||
|
if res.status != 201:
|
||||||
|
sys.exit(f"FATAL: could not drain the smoke warehouse: {res.status} {res.body}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_vendor(c) -> int:
|
||||||
|
existing = c.get("/vendors?pageSize=1").body["items"]
|
||||||
|
if existing:
|
||||||
|
return existing[0]["vendorId"]
|
||||||
|
created = c.post("/vendors", {"code": "SMOKE-V", "name": "Smoke vendor"})
|
||||||
|
if created.status != 201:
|
||||||
|
sys.exit(f"FATAL: could not create a vendor: {created.status} {created.body}")
|
||||||
|
return created.body["vendorId"]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Create on-hand at explicit unit costs via a direct GRN + confirm.
|
||||||
|
|
||||||
|
`lines` is an iterable of (item_id, uom_id, qty, unit_cost).
|
||||||
|
"""
|
||||||
|
vendor_id = vendor_id or ensure_vendor(c)
|
||||||
|
grn = c.post("/grns", {
|
||||||
|
"vendorId": vendor_id,
|
||||||
|
"warehouseId": warehouse_id,
|
||||||
|
"lines": [
|
||||||
|
{"itemId": i, "uomId": u, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
|
||||||
|
for (i, u, q, cost) in lines
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if grn.status != 201:
|
||||||
|
sys.exit(f"FATAL: could not create the seeding GRN: {grn.status} {grn.body}")
|
||||||
|
|
||||||
|
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
|
||||||
|
if confirmed.status != 200:
|
||||||
|
sys.exit(f"FATAL: could not confirm the seeding GRN: {confirmed.status} {confirmed.body}")
|
||||||
@@ -115,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.1–D.3). Validation posture: `docs/20-FRONTEND.md §3` — client checks are UX only.
|
||||||
|
|
||||||
|
> **Every production screen now runs on the real API. Both mock modules are deleted.** `npx tsc --noEmit` reports 0 errors in this module (the only 4 project errors are pre-existing HRM ones — see the note at the end of §13), and `npx next build` reports **"Compiled successfully"** before failing type-check on those same HRM files. **Nothing has been driven in a browser** — see the honesty note at the end.
|
||||||
|
|
||||||
|
## 11. Contract layer (F1) — DONE
|
||||||
|
- [x] `types/production.ts` **fully rewritten** against docs/30 Part D — every request/response DTO, all six enums, and the stage-action result shapes. Replaces the frontend-only placeholder shapes entirely
|
||||||
|
- [x] Three contract corrections carried through: templates are keyed by **`code`** (not `docNo` — only runs get a document number); quantities use **`itemId`/`uomId`/`qtyPerBatch`** numeric FKs (not free-text uom/qty); stages carry **`posX`/`posY`**, so canvas layout round-trips through the server
|
||||||
|
- [x] `lib/api/production-templates.ts` — list/get/create/update/updateStatus with ETag + `If-Match`
|
||||||
|
- [x] `lib/api/production-runs.ts` — the full §D.3 surface (start, complete, approve, transfer, reject-intake, reject, return-leftover, cancel, quantities), every action taking an `idempotencyKey`; plus `isStaleStageError()` for the docs/21 §6 "409 on a stage-status code → refetch silently" rule
|
||||||
|
- [x] `lib/error-map.ts` — all 17 docs/30 §D.4 codes. **Also fixed a real mechanism gap:** `errorMessage()` let any mapped domain code override the server's `detail`, which would have thrown away the specifics the user needs — the graph validator names the offending stages, and the transfer/leftover guards quote the actual figures. Added `DETAIL_PREFERRED_CODES` so those eight codes let `detail` win and keep their map entry as a fallback
|
||||||
|
|
||||||
|
## 12. Screens (F2–F5) — DONE
|
||||||
|
- [x] **Template overview** (`app/dashboard/production/templates/page.tsx`) — real `productionTemplatesApi.list` with a 300 ms debounced search, status filter, pagination and real `activeRunCount`. One-row-per-template canvas labelled from live data
|
||||||
|
- [x] **Template builder** (`templates/[id]/page.tsx`) — **fully rewired.** GETs the graph, holds the ETag, and the former `handleSave()` toast stub is now a real create/update. Node ids **are** the server's stage keys (`tmp-<uuid>` for stages drawn this session), so a PUT diffs stages in place and keeps historical runs linked; `node.position` persists as `posX`/`posY`; real `itemsApi`/`uomsApi` pickers replaced `MOCK_ITEMS`; `/templates/new` renders an unsaved draft seeded from the overview dialog's query params and swaps its URL on first save. Also gained a Deactivate/Activate control — `productionTemplatesApi.updateStatus` previously had no UI path at all
|
||||||
|
- [x] **Run board** (`runs/page.tsx`) — real list with debounced doc-no search, template/warehouse/status filters and pagination. Start dialog posts `productionRunsApi.create` and **navigates to the run**
|
||||||
|
- [x] **Run detail** (`runs/[id]/page.tsx`) — canvas built from the run's own `posX`/`posY` and run edges, with per-stage intake (`delivered/planned`) and available-to-transfer badges, live cost pool, and a `stageSummary` computed from real stage statuses
|
||||||
|
- [x] **Stage drawer** (`runs/[id]/StageDrawer.tsx`) — the whole of docs/21 §5: per-status bodies (Waiting → explanation · Ready → editable planned quantities + Start · InProgress → produced/scrapped per output with a required Production reason + custom fields + Complete · Done non-terminal → approve with optional partial transfer · Done terminal → receipt preview + Approve & receive + Reject for rework · Approved → transfer remainder), reject-intake from Ready **or** Waiting-with-deliveries, and the per-stage event timeline
|
||||||
|
- [x] **Runtime custom-field renderer** (`runs/[id]/CustomFieldForm.tsx`) — `fieldDefs` → typed inputs for all five types, plus `missingRequiredFields()` which **mirrors the server's rule exactly**, including the part that surprises people: an unchecked Checkbox counts as *provided* (`false`), so a required checkbox does not force a tick
|
||||||
|
- [x] **Run-level actions** (`runs/[id]/RunActions.tsx`) — Return leftover (per consumed Stock input, showing consumed/returned/weighted cost, in base UOM and capped at the unreturned remainder) and Cancel run (previewing what goes back to stock). Both hidden once the run leaves InProgress, because `RUN_COST_CLOSED`/`RUN_NOT_CANCELLABLE` mean offering them could only produce an error
|
||||||
|
- [x] `lib/production-status-colors.ts` kept untouched — it already matches docs/21 §3 exactly and is the single source for status colour everywhere
|
||||||
|
- [x] **Deleted `lib/production-mock-runs.ts` and `lib/production-mock-templates.ts`**, including `buildStagePlan()`
|
||||||
|
|
||||||
|
**Deviations / decisions (recorded):**
|
||||||
|
- **The drawer is one file, not the seven the plan sketched.** Each per-status panel is ~30 lines and they all share the same lookup helpers, `submit()` wrapper and error handling; splitting them would mean threading that shared context through seven prop lists for no isolation benefit. `CustomFieldForm` and `RunActions` *are* separate, because both stand alone and neither needs the drawer's form state.
|
||||||
|
- **The board shows per-status counts, not named stages.** The list projection carries `stageSummary` only, so naming stages there would mean guessing which stage holds which count — exactly what the deleted `buildStagePlan()` did. Named per-stage state lives on the run detail, where the server actually returns it.
|
||||||
|
- **Non-terminal outputs have their `itemId` stripped on save, not rejected.** A stage that *was* terminal and then gained a child keeps its picked item in local state with the field no longer rendered; an issue-list message about an invisible field would be unactionable, so the builder drops it silently (FR-MFG-05 forbids it on a WIP output anyway).
|
||||||
|
- **The terminal receipt preview is computed client-side.** There is no preview endpoint and every input (cost pool, produced, scrapped) is already on the page, so the drawer mirrors the server's arithmetic to show the layer *before* creating it. Preview only — the server recomputes.
|
||||||
|
- **A status toggle re-reads the ETag.** `PATCH /status` bumps the row's `xmin`, invalidating the token the builder holds. It re-GETs and takes *only* the etag and status, deliberately not reloading the canvas, because a full reload there would silently discard unsaved edits.
|
||||||
|
- **"New Template" opens an unsaved draft rather than creating immediately.** A template cannot exist without a valid graph — the server requires ≥1 stage and a terminal output naming a real item (FR-MFG-02/05) — so there is nothing sensible to POST from a name alone.
|
||||||
|
- **`templateGraphToSaveRequest()` was deleted from `lib/api/production-templates.ts`.** It converted a fetched graph into a save payload, but the builder's canvas — not the last GET — is the source of truth for what gets saved, so it had no caller and would have drifted.
|
||||||
|
|
||||||
|
**Backend additions made for these screens** (all amended into docs/30 as built):
|
||||||
|
- **`TemplateSummaryDto.stageNames`, in flow order.** The overview draws each template as a line left-to-right and needs the names for every row; without the field the client would fetch every template's full graph just to label boxes. Ordering by stage id turned out to be insertion order, which put the *terminal* stage first and drew lines backwards — so the server toposorts (Kahn, tie-broken by id for stability, falling back to id order if the graph is ever cyclic so a listing can't fail on bad data).
|
||||||
|
- **`TemplateGraphDto.activeRunCount`.** The builder reads its edit-locked state straight off the graph; without it, it would need a second request to the list endpoint purely to know whether to disable itself.
|
||||||
|
- **`production_templates.Annotations` (jsonb) + `SaveTemplateRequest.annotations`.** The canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so every save would have silently discarded the user's layout notes. Round-tripped verbatim, capped at 200 entries, `kind` validated to `box`/`line`, and invisible to the graph validator. Migration `AddTemplateCanvasAnnotations`. Note the flip side, pinned by a smoke assertion: replacement is wholesale, so a client that forgets to echo `annotations` back on a PUT clears them.
|
||||||
|
|
||||||
|
## 13. Validation posture (F6) — DONE
|
||||||
|
- [x] Domain-code → message map complete (§11), with `detail` preferred where the server is more specific
|
||||||
|
- [x] `412 CONCURRENCY_CONFLICT` amber conflict banner + Reload on the builder (the `app/dashboard/vendors/[id]/page.tsx` pattern)
|
||||||
|
- [x] `409 TEMPLATE_IN_USE` edit-lock banner driven by the server. Two distinct messages: locked on load (`activeRunCount > 0`) versus locked *while editing* — the FR-MFG-06 TOCTOU, where a run starts between the GET and the PUT. The second locks the canvas rather than reloading, so nothing the user just drew disappears without an explanation
|
||||||
|
- [x] `422 GRAPH_*` focuses the offending stage — a best-effort substring match of the server's `detail` against stage names, which is why the validator quotes them. Advisory by design: the full message is always in the banner too, so an ambiguous name costs a highlight, never the explanation
|
||||||
|
- [x] `Idempotency-Key` per action (`useRef(crypto.randomUUID())`, re-minted after each success and whenever the drawer switches stage)
|
||||||
|
- [x] Silent refetch on stage-status 409s — `submit()` in the drawer routes every action through `isStaleStageError()`
|
||||||
|
|
||||||
|
**Not done, flagged rather than silently skipped:**
|
||||||
|
- **No browser verification of any production screen, and no live end-to-end run.** The contract layer is written against a backend whose every endpoint is smoke-verified, the tree type-checks and Turbopack compiles it, but **nothing has been clicked.** Blocked on AuthHex: its configured MySQL host (`187.127.102.190:3306`) is unreachable from this machine, so no token can be issued — which also means the backend smoke suite could not be re-run after this pass's backend additions.
|
||||||
|
- **`components/Layouts/AppSidebar.tsx:351` still lists `"production"` in `bypassCodes`.** Correct for now — no role is seeded with a `NAV:production` permission, so removing the bypass would hide the section from everyone. Seeding that permission is the real fix (same outstanding item as `procurement`/`hrm`).
|
||||||
|
- **4 pre-existing `tsc` errors, unrelated to this work — and they block `next build` for the whole app:** `hrm/employees/[id]/page.tsx` (`UpdateEmployeeRequest` missing `hireDate`) and three `hrm/settings/*` pages (an `Api<T>` generic expecting `{value}` where `ApiResult<T>` is returned). None of these files import anything added or changed by this pass, so they were left alone rather than fixed as a side effect of manufacturing work.
|
||||||
|
- **10 `react-hooks/set-state-in-effect` lint errors across the five production files.** Same rule fires 42 times repo-wide (`app/dashboard/receiving/grn/page.tsx` included); these are the load effects, the hydration-mismatch guards and the builder's stale-upstream repair. No other rule fires in this module.
|
||||||
|
|
||||||
## Done
|
## 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.
|
||||||
|
|||||||
@@ -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>
|
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p>
|
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
|
||||||
<div className="flex flex-col gap-3">
|
</div>
|
||||||
{/* 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 */}
|
{error && (
|
||||||
<Breadcrumb>
|
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||||
<BreadcrumbList>
|
{error}
|
||||||
<BreadcrumbItem>
|
</div>
|
||||||
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
|
)}
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator />
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
||||||
<BreadcrumbItem>
|
{loaded ? (
|
||||||
<BreadcrumbEllipsis />
|
<>
|
||||||
</BreadcrumbItem>
|
<Link href="/dashboard/stock/reorder-alerts">
|
||||||
<BreadcrumbSeparator />
|
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
||||||
<BreadcrumbItem>
|
</Link>
|
||||||
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
|
<Link href="/dashboard/stock/enquiry">
|
||||||
</BreadcrumbItem>
|
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||||
<BreadcrumbSeparator />
|
</Link>
|
||||||
<BreadcrumbItem>
|
<Link href="/dashboard/procurement/purchase-orders">
|
||||||
<BreadcrumbPage>Edit</BreadcrumbPage>
|
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
|
||||||
</BreadcrumbItem>
|
</Link>
|
||||||
</BreadcrumbList>
|
<Link href="/dashboard/receiving/grn">
|
||||||
</Breadcrumb>
|
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||||
|
Stock Valuation by Warehouse
|
||||||
|
</h2>
|
||||||
|
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||||
|
View details
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{loaded ? (
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Variants</p>
|
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="h-52 sm:h-64">
|
||||||
<Button variant="default">Default</Button>
|
<BarChart
|
||||||
<Button variant="outline">Outline</Button>
|
labels={stats.stockValuationByWarehouse.map(
|
||||||
<Button variant="secondary">Secondary</Button>
|
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||||
<Button variant="ghost">Ghost</Button>
|
)}
|
||||||
<Button variant="destructive">Destructive</Button>
|
datasets={[
|
||||||
<Button variant="success">Success</Button>
|
{
|
||||||
<Button variant="warning">Warning</Button>
|
label: "Stock Value (LKR)",
|
||||||
<Button variant="info">Info</Button>
|
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||||
<Button variant="link">Link</Button>
|
backgroundColor: "#6366f1",
|
||||||
</div>
|
},
|
||||||
</div>
|
]}
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Sizes</p>
|
|
||||||
<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>
|
||||||
<div className="flex flex-col gap-1">
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">Date range</span>
|
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||||
<DateRangePicker
|
)
|
||||||
value={range}
|
) : (
|
||||||
onChange={setRange}
|
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||||
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 && " · "}
|
|
||||||
Range: <span className="font-medium text-foreground">{range.from.toLocaleDateString()}</span>
|
|
||||||
{range.to && <> – <span className="font-medium text-foreground">{range.to.toLocaleDateString()}</span></>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Modal</p>
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger render={<Button variant="outline">Open Modal</Button>} />
|
|
||||||
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-md">
|
|
||||||
<DialogHeader className="items-center gap-3 px-2 pt-4 text-center sm:px-4">
|
|
||||||
<div className="flex size-14 items-center justify-center rounded-full bg-success/10">
|
|
||||||
<CheckCircle2 className="size-7 text-success" />
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Toast</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-5 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">
|
||||||
<div>
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Alert Dialogs</p>
|
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||||
<div className="flex flex-wrap gap-2">
|
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||||
<AlertDialog>
|
Stock Movement Trend (last {TREND_DAYS} days)
|
||||||
<AlertDialogTrigger render={<Button variant="info">Info</Button>} />
|
</h2>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
{loaded ? (
|
||||||
<StatCard label="Total Revenue" value={45231.89} icon={DollarSign} />
|
<div className="h-52 sm:h-64">
|
||||||
<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
|
<LineChart
|
||||||
labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]}
|
labels={movementTrend.labels}
|
||||||
datasets={[{ label: "Sales", data: [120, 200, 150, 220, 180, 260] }]}
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
{loaded ? (
|
||||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Revenue (Bar)</h3>
|
movements.length > 0 ? (
|
||||||
<div className="h-56">
|
<Table>
|
||||||
<BarChart
|
<TableHeader>
|
||||||
labels={["Q1", "Q2", "Q3", "Q4"]}
|
<TableRow>
|
||||||
datasets={[{ label: "Revenue", data: [30000, 42000, 36000, 48000], backgroundColor: "var(--color-primary)" }]}
|
<TableHead>Item</TableHead>
|
||||||
/>
|
<TableHead>Warehouse</TableHead>
|
||||||
</div>
|
<TableHead>Direction</TableHead>
|
||||||
</div>
|
<TableHead>Qty</TableHead>
|
||||||
|
<TableHead>Source</TableHead>
|
||||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
<TableHead className="text-right">Date</TableHead>
|
||||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3>
|
</TableRow>
|
||||||
<div className="h-56">
|
</TableHeader>
|
||||||
<PieChart labels={["A","B","C"]} data={[45, 30, 25]} />
|
<TableBody>
|
||||||
</div>
|
{movements.map((entry) => (
|
||||||
</div>
|
<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's cost pool.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Consumed material</FieldLabel>
|
||||||
|
<Select<number> value={selectedInputId} onValueChange={(v) => { setSelectedInputId(v); setReturnQty("") }}>
|
||||||
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
|
<SelectValue placeholder="Pick a material" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{returnable.map((r) => (
|
||||||
|
<SelectItem key={r.runInputId} value={r.runInputId} className="text-base">
|
||||||
|
{itemName(r.itemId)} · {r.stageName} · {fmt(r.remaining)} left
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div className="flex flex-col gap-1 rounded-lg border border-border bg-muted/40 p-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Consumed</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{fmt(selected.consumedQty)} · {money(selected.consumedValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Already returned</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{fmt(selected.returnedQty)} · {money(selected.returnedValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Weighted cost</span>
|
||||||
|
<span className="font-medium text-foreground">{weightedCost === null ? "—" : money(weightedCost)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="return-qty">
|
||||||
|
Quantity to return
|
||||||
|
{selected && <span className="font-normal text-muted-foreground"> (base UOM, max {fmt(selected.remaining)})</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="return-qty"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
max={selected?.remaining}
|
||||||
|
value={returnQty}
|
||||||
|
onChange={(e) => setReturnQty(e.target.value)}
|
||||||
|
placeholder={selected ? String(selected.remaining) : ""}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Reason</FieldLabel>
|
||||||
|
<Select<number> value={returnReasonId} onValueChange={setReturnReasonId}>
|
||||||
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
|
<SelectValue placeholder="Pick a reason" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{productionReasons.map((r) => (
|
||||||
|
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
|
||||||
|
{r.code} — {r.description}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={() => setDialog(null)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy || !returnValid}
|
||||||
|
onClick={() =>
|
||||||
|
submit(() =>
|
||||||
|
productionRunsApi.returnLeftover(run.runId, selected!.runInputId, {
|
||||||
|
qty: returnQtyNum,
|
||||||
|
reasonCodeId: returnReasonId!,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? "Returning…" : "Return to stock"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------------------- cancel */}
|
||||||
|
<Dialog open={dialog === "cancel"} onOpenChange={(open) => !open && setDialog(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Cancel this run?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Everything consumed and not yet returned goes back into stock at its consumed cost. Scrapped output is
|
||||||
|
written off — it never entered stock. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{returnable.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 rounded-lg border border-border bg-muted/40 p-3 text-sm">
|
||||||
|
<p className="font-medium text-foreground">Will be returned to stock</p>
|
||||||
|
{returnable.map((r) => (
|
||||||
|
<div key={r.runInputId} className="flex justify-between">
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">{itemName(r.itemId)}</span>
|
||||||
|
<span className="shrink-0 font-medium text-foreground">{fmt(r.remaining)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Reason</FieldLabel>
|
||||||
|
<Select<number> value={cancelReasonId} onValueChange={setCancelReasonId}>
|
||||||
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
|
<SelectValue placeholder="Pick a reason" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{productionReasons.map((r) => (
|
||||||
|
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
|
||||||
|
{r.code} — {r.description}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="cancel-note">Note (optional)</FieldLabel>
|
||||||
|
<Input id="cancel-note" value={cancelNote} onChange={(e) => setCancelNote(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={() => setDialog(null)} disabled={busy}>
|
||||||
|
Keep run
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={busy || cancelReasonId === null}
|
||||||
|
onClick={() =>
|
||||||
|
submit(() =>
|
||||||
|
productionRunsApi.cancel(run.runId, {
|
||||||
|
reasonCodeId: cancelReasonId!,
|
||||||
|
note: cancelNote.trim() || null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? "Cancelling…" : "Cancel run"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { AlertTriangle, Ban, CheckCircle2, PackageCheck, Play, Send, Undo2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { isStaleStageError, productionRunsApi } from "@/lib/api/production-runs"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { CustomFieldForm, missingRequiredFields } from "./CustomFieldForm"
|
||||||
|
import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors"
|
||||||
|
import {
|
||||||
|
CompleteOutputLine,
|
||||||
|
ProductionRunGraph,
|
||||||
|
RunStage,
|
||||||
|
RunStageInput,
|
||||||
|
RunStageOutput,
|
||||||
|
StageQuantityLine,
|
||||||
|
TransferLine,
|
||||||
|
} from "@/types/production"
|
||||||
|
import { ItemListItem, Uom } from "@/types/master-data"
|
||||||
|
import { ReasonCode } from "@/types/stock"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage drawer (docs/21-FRONTEND-PHASE2.md §5) — the only place a run is actually driven.
|
||||||
|
*
|
||||||
|
* One component rather than the seven the plan sketched: each per-status panel is ~30 lines and
|
||||||
|
* they all share the same lookup helpers, submit wrapper, and error handling, so splitting them
|
||||||
|
* would mean threading that shared context through seven prop lists for no isolation benefit.
|
||||||
|
*
|
||||||
|
* The body is switched on `stage.status`, which is deliberately the *server's* status and never
|
||||||
|
* a local guess — every action re-fetches the whole run through `onActed`, so what is rendered
|
||||||
|
* is always what the server last said. Two consequences worth knowing:
|
||||||
|
*
|
||||||
|
* * A 409 carrying a stage-status code means someone else moved first. `submit` refreshes
|
||||||
|
* silently instead of showing an error (docs/21 §6); that is also what makes the server's
|
||||||
|
* accept-and-ignore `Idempotency-Key` posture feel right — a double-click just refreshes.
|
||||||
|
* * Local form state is keyed off `stage.runStageId` and reset whenever the stage changes, so
|
||||||
|
* a refresh mid-edit can never post figures from a stage the user is no longer looking at.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fmt(n: number): string {
|
||||||
|
return Number(n.toFixed(4)).toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(n: number): string {
|
||||||
|
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minutes between two instants, floored — the same unit the server reports `actualMinutes` in. */
|
||||||
|
function minutesSince(iso: string, now: number): number {
|
||||||
|
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: StageStatus }) {
|
||||||
|
const color = STAGE_STATUS_COLOR[status]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||||
|
style={{ backgroundColor: `${color}1a`, color }}
|
||||||
|
>
|
||||||
|
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
{STAGE_STATUS_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-right font-medium text-foreground">{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageDrawer({
|
||||||
|
run,
|
||||||
|
stage,
|
||||||
|
items,
|
||||||
|
uoms,
|
||||||
|
reasonCodes,
|
||||||
|
onClose,
|
||||||
|
onActed,
|
||||||
|
}: {
|
||||||
|
run: ProductionRunGraph
|
||||||
|
stage: RunStage | null
|
||||||
|
items: ItemListItem[]
|
||||||
|
uoms: Uom[]
|
||||||
|
reasonCodes: ReasonCode[]
|
||||||
|
onClose: () => void
|
||||||
|
onActed: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Editable planned quantities (Ready only) keyed by run input/output id.
|
||||||
|
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
|
||||||
|
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
|
||||||
|
|
||||||
|
// Complete-form state.
|
||||||
|
const [produced, setProduced] = useState<Record<number, string>>({})
|
||||||
|
const [scrapped, setScrapped] = useState<Record<number, string>>({})
|
||||||
|
const [scrapReason, setScrapReason] = useState<Record<number, number | null>>({})
|
||||||
|
const [fieldValues, setFieldValues] = useState<Record<string, unknown>>({})
|
||||||
|
|
||||||
|
// Transfer state — the amount to push per output, defaulted to everything available.
|
||||||
|
const [transferQty, setTransferQty] = useState<Record<number, string>>({})
|
||||||
|
|
||||||
|
const [confirm, setConfirm] = useState<null | "rejectIntake" | "rejectTerminal">(null)
|
||||||
|
|
||||||
|
// A per-action Idempotency-Key, minted fresh for each stage the drawer opens on. Same shape as
|
||||||
|
// app/dashboard/receiving/grn/[id]/page.tsx.
|
||||||
|
const idempotencyKey = useRef(crypto.randomUUID())
|
||||||
|
|
||||||
|
const stageId = stage?.runStageId ?? null
|
||||||
|
|
||||||
|
// Reseed every form whenever the drawer switches stage *or* the server sends new figures for
|
||||||
|
// the one it is on. Without the second half, a refresh after an action would leave the inputs
|
||||||
|
// showing pre-action numbers.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stage) return
|
||||||
|
setPlannedInputs(Object.fromEntries(stage.inputs.map((i) => [i.runInputId, String(i.plannedQty)])))
|
||||||
|
setPlannedOutputs(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.plannedQty)])))
|
||||||
|
setProduced(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.producedQty || o.plannedQty)])))
|
||||||
|
setScrapped(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.scrappedQty || 0)])))
|
||||||
|
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
|
||||||
|
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
|
||||||
|
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
|
||||||
|
setError(null)
|
||||||
|
idempotencyKey.current = crypto.randomUUID()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stageId, stage?.status, stage?.outputs, stage?.inputs])
|
||||||
|
|
||||||
|
// Live elapsed while the stage is running (FR-MFG-19). One tick a minute is enough — the
|
||||||
|
// figure is rendered in whole minutes, so a faster interval would just re-render for nothing.
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const running = stage?.actualStartAt != null && stage?.actualEndAt == null
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [running])
|
||||||
|
|
||||||
|
const itemName = useMemo(() => {
|
||||||
|
const byId = new Map(items.map((i) => [i.itemId, i.name]))
|
||||||
|
return (id: number | null) => (id === null ? "—" : byId.get(id) ?? `Item #${id}`)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const uomName = useMemo(() => {
|
||||||
|
const byId = new Map(uoms.map((u) => [u.uomId, u.name]))
|
||||||
|
return (id: number) => byId.get(id) ?? `#${id}`
|
||||||
|
}, [uoms])
|
||||||
|
|
||||||
|
const stageName = useMemo(() => {
|
||||||
|
const byId = new Map(run.stages.map((s) => [s.runStageId, s.name]))
|
||||||
|
return (id: number) => byId.get(id) ?? `Stage #${id}`
|
||||||
|
}, [run.stages])
|
||||||
|
|
||||||
|
if (!stage) return null
|
||||||
|
|
||||||
|
const isEditable = stage.status === "Ready"
|
||||||
|
const upstreamInputs = stage.inputs.filter((i) => i.source === "Upstream")
|
||||||
|
const deliveredSoFar = upstreamInputs.reduce((sum, i) => sum + i.deliveredQty, 0)
|
||||||
|
const canRejectIntake = (stage.status === "Ready" || stage.status === "Waiting") && deliveredSoFar > 0
|
||||||
|
const availableTotal = stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0)
|
||||||
|
const stageEvents = run.events.filter((e) => e.runStageId === stage.runStageId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One wrapper for every action: busy flag, error surfacing, and the docs/21 §6 rule that a
|
||||||
|
* stage-status 409 refreshes silently rather than shouting at the user.
|
||||||
|
*/
|
||||||
|
async function submit(action: () => Promise<unknown>) {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
idempotencyKey.current = crypto.randomUUID()
|
||||||
|
onActed()
|
||||||
|
} catch (err) {
|
||||||
|
if (isStaleStageError(err)) {
|
||||||
|
onActed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQuantities() {
|
||||||
|
const inputs: StageQuantityLine[] = stage!.inputs
|
||||||
|
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
|
||||||
|
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
|
||||||
|
const outputs: StageQuantityLine[] = stage!.outputs
|
||||||
|
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
|
||||||
|
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
|
||||||
|
|
||||||
|
return submit(() => productionRunsApi.updateStageQuantities(run.runId, stage!.runStageId, { inputs, outputs }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeStage() {
|
||||||
|
const outputs: CompleteOutputLine[] = stage!.outputs.map((o) => ({
|
||||||
|
runOutputId: o.runOutputId,
|
||||||
|
producedQty: Number(produced[o.runOutputId] ?? 0) || 0,
|
||||||
|
scrappedQty: Number(scrapped[o.runOutputId] ?? 0) || 0,
|
||||||
|
scrapReasonCodeId: Number(scrapped[o.runOutputId] ?? 0) > 0 ? scrapReason[o.runOutputId] : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return submit(() =>
|
||||||
|
productionRunsApi.complete(
|
||||||
|
run.runId,
|
||||||
|
stage!.runStageId,
|
||||||
|
{ outputs, fieldValues: Object.keys(fieldValues).length > 0 ? fieldValues : null },
|
||||||
|
idempotencyKey.current
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve. An empty `transfers` array tells the server to push every output in full, which is
|
||||||
|
* the common case; a line is only sent when the user has dialled it down below what's
|
||||||
|
* available. On the terminal stage the server ignores transfers entirely and posts the receipt.
|
||||||
|
*/
|
||||||
|
function approveStage() {
|
||||||
|
const transfers: TransferLine[] = stage!.outputs
|
||||||
|
.filter((o) => {
|
||||||
|
const wanted = Number(transferQty[o.runOutputId] ?? o.availableToTransfer)
|
||||||
|
return Number.isFinite(wanted) && wanted !== o.availableToTransfer
|
||||||
|
})
|
||||||
|
.map((o) => ({ runOutputId: o.runOutputId, qty: Number(transferQty[o.runOutputId]) }))
|
||||||
|
|
||||||
|
return submit(() =>
|
||||||
|
productionRunsApi.approve(
|
||||||
|
run.runId,
|
||||||
|
stage!.runStageId,
|
||||||
|
transfers.length > 0 ? { transfers } : {},
|
||||||
|
idempotencyKey.current
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferRemainder(output: RunStageOutput) {
|
||||||
|
const qty = Number(transferQty[output.runOutputId] ?? 0)
|
||||||
|
return submit(() =>
|
||||||
|
productionRunsApi.transfer(
|
||||||
|
run.runId,
|
||||||
|
stage!.runStageId,
|
||||||
|
{ runOutputId: output.runOutputId, qty },
|
||||||
|
idempotencyKey.current
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- terminal receipt preview ---------------------------------------------
|
||||||
|
// Mirrors the server's arithmetic (docs/30 §D.3 terminal approve) so the operator sees the
|
||||||
|
// layer they are about to create *before* creating it. Deliberately recomputed here rather
|
||||||
|
// than requested: there is no preview endpoint, and every input is already on this page.
|
||||||
|
const terminalOutput = stage.isTerminal ? stage.outputs[0] : undefined
|
||||||
|
const goodQty = terminalOutput ? terminalOutput.producedQty - terminalOutput.scrappedQty : 0
|
||||||
|
const previewUnitCost = goodQty > 0 ? run.costPool.net / goodQty : null
|
||||||
|
|
||||||
|
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
|
||||||
|
|
||||||
|
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
|
||||||
|
const missingScrapReasons = stage.outputs
|
||||||
|
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
|
||||||
|
.map((o) => o.name)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<SheetContent className="w-full overflow-y-auto sm:max-w-lg!">
|
||||||
|
<SheetHeader>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<SheetTitle className="text-base">{stage.name}</SheetTitle>
|
||||||
|
<StatusPill status={stage.status} />
|
||||||
|
{stage.roleLabel && (
|
||||||
|
<Badge variant="outline" className="border-transparent bg-primary/10 text-primary">
|
||||||
|
{stage.roleLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{stage.isTerminal && (
|
||||||
|
<Badge variant="outline" className="border-transparent bg-muted text-muted-foreground">
|
||||||
|
Final stage
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SheetDescription>
|
||||||
|
Estimated {stage.estimatedMinutes} min
|
||||||
|
{stage.actualMinutes !== null
|
||||||
|
? ` · actual ${stage.actualMinutes} min`
|
||||||
|
: running && stage.actualStartAt
|
||||||
|
? ` · running for ${minutesSince(stage.actualStartAt, now)} min`
|
||||||
|
: ""}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------- inputs */}
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Inputs</h3>
|
||||||
|
{stage.inputs.length === 0 && <p className="text-sm text-muted-foreground">This stage consumes nothing.</p>}
|
||||||
|
{stage.inputs.map((input) => (
|
||||||
|
<InputCard
|
||||||
|
key={input.runInputId}
|
||||||
|
input={input}
|
||||||
|
editable={isEditable}
|
||||||
|
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
|
||||||
|
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||||
|
itemName={itemName}
|
||||||
|
uomName={uomName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* --------------------------------------------------------- outputs */}
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Outputs</h3>
|
||||||
|
{stage.outputs.map((output) => (
|
||||||
|
<div key={output.runOutputId} className="rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="min-w-0 truncate text-sm font-medium text-foreground">{output.name}</p>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{uomName(output.uomId)}</span>
|
||||||
|
</div>
|
||||||
|
{output.itemId !== null && (
|
||||||
|
<p className="text-xs text-muted-foreground">Finished good: {itemName(output.itemId)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
{isEditable ? (
|
||||||
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">Planned</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={plannedOutputs[output.runOutputId] ?? String(output.plannedQty)}
|
||||||
|
onChange={(e) => setPlannedOutputs((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||||
|
className="h-8 w-28 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<Row label="Planned">{fmt(output.plannedQty)}</Row>
|
||||||
|
)}
|
||||||
|
{output.producedQty > 0 && <Row label="Produced">{fmt(output.producedQty)}</Row>}
|
||||||
|
{output.scrappedQty > 0 && (
|
||||||
|
<Row label="Scrapped">
|
||||||
|
<span className="text-destructive">{fmt(output.scrappedQty)}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{output.transferredQty > 0 && <Row label="Transferred">{fmt(output.transferredQty)}</Row>}
|
||||||
|
{output.availableToTransfer > 0 && (
|
||||||
|
<Row label="Available to transfer">
|
||||||
|
<span className="text-info">{fmt(output.availableToTransfer)}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ------------------------------------------------ status-specific */}
|
||||||
|
{stage.status === "Waiting" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Waiting on upstream deliveries. This stage becomes Ready once every upstream input has received its
|
||||||
|
planned quantity.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.status === "Ready" && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adjust planned quantities if needed, then start. Starting consumes the stock inputs above FIFO —
|
||||||
|
quantities are locked from that point on.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={saveQuantities} disabled={busy}>
|
||||||
|
Save quantities
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
submit(() => productionRunsApi.start(run.runId, stage.runStageId, idempotencyKey.current))
|
||||||
|
}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Start stage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.status === "InProgress" && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Record output</h3>
|
||||||
|
{stage.outputs.map((output) => {
|
||||||
|
const scrapQty = Number(scrapped[output.runOutputId] ?? 0)
|
||||||
|
return (
|
||||||
|
<div key={output.runOutputId} className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">{output.name}</p>
|
||||||
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">Produced</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={produced[output.runOutputId] ?? ""}
|
||||||
|
onChange={(e) => setProduced((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||||
|
className="h-8 w-28 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">Scrapped</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={scrapped[output.runOutputId] ?? ""}
|
||||||
|
onChange={(e) => setScrapped((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||||
|
className="h-8 w-28 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{scrapQty > 0 && (
|
||||||
|
<Select<number>
|
||||||
|
value={scrapReason[output.runOutputId] ?? null}
|
||||||
|
onValueChange={(v) => setScrapReason((prev) => ({ ...prev, [output.runOutputId]: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9! w-full text-sm">
|
||||||
|
<SelectValue placeholder="Scrap reason (required)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{scrapReasons.map((r) => (
|
||||||
|
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
|
||||||
|
{r.code} — {r.description}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<CustomFields stage={stage} values={fieldValues} onChange={setFieldValues} />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Blocked client-side as well as server-side. The server's 400
|
||||||
|
REQUIRED_FIELD_MISSING rolls the whole transaction back, so letting it through
|
||||||
|
would cost a round trip and — worse — look to the operator like the completion
|
||||||
|
half-applied. `missingRequiredFields` deliberately mirrors the server's rule.
|
||||||
|
*/}
|
||||||
|
{missingScrapReasons.length > 0 && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Pick a scrap reason for: {missingScrapReasons.join(", ")}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{missingFields.length > 0 && (
|
||||||
|
<p className="text-sm text-destructive">Fill in: {missingFields.join(", ")}.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={completeStage}
|
||||||
|
disabled={busy || missingFields.length > 0 || missingScrapReasons.length > 0}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Complete stage
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.status === "Done" && !stage.isTerminal && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Approve & transfer</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Approving pushes work downstream. Leave the amounts as they are to transfer everything, or lower one to
|
||||||
|
hold some back — you can transfer the remainder later.
|
||||||
|
</p>
|
||||||
|
{stage.outputs.map((output) => (
|
||||||
|
<label key={output.runOutputId} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">{output.name}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={output.availableToTransfer}
|
||||||
|
step="any"
|
||||||
|
value={transferQty[output.runOutputId] ?? ""}
|
||||||
|
onChange={(e) => setTransferQty((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||||
|
className="h-8 w-28 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">of {fmt(output.availableToTransfer)}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<Button onClick={approveStage} disabled={busy}>
|
||||||
|
<Send className="size-4" />
|
||||||
|
Approve & transfer
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.status === "Done" && stage.isTerminal && terminalOutput && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Finish the run</h3>
|
||||||
|
<div className="flex flex-col gap-1 rounded-lg border border-info/30 bg-info/5 p-3">
|
||||||
|
<Row label="Good quantity">{fmt(goodQty)} {uomName(terminalOutput.uomId)}</Row>
|
||||||
|
<Row label="Materials consumed">{money(run.costPool.consumed)}</Row>
|
||||||
|
<Row label="Leftovers returned">−{money(run.costPool.returned)}</Row>
|
||||||
|
<Separator className="my-1" />
|
||||||
|
<Row label="Cost pool">{money(run.costPool.net)}</Row>
|
||||||
|
<Row label="Unit cost">{previewUnitCost === null ? "—" : money(previewUnitCost)}</Row>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Approving creates a costed stock layer of {fmt(goodQty)} {itemName(terminalOutput.itemId)} and completes
|
||||||
|
the run. Its costs close at that point — return any leftover materials first.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={approveStage} disabled={busy || goodQty <= 0}>
|
||||||
|
<PackageCheck className="size-4" />
|
||||||
|
Approve & receive
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setConfirm("rejectTerminal")} disabled={busy}>
|
||||||
|
<Undo2 className="size-4" />
|
||||||
|
Reject for rework
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.status === "Approved" && availableTotal > 0 && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Transfer remainder</h3>
|
||||||
|
{stage.outputs
|
||||||
|
.filter((o) => o.availableToTransfer > 0)
|
||||||
|
.map((output) => (
|
||||||
|
<div key={output.runOutputId} className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{output.name}</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={output.availableToTransfer}
|
||||||
|
step="any"
|
||||||
|
value={transferQty[output.runOutputId] ?? ""}
|
||||||
|
onChange={(e) => setTransferQty((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))}
|
||||||
|
className="h-8 w-24 text-sm"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={() => transferRemainder(output)} disabled={busy}>
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.status === "Approved" && availableTotal === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Approved — everything this stage produced has moved on.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canRejectIntake && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Reject what was delivered</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Sends {fmt(deliveredSoFar)} back to the stage(s) that delivered it and reopens them for correction.
|
||||||
|
No stock moves — this is work in progress, not inventory.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="w-fit text-destructive" onClick={() => setConfirm("rejectIntake")} disabled={busy}>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
Reject intake
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --------------------------------------------------------- history */}
|
||||||
|
{stageEvents.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">History</h3>
|
||||||
|
<ol className="flex flex-col gap-2">
|
||||||
|
{stageEvents.map((event) => (
|
||||||
|
<li key={event.eventId} className="flex gap-2 text-sm">
|
||||||
|
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground" />
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="font-medium text-foreground">{event.eventType}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(event.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{event.note && <span className="text-xs text-muted-foreground">{event.note}</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<AlertDialog open={confirm === "rejectIntake"} onOpenChange={(open) => !open && setConfirm(null)}>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title="Reject the delivered work?"
|
||||||
|
description={`${stageName(stage.runStageId)} will go back to Waiting and every stage that delivered to it reopens for correction. Their recorded output is cleared, so it has to be re-entered.`}
|
||||||
|
confirmLabel="Reject intake"
|
||||||
|
onConfirm={() =>
|
||||||
|
submit(() => productionRunsApi.rejectIntake(run.runId, stage.runStageId, {}, idempotencyKey.current))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={confirm === "rejectTerminal"} onOpenChange={(open) => !open && setConfirm(null)}>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title="Reject and rework the whole run?"
|
||||||
|
description="Every stage resets and the run starts over as a rework pass. Materials already consumed stay in the cost pool — no stock is returned — so raise planned quantities before restarting if more will be needed."
|
||||||
|
confirmLabel="Reject for rework"
|
||||||
|
onConfirm={() =>
|
||||||
|
submit(() => productionRunsApi.rejectTerminal(run.runId, stage.runStageId, {}, idempotencyKey.current))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split out only because the input card has three mutually exclusive quantity presentations. */
|
||||||
|
function InputCard({
|
||||||
|
input,
|
||||||
|
editable,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
itemName,
|
||||||
|
uomName,
|
||||||
|
}: {
|
||||||
|
input: RunStageInput
|
||||||
|
editable: boolean
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
itemName: (id: number | null) => string
|
||||||
|
uomName: (id: number) => string
|
||||||
|
}) {
|
||||||
|
const isUpstream = input.source === "Upstream"
|
||||||
|
const short = isUpstream && input.deliveredQty < input.plannedQty
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||||
|
{isUpstream ? "Upstream work in progress" : itemName(input.itemId)}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{uomName(input.uomId)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
{editable ? (
|
||||||
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">Planned</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onValueChange(e.target.value)}
|
||||||
|
className="h-8 w-28 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<Row label="Planned">{fmt(input.plannedQty)}</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUpstream && (
|
||||||
|
<Row label="Delivered">
|
||||||
|
<span className={cn(short ? "text-warning" : "text-success")}>{fmt(input.deliveredQty)}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Consumed/returned figures are in the item's BASE uom, while `plannedQty` above is in the
|
||||||
|
input's declared uom — an input declared in "box of 12" shows planned 3 and consumed 36.
|
||||||
|
Labelled explicitly so the two are never read as the same unit.
|
||||||
|
*/}
|
||||||
|
{input.consumedQty > 0 && (
|
||||||
|
<>
|
||||||
|
<Row label="Consumed (base)">
|
||||||
|
{fmt(input.consumedQty)} · {money(input.consumedValue)}
|
||||||
|
</Row>
|
||||||
|
{input.returnedQty > 0 && (
|
||||||
|
<Row label="Returned (base)">
|
||||||
|
{fmt(input.returnedQty)} · {money(input.returnedValue)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
|
||||||
|
function CustomFields({
|
||||||
|
stage,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
stage: RunStage
|
||||||
|
values: Record<string, unknown>
|
||||||
|
onChange: (next: Record<string, unknown>) => void
|
||||||
|
}) {
|
||||||
|
if (stage.fieldDefs.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Checks</h3>
|
||||||
|
<CustomFieldForm defs={stage.fieldDefs} values={values} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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's planned inputs and outputs are scaled from the template's per-batch figures against
|
||||||
|
this target. The authoritative numbers come back with the run and stay editable until each stage starts.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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'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,14 +156,9 @@ 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">
|
<div>
|
||||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||||
<ArrowLeft className="size-5" />
|
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
|
||||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -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,14 +155,9 @@ 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">
|
<div>
|
||||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||||
<ArrowLeft className="size-5" />
|
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
|
||||||
<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}>
|
||||||
@@ -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
Reference in New Issue
Block a user