Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a271d1832e | |||
| 22657f0910 | |||
| 76484c7268 | |||
| 96f81cb03a | |||
| cb3f1e5cde | |||
| c3fef88bcf | |||
| 7d6e597389 | |||
| 415ac94ab2 | |||
| 4561ef7ba8 | |||
| b12adebaa0 | |||
| 0b95d6f1cd | |||
| d35b076435 | |||
| c564916c60 | |||
| 86bb4d4908 | |||
| 816ffbbfb6 | |||
| ae20bc4e34 | |||
| 8484601494 |
+8
-5
@@ -31,8 +31,11 @@ Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Migrations ─────────────────────────────────────────────────────────
|
||||
# New EF Core migrations are not committed. Note the 4 migrations already in
|
||||
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
|
||||
# not apply to tracked files — so edits to those still get committed as normal.
|
||||
# Untracking them too takes `git rm --cached`.
|
||||
**/Migrations/
|
||||
# Reverted 2026-07-31: excluding new EF Core migrations while
|
||||
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
||||
# migrations add` after the initial 4 silently produced a migration git would
|
||||
# never see, while the (tracked) snapshot's changes committed normally —
|
||||
# so the snapshot kept claiming tables existed that no migration in git
|
||||
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
|
||||
# tables were missing from the actual database for exactly this reason.
|
||||
# Migrations now stay tracked like any other source file — commit them.
|
||||
|
||||
@@ -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,43 @@
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic reverse proxy into the external General Ledger service — forwards every
|
||||
/// method/path/query/body under this prefix verbatim via
|
||||
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
|
||||
/// body) unchanged. No endpoint-specific shape lives here; see
|
||||
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
|
||||
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
|
||||
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
|
||||
/// only and is never exposed to the frontend.
|
||||
/// </summary>
|
||||
[Route("api/v1/gl")]
|
||||
public sealed class GeneralLedgerController : ApiControllerBase
|
||||
{
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
|
||||
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
|
||||
|
||||
[HttpGet("{**path}")]
|
||||
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
|
||||
|
||||
[HttpPost("{**path}")]
|
||||
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
|
||||
|
||||
[HttpPut("{**path}")]
|
||||
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
||||
|
||||
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
||||
{
|
||||
var body = method == HttpMethod.Get ? null : Request.Body;
|
||||
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = result.StatusCode,
|
||||
Content = result.Body,
|
||||
ContentType = result.ContentType ?? "application/json"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
|
||||
[Route("api/v1/production-runs")]
|
||||
public sealed class ProductionRunsController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionRunService _runs;
|
||||
|
||||
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] ProductionRunStatus? status,
|
||||
[FromQuery] int? templateId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{runId:int}")]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
|
||||
{
|
||||
var result = await _runs.GetAsync(runId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _runs.CreateAsync(request, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
|
||||
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
|
||||
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
|
||||
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
|
||||
|
||||
// --- stage actions (docs/30 §D.3) ---------------------------------------
|
||||
//
|
||||
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
|
||||
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
|
||||
// the status guards instead: a double-fire finds the stage already moved on and gets a
|
||||
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
|
||||
// deviation from §D.3's "idempotency-key honored".
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
|
||||
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<StartStageResultDto>> Start(
|
||||
int runId, int runStageId,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
|
||||
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RunStageDto>> Complete(
|
||||
int runId, int runStageId, [FromBody] CompleteStageRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
|
||||
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ApproveStageResultDto>> Approve(
|
||||
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
|
||||
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TransferResultDto>> Transfer(
|
||||
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
|
||||
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
|
||||
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
|
||||
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
|
||||
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||
|
||||
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
|
||||
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
|
||||
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
|
||||
|
||||
[HttpPost("{runId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<CancelRunResultDto>> Cancel(
|
||||
int runId, [FromBody] CancelRunRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.CancelAsync(runId, request, ct));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
||||
[Route("api/v1/production-templates")]
|
||||
public sealed class ProductionTemplatesController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionTemplateService _templates;
|
||||
|
||||
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _templates.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{templateId:int}")]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _templates.GetAsync(templateId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> Create(
|
||||
[FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _templates.CreateAsync(request, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{templateId:int}")]
|
||||
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TemplateGraphDto>> Update(
|
||||
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{templateId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(
|
||||
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _templates.SetStatusAsync(templateId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,7 @@ public static class DocumentTypes
|
||||
public const string Adjustment = "ADJ";
|
||||
public const string Count = "CNT";
|
||||
public const string PurchaseReturn = "PRET";
|
||||
|
||||
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
||||
public const string Production = "PRD";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One execution instance of a template (FR-MFG-08), numbered <c>PRD-2026-00001</c>.
|
||||
/// Every stage, input, output and edge is <b>copied</b> from the template at creation
|
||||
/// with quantities scaled by <see cref="ScaleFactor"/>, so a completed run stays
|
||||
/// readable even if the template is later edited (FR-MFG-06).
|
||||
/// <para>The run's <b>cost pool</b> is derived, never stored:
|
||||
/// <c>Σ RunStageInput.ConsumedValue − Σ RunStageInput.ReturnedValue</c>. The terminal
|
||||
/// approve divides it by the good quantity to cost the finished layer, then closes it
|
||||
/// (FR-MFG-13, <c>409 RUN_COST_CLOSED</c>).</para>
|
||||
/// Mutable aggregate with a <see cref="RowVersion"/> token. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class ProductionRun
|
||||
{
|
||||
public int RunId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int TemplateId { get; set; }
|
||||
public ProductionTemplate? Template { get; set; }
|
||||
|
||||
/// <summary>Stock inputs are consumed from, and the finished good received into, this warehouse.</summary>
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
/// <summary>Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.</summary>
|
||||
public int? OutputBinId { get; set; }
|
||||
public Bin? OutputBin { get; set; }
|
||||
|
||||
/// <summary>Target quantity of the finished item; drives <see cref="ScaleFactor"/>.</summary>
|
||||
public decimal TargetQty { get; set; }
|
||||
|
||||
/// <summary><c>TargetQty / terminalOutput.QtyPerBatch</c>, applied to every copied quantity.</summary>
|
||||
public decimal ScaleFactor { get; set; }
|
||||
|
||||
public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
|
||||
|
||||
/// <summary>Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.</summary>
|
||||
public int ReworkCount { get; set; }
|
||||
|
||||
public int? CancelReasonCodeId { get; set; }
|
||||
public ReasonCode? CancelReason { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Set by the terminal approve only. A cancelled run leaves this null.</summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<RunStage> Stages { get; set; } = new List<RunStage>();
|
||||
public ICollection<RunEdge> Edges { get; set; } = new List<RunEdge>();
|
||||
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A reusable production-line definition — the stage graph designed on the canvas
|
||||
/// (FR-MFG-01). Never hard-deleted once referenced by a run; deactivated instead
|
||||
/// (FR-MD-08 posture). Editing is locked while any run of it is InProgress
|
||||
/// (FR-MFG-06, <c>409 TEMPLATE_IN_USE</c>) — edit-lock replaces versioning, which is
|
||||
/// why runs copy display fields at creation. Mutable aggregate with a
|
||||
/// <see cref="RowVersion"/> token. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class ProductionTemplate
|
||||
{
|
||||
public int TemplateId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
|
||||
/// verbatim and never interpreted server-side.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not in docs/30 Part C — added because the builder canvas already draws these and
|
||||
/// without somewhere to keep them a save would silently discard the user's layout notes.
|
||||
/// They carry no graph semantics: no ports, no edges, and the validator never sees them.
|
||||
/// Nullable so a template that has none stores nothing rather than an empty array.
|
||||
/// </remarks>
|
||||
public string? Annotations { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<TemplateStage> Stages { get; set; } = new List<TemplateStage>();
|
||||
public ICollection<StageEdge> Edges { get; set; } = new List<StageEdge>();
|
||||
public ICollection<ProductionRun> Runs { get; set; } = new List<ProductionRun>();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A parent → child arrow copied from the template's <see cref="StageEdge"/> set at run
|
||||
/// creation.
|
||||
/// <para><b>Addition to docs/30 Part C (recorded).</b> The doc's entity model has no run
|
||||
/// edge table, but the run graph needs its own copy: deriving edges at read time through
|
||||
/// <c>RunStage.TemplateStageId → STAGE_EDGE</c> would let a later template edit silently
|
||||
/// rewrite completed-run history — the exact thing FR-MFG-06 exists to prevent — and
|
||||
/// breaks outright once that link is nulled by a stage deletion.</para>
|
||||
/// <para>Used for the run canvas, child-readiness evaluation and reject-intake's
|
||||
/// "delivering parents". Note that <b>WIP delivery is routed by
|
||||
/// <c>RunStageInput.FromRunOutputId</c>, not by these edges</b> — an edge is display and
|
||||
/// validation only.</para>
|
||||
/// </summary>
|
||||
public class RunEdge
|
||||
{
|
||||
public int RunEdgeId { get; set; }
|
||||
|
||||
public int RunId { get; set; }
|
||||
public ProductionRun? Run { get; set; }
|
||||
|
||||
public int ParentRunStageId { get; set; }
|
||||
public RunStage? ParentRunStage { get; set; }
|
||||
|
||||
public int ChildRunStageId { get; set; }
|
||||
public RunStage? ChildRunStage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One stage of a run — a copy of a <see cref="TemplateStage"/> taken at run creation
|
||||
/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
|
||||
/// <para><b>Whether this stage is terminal is derived</b>, never stored: a stage is
|
||||
/// terminal when it has no outbound <see cref="RunEdge"/>. Storing it would let it
|
||||
/// drift from the edge set.</para>
|
||||
/// </summary>
|
||||
public class RunStage
|
||||
{
|
||||
public int RunStageId { get; set; }
|
||||
|
||||
public int RunId { get; set; }
|
||||
public ProductionRun? Run { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance link back to the template stage. <b>Nullable</b>: a template PUT may
|
||||
/// delete a stage while completed/cancelled runs still reference it (the edit-lock
|
||||
/// only blocks edits during InProgress runs), so the FK is <c>SET NULL</c> rather
|
||||
/// than blocking the edit forever. Everything needed to display a historical run is
|
||||
/// copied below, which is exactly what FR-MFG-06 anticipates.
|
||||
/// </summary>
|
||||
public int? TemplateStageId { get; set; }
|
||||
public TemplateStage? TemplateStage { get; set; }
|
||||
|
||||
// --- copied from the template at run creation (FR-MFG-06) ---
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? RoleLabel { get; set; }
|
||||
public int EstimatedMinutes { get; set; }
|
||||
public decimal PosX { get; set; }
|
||||
public decimal PosY { get; set; }
|
||||
|
||||
public ProductionStageStatus Status { get; set; } = ProductionStageStatus.Waiting;
|
||||
|
||||
/// <summary>Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).</summary>
|
||||
public DateTime? ActualStartAt { get; set; }
|
||||
public DateTime? ActualEndAt { get; set; }
|
||||
|
||||
/// <summary>Copied from the template stage; definitions survive a rework.</summary>
|
||||
public string FieldDefs { get; set; } = "[]";
|
||||
|
||||
/// <summary>Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.</summary>
|
||||
public string? FieldValues { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency token. Stage actions re-read the stage inside their transaction and
|
||||
/// let this xmin check serialize concurrent requests — it is what stops two
|
||||
/// simultaneous terminal approves from both reading <c>Done</c> and posting two
|
||||
/// receipts. See the idempotency note in <c>ProductionRunService</c>.
|
||||
/// </summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<RunStageInput> Inputs { get; set; } = new List<RunStageInput>();
|
||||
public ICollection<RunStageOutput> Outputs { get; set; } = new List<RunStageOutput>();
|
||||
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable history of everything that happened to a run (docs/30 Part C). Written by
|
||||
/// every mutating action and never updated or deleted, so a run's story — including the
|
||||
/// figures discarded by each rework — survives in full.
|
||||
/// <para><b>Addition to docs/30 Part C (recorded):</b> <see cref="RunId"/>. The doc hangs
|
||||
/// events off the stage only, which leaves run-level events (cancel, terminal reject)
|
||||
/// with no home and forces the detail timeline to join through stages. Keeping both
|
||||
/// links makes <see cref="RunStageId"/> optional and the timeline a single query.</para>
|
||||
/// </summary>
|
||||
public class RunStageEvent
|
||||
{
|
||||
public int EventId { get; set; }
|
||||
|
||||
public int RunId { get; set; }
|
||||
public ProductionRun? Run { get; set; }
|
||||
|
||||
/// <summary>Null for run-level events (Cancel).</summary>
|
||||
public int? RunStageId { get; set; }
|
||||
public RunStage? RunStage { get; set; }
|
||||
|
||||
public RunStageEventType EventType { get; set; }
|
||||
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event-specific detail as jsonb — consumed layers on a Start, pulled-back
|
||||
/// quantities on a RejectIntake, the full pre-rework snapshot on a TerminalReject.
|
||||
/// Pre-serialized string, written only through <c>ProductionJson</c>.
|
||||
/// </summary>
|
||||
public string? Payload { get; set; }
|
||||
|
||||
public int UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One input line of a run stage — a copy of a <see cref="StageInput"/> with its
|
||||
/// quantity scaled at creation, plus the live consumption/delivery figures.
|
||||
/// Model: docs/30 Part C.
|
||||
/// <para><b>Stock inputs</b> accumulate <see cref="ConsumedQty"/>/<see cref="ConsumedValue"/>
|
||||
/// at each start and <see cref="ReturnedQty"/>/<see cref="ReturnedValue"/> on leftover
|
||||
/// return or run cancel. Those four columns are the whole cost pool
|
||||
/// (<c>Σ consumed − Σ returned</c>) and are deliberately <b>not</b> reset by a terminal
|
||||
/// reject — already-consumed material stays in the pool (FR-MFG-16).</para>
|
||||
/// <para><b>Upstream inputs</b> accumulate <see cref="DeliveredQty"/> as parent stages
|
||||
/// transfer WIP in. The stage becomes Ready only when every upstream input has
|
||||
/// <c>DeliveredQty >= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
|
||||
/// </summary>
|
||||
public class RunStageInput
|
||||
{
|
||||
public int RunInputId { get; set; }
|
||||
|
||||
public int RunStageId { get; set; }
|
||||
public RunStage? RunStage { get; set; }
|
||||
|
||||
public StageInputSource Source { get; set; }
|
||||
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
|
||||
public int? FromRunOutputId { get; set; }
|
||||
public RunStageOutput? FromRunOutput { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
|
||||
/// <c>max(0, PlannedQty − ConsumedQty)</c> and adds to these, so a rework restart
|
||||
/// with an unchanged planned quantity consumes nothing and one with a raised planned
|
||||
/// quantity consumes only the delta (FR-MFG-16).
|
||||
/// </summary>
|
||||
public decimal ConsumedQty { get; set; }
|
||||
public decimal ConsumedValue { get; set; }
|
||||
|
||||
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
|
||||
public decimal DeliveredQty { get; set; }
|
||||
|
||||
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
|
||||
public decimal ReturnedQty { get; set; }
|
||||
public decimal ReturnedValue { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
|
||||
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
|
||||
/// Model: docs/30 Part C.
|
||||
/// <para><b>Available to transfer is derived, never stored:</b>
|
||||
/// <c>ProducedQty − ScrappedQty − TransferredQty</c>. Every transfer path checks it and
|
||||
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
|
||||
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
|
||||
/// write-off ledger entry is posted (FR-MFG-11).</para>
|
||||
/// </summary>
|
||||
public class RunStageOutput
|
||||
{
|
||||
public int RunOutputId { get; set; }
|
||||
|
||||
public int RunStageId { get; set; }
|
||||
public RunStage? RunStage { get; set; }
|
||||
|
||||
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
|
||||
public decimal ProducedQty { get; set; }
|
||||
|
||||
public decimal ScrappedQty { get; set; }
|
||||
|
||||
/// <summary>Mandatory when <see cref="ScrappedQty"/> > 0, context <c>Production</c> (FR-MFG-11).</summary>
|
||||
public int? ScrapReasonCodeId { get; set; }
|
||||
public ReasonCode? ScrapReason { get; set; }
|
||||
|
||||
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
|
||||
public decimal TransferredQty { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A parent → child arrow on the template canvas. The edge set must form a DAG with
|
||||
/// at least one entry stage and exactly one terminal stage; that is enforced in
|
||||
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
|
||||
/// Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class StageEdge
|
||||
{
|
||||
public int EdgeId { get; set; }
|
||||
|
||||
public int TemplateId { get; set; }
|
||||
public ProductionTemplate? Template { get; set; }
|
||||
|
||||
public int ParentStageId { get; set; }
|
||||
public TemplateStage? ParentStage { get; set; }
|
||||
|
||||
public int ChildStageId { get; set; }
|
||||
public TemplateStage? ChildStage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
|
||||
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
|
||||
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
|
||||
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
|
||||
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
|
||||
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
|
||||
/// internal WIP and never touches stock or the ledger.</item>
|
||||
/// </list>
|
||||
/// Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class StageInput
|
||||
{
|
||||
public int InputId { get; set; }
|
||||
|
||||
public int StageId { get; set; }
|
||||
public TemplateStage? Stage { get; set; }
|
||||
|
||||
public StageInputSource Source { get; set; }
|
||||
|
||||
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
|
||||
public int? FromOutputId { get; set; }
|
||||
public StageOutput? FromOutput { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
|
||||
/// <b>internal WIP only</b> — <see cref="ItemId"/> is null, no stock and no ledger row
|
||||
/// is ever written for them. The terminal stage is the exception: it has exactly one
|
||||
/// output and that output <b>must</b> reference a real Item (the finished good), which
|
||||
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class StageOutput
|
||||
{
|
||||
public int OutputId { get; set; }
|
||||
|
||||
public int StageId { get; set; }
|
||||
public TemplateStage? Stage { get; set; }
|
||||
|
||||
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
|
||||
public int? ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
|
||||
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
|
||||
/// custom field definitions. Model: docs/30 Part C.
|
||||
/// </summary>
|
||||
public class TemplateStage
|
||||
{
|
||||
public int StageId { get; set; }
|
||||
|
||||
public int TemplateId { get; set; }
|
||||
public ProductionTemplate? Template { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
|
||||
public string? RoleLabel { get; set; }
|
||||
|
||||
public int EstimatedMinutes { get; set; }
|
||||
|
||||
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
|
||||
public decimal PosX { get; set; }
|
||||
public decimal PosY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
|
||||
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
|
||||
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
|
||||
/// hold canonical JSON.
|
||||
/// </summary>
|
||||
public string FieldDefs { get; set; } = "[]";
|
||||
|
||||
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
|
||||
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
|
||||
/// Lives inside the <c>field_defs</c> jsonb rather than a column, but is modelled as an
|
||||
/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
|
||||
/// <see cref="Select"/> is the only type that reads <c>options</c>.
|
||||
/// </summary>
|
||||
public enum CustomFieldType
|
||||
{
|
||||
Text,
|
||||
Number,
|
||||
Checkbox,
|
||||
Date,
|
||||
Select
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
|
||||
/// <see cref="InProgress"/> and reaches exactly one final state: it completes at the
|
||||
/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
|
||||
/// stock return (FR-MFG-17). Stored as a string.
|
||||
/// </summary>
|
||||
public enum ProductionRunStatus
|
||||
{
|
||||
InProgress,
|
||||
Completed,
|
||||
Cancelled
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
|
||||
/// <code>
|
||||
/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
|
||||
/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
|
||||
/// InProgress ──complete (produced/scrap/fields)──▶ Done
|
||||
/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
|
||||
/// </code>
|
||||
/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
|
||||
/// rework transition back into this set and is recorded as a
|
||||
/// <see cref="RunStageEventType"/> instead. Stored as a string.
|
||||
/// </summary>
|
||||
public enum ProductionStageStatus
|
||||
{
|
||||
Waiting,
|
||||
Ready,
|
||||
InProgress,
|
||||
Done,
|
||||
Approved
|
||||
}
|
||||
@@ -5,5 +5,8 @@ public enum ReasonContext
|
||||
{
|
||||
Adjustment,
|
||||
Return,
|
||||
Count
|
||||
Count,
|
||||
|
||||
/// <summary>Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).</summary>
|
||||
Production
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Kind of entry in a run's immutable history (docs/30 Part C, <c>RUN_STAGE_EVENT</c>).
|
||||
/// Every mutating action on a run writes exactly one event carrying who/when plus a
|
||||
/// jsonb payload; <see cref="TerminalReject"/> additionally snapshots the whole run's
|
||||
/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
|
||||
/// </summary>
|
||||
public enum RunStageEventType
|
||||
{
|
||||
Start,
|
||||
Complete,
|
||||
Approve,
|
||||
Transfer,
|
||||
RejectIntake,
|
||||
TerminalReject,
|
||||
LeftoverReturn,
|
||||
Cancel,
|
||||
QuantityEdit
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
|
||||
/// <see cref="Stock"/> inputs reference an Item and are FIFO-consumed from the run
|
||||
/// warehouse when the stage starts. <see cref="Upstream"/> inputs reference a direct
|
||||
/// parent stage's output and flow as internal WIP — they never touch the ledger.
|
||||
/// Stored as a string.
|
||||
/// </summary>
|
||||
public enum StageInputSource
|
||||
{
|
||||
Stock,
|
||||
Upstream
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ERPCore.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Additional <c>STOCK_LEDGER.source_doc_type</c> values for manufacturing movements
|
||||
/// (docs/30 §A.2). <c>source_doc_id</c> is always the <c>run_id</c>, so
|
||||
/// <c>LIKE 'PRD%'</c> traces every stock movement a run caused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Deviation from docs/30 §A.2 (recorded).</b> The doc proposes the long names
|
||||
/// <c>ProductionIssue</c>/<c>ProductionReceipt</c>/<c>ProductionReturn</c>/
|
||||
/// <c>ProductionCancelReturn</c>, but both <c>stock_ledger.SourceDocType</c> and
|
||||
/// <c>journal_entry_stubs.SourceDocType</c> are <c>varchar(10)</c> and every existing
|
||||
/// value is a short prefix (<c>GRN</c>, <c>TRF</c>, <c>ADJ</c>, <c>PRET</c>). Widening
|
||||
/// those columns would be a second Phase-1 schema change beyond the single deviation
|
||||
/// §A.1 declares (NFR-08), so the short codes below extend the existing convention
|
||||
/// instead. docs/30 §A.2 and §D.5 are amended to match.</para>
|
||||
/// <para>These are <b>not</b> <see cref="DocumentTypes"/> entries — production issues no
|
||||
/// document per movement. The run's own document number uses
|
||||
/// <see cref="DocumentTypes.Production"/> (<c>PRD-2026-00001</c>).</para>
|
||||
/// </remarks>
|
||||
public static class LedgerSourceTypes
|
||||
{
|
||||
/// <summary>Stock consumed by a stage start (FR-MFG-10). Outbound.</summary>
|
||||
public const string ProductionIssue = "PRDI";
|
||||
|
||||
/// <summary>Finished goods received at the terminal approve (FR-MFG-13). Inbound.</summary>
|
||||
public const string ProductionReceipt = "PRDR";
|
||||
|
||||
/// <summary>Unconsumed material returned before receipt (FR-MFG-14). Inbound.</summary>
|
||||
public const string ProductionReturn = "PRDL";
|
||||
|
||||
/// <summary>Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.</summary>
|
||||
public const string ProductionCancelReturn = "PRDC";
|
||||
}
|
||||
@@ -0,0 +1,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,55 @@
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="IGeneralLedgerClient"/>. Registered as a typed
|
||||
/// client (`AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>`) with its
|
||||
/// `BaseAddress` bound from `GeneralLedgerService:BaseUrl`. Every call attaches the
|
||||
/// shared `GeneralLedgerService:ApiKey` as `X-Api-Key` and streams the request/response
|
||||
/// body straight through, unparsed — GL's own response (status, content-type, body) is
|
||||
/// returned exactly as received; nothing here reshapes it.
|
||||
/// </summary>
|
||||
public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configuration) : IGeneralLedgerClient
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _apiKey = configuration["GeneralLedgerService:ApiKey"] ?? string.Empty;
|
||||
|
||||
public async Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
{
|
||||
var relativeUri = path.TrimStart('/') + queryString;
|
||||
using var request = new HttpRequestMessage(method, relativeUri);
|
||||
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
|
||||
|
||||
if (body is not null && method != HttpMethod.Get)
|
||||
{
|
||||
var content = new StreamContent(body);
|
||||
if (!string.IsNullOrEmpty(contentType))
|
||||
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
request.Content = content;
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, ct);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service timed out.", 503);
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
return new GeneralLedgerResponse
|
||||
{
|
||||
StatusCode = (int)response.StatusCode,
|
||||
ContentType = response.Content.Headers.ContentType?.ToString(),
|
||||
Body = responseBody
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Raw HTTP result from the external General Ledger service — status code, content
|
||||
/// type, and body exactly as GL returned them. Deliberately un-reshaped: GL's own
|
||||
/// envelope (see the GL service's own API reference) is passed through byte-for-byte
|
||||
/// so its camelCase-success/PascalCase-error inconsistency and full decimal precision
|
||||
/// survive the hop unchanged (docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// </summary>
|
||||
public sealed class GeneralLedgerResponse
|
||||
{
|
||||
public int StatusCode { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string Body { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Typed HTTP transport to the external General Ledger service. Injects the shared
|
||||
/// `X-Api-Key` secret and forwards method/path/query/body/content-type verbatim —
|
||||
/// see docs/12-GENERAL-LEDGER-INTEGRATION.md. Internal: only
|
||||
/// <see cref="ERPCore.Services.Interfaces.IGeneralLedgerService"/> consumes this.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerClient
|
||||
{
|
||||
Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
}
|
||||
@@ -36,7 +36,9 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
|
||||
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
|
||||
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,26 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
||||
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
||||
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
|
||||
// IDs 28-31 (not 19-22): 19-22 were already claimed by the Ledgers permissions below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
new Permission { PermissionId = 28, Code = "NAV:procurement.requisitions", SubNavItemId = 17 },
|
||||
new Permission { PermissionId = 29, Code = "NAV:procurement.rfqs", SubNavItemId = 18 },
|
||||
new Permission { PermissionId = 30, Code = "NAV:procurement.purchase-orders", SubNavItemId = 19 },
|
||||
new Permission { PermissionId = 31, Code = "NAV:procurement.purchase-returns", SubNavItemId = 20 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:ledgers", NavItemId = 11 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:ledgers.trial-balance", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:ledgers.balance-sheet", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:ledgers.general-ledger", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 23, Code = "NAV:ledgers.profit-and-loss", SubNavItemId = 12 },
|
||||
new Permission { PermissionId = 24, Code = "NAV:ledgers.cash-flow", SubNavItemId = 13 },
|
||||
new Permission { PermissionId = 25, Code = "NAV:ledgers.budget-vs-actual", SubNavItemId = 14 },
|
||||
new Permission { PermissionId = 27, Code = "NAV:ledgers.tax-report", SubNavItemId = 16 },
|
||||
// Moved under the new Accounts nav item (2026-07-31) — same PermissionId (26), just a
|
||||
// renamed Code, so any role already granted this permission keeps it.
|
||||
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
|
||||
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,25 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
|
||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 12, NavItemId = 11, Code = "ledgers.profit-and-loss", Label = "Profit & Loss", Href = "/dashboard/ledgers/profit-and-loss", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 13, NavItemId = 11, Code = "ledgers.cash-flow", Label = "Cash Flow", Href = "/dashboard/ledgers/cash-flow", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 14, NavItemId = 11, Code = "ledgers.budget-vs-actual", Label = "Budget vs Actual", Href = "/dashboard/ledgers/budget-vs-actual", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 16, NavItemId = 11, Code = "ledgers.tax-report", Label = "Tax Report", Href = "/dashboard/ledgers/tax-report", SortOrder = 7 },
|
||||
// Moved under the new Accounts nav item (2026-07-31) — kept the same SubNavItemId (15)
|
||||
// rather than delete+recreate, so any role that already had this permission granted
|
||||
// doesn't silently lose it just because the section it lives under changed.
|
||||
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ public static class DataSeeder
|
||||
("WRONG", "Wrong Item", ReasonContext.Return),
|
||||
("OVER", "Over-supply", ReasonContext.Return),
|
||||
("QREJ", "Quality Reject", ReasonContext.Return),
|
||||
// Manufacturing (docs/30 §A.2) — scrap at stage complete, leftover return before
|
||||
// receipt, and the mandatory reason on a run cancel.
|
||||
("PRD-SCRAP", "Production Scrap", ReasonContext.Production),
|
||||
("PRD-LEFTOVER", "Production Leftover Return", ReasonContext.Production),
|
||||
("PRD-CANCEL", "Production Run Cancelled", ReasonContext.Production),
|
||||
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
||||
];
|
||||
|
||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
||||
|
||||
@@ -126,6 +126,21 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
|
||||
public DbSet<Payslip> Payslips => Set<Payslip>();
|
||||
|
||||
// --- Manufacturing: production templates (docs/30-BACKEND-PHASE2.md Part C) ---
|
||||
public DbSet<ProductionTemplate> ProductionTemplates => Set<ProductionTemplate>();
|
||||
public DbSet<TemplateStage> TemplateStages => Set<TemplateStage>();
|
||||
public DbSet<StageEdge> StageEdges => Set<StageEdge>();
|
||||
public DbSet<StageInput> StageInputs => Set<StageInput>();
|
||||
public DbSet<StageOutput> StageOutputs => Set<StageOutput>();
|
||||
|
||||
// --- Manufacturing: production runs (docs/30-BACKEND-PHASE2.md Part C) ---
|
||||
public DbSet<ProductionRun> ProductionRuns => Set<ProductionRun>();
|
||||
public DbSet<RunStage> RunStages => Set<RunStage>();
|
||||
public DbSet<RunEdge> RunEdges => Set<RunEdge>();
|
||||
public DbSet<RunStageInput> RunStageInputs => Set<RunStageInput>();
|
||||
public DbSet<RunStageOutput> RunStageOutputs => Set<RunStageOutput>();
|
||||
public DbSet<RunStageEvent> RunStageEvents => Set<RunStageEvent>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
+3128
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLedgersNavSeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[] { 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 19, "NAV:ledgers", 11, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 9, "ledgers.trial-balance", "/dashboard/ledgers/trial-balance", null, "Trial Balance", 11, 1 },
|
||||
{ 10, "ledgers.balance-sheet", "/dashboard/ledgers/balance-sheet", null, "Balance Sheet", 11, 2 },
|
||||
{ 11, "ledgers.general-ledger", "/dashboard/ledgers/general-ledger", null, "General Ledger", 11, 3 },
|
||||
{ 12, "ledgers.profit-and-loss", "/dashboard/ledgers/profit-and-loss", null, "Profit & Loss", 11, 4 },
|
||||
{ 13, "ledgers.cash-flow", "/dashboard/ledgers/cash-flow", null, "Cash Flow", 11, 5 },
|
||||
{ 14, "ledgers.budget-vs-actual", "/dashboard/ledgers/budget-vs-actual", null, "Budget vs Actual", 11, 6 },
|
||||
{ 15, "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", null, "Cash / Bank Accounts", 11, 7 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 20, "NAV:ledgers.trial-balance", null, 9 },
|
||||
{ 21, "NAV:ledgers.balance-sheet", null, 10 },
|
||||
{ 22, "NAV:ledgers.general-ledger", null, 11 },
|
||||
{ 23, "NAV:ledgers.profit-and-loss", null, 12 },
|
||||
{ 24, "NAV:ledgers.cash-flow", null, 13 },
|
||||
{ 25, "NAV:ledgers.budget-vs-actual", null, 14 },
|
||||
{ 26, "NAV:ledgers.bank-accounts", null, 15 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 19);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 20);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 21);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 22);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 23);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 24);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 25);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 26);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 9);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 10);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 11);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 12);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 13);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 14);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 11);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaxReportNavSeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
column: "SortOrder",
|
||||
value: 8);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[] { 16, "ledgers.tax-report", "/dashboard/ledgers/tax-report", null, "Tax Report", 11, 7 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 27, "NAV:ledgers.tax-report", null, 16 });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 27);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 16);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
column: "SortOrder",
|
||||
value: 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend/ERPCore/Infra/Persistence/Migrations/20260730105356_FixProcurementNavIdCollision.Designer.cs
Generated
+5152
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixProcurementNavIdCollision : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 17, "procurement.requisitions", "/dashboard/procurement/requisitions", null, "Requisitions", 4, 1 },
|
||||
{ 18, "procurement.rfqs", "/dashboard/procurement/rfqs", null, "RFQs", 4, 2 },
|
||||
{ 19, "procurement.purchase-orders", "/dashboard/procurement/purchase-orders", null, "Purchase Orders", 4, 3 },
|
||||
{ 20, "procurement.purchase-returns", "/dashboard/procurement/purchase-returns", null, "Purchase Returns", 4, 4 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 28, "NAV:procurement.requisitions", null, 17 },
|
||||
{ 29, "NAV:procurement.rfqs", null, 18 },
|
||||
{ 30, "NAV:procurement.purchase-orders", null, 19 },
|
||||
{ 31, "NAV:procurement.purchase-returns", null, 20 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 28);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 29);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 30);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 31);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 17);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 18);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 19);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+5199
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountsNavSeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[] { 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 26,
|
||||
column: "Code",
|
||||
value: "NAV:accounts.bank-accounts");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
|
||||
values: new object[] { "accounts.bank-accounts", "/dashboard/accounts/bank-accounts", 12, 1 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 32, "NAV:accounts", 12, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 21, "accounts.cheque-books", "/dashboard/accounts/cheque-books", null, "Cheque Books", 12, 2 },
|
||||
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 33, "NAV:accounts.cheque-books", null, 21 },
|
||||
{ 34, "NAV:accounts.received-cheques", null, 22 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 32);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 33);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 34);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 21);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 22);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 12);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 26,
|
||||
column: "Code",
|
||||
value: "NAV:ledgers.bank-accounts");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
|
||||
values: new object[] { "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", 11, 8 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+6054
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class production : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+6054
File diff suppressed because it is too large
Load Diff
+1871
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -10,6 +11,7 @@ using ERPCore.Services;
|
||||
using ERPCore.Services.Auth;
|
||||
using ERPCore.Services.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Production;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
@@ -50,6 +52,15 @@ builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
||||
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
||||
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
||||
|
||||
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
|
||||
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
||||
c.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
||||
|
||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -81,6 +92,7 @@ builder.Services.AddScoped<IRfqService, RfqService>();
|
||||
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
|
||||
|
||||
// Stock core + goods receipt (docs/11 §4–5)
|
||||
builder.Services.AddScoped<IUomConverter, UomConverter>();
|
||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
@@ -97,6 +109,9 @@ builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
|
||||
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
||||
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
|
||||
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
|
||||
builder.Services.AddScoped<IBranchService, BranchService>();
|
||||
@@ -133,12 +148,22 @@ builder.Services.AddScoped<IPayslipService, PayslipService>();
|
||||
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
|
||||
builder.Services.AddScoped<IHrReportService, HrReportService>();
|
||||
|
||||
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md Part A). Templates stand
|
||||
// alone; runs consume FifoCostingService for all stock movement. ProductionGraphValidator
|
||||
// is deliberately unregistered — it is a pure static algorithm, not an injected service.
|
||||
builder.Services.AddScoped<IProductionTemplateService, ProductionTemplateService>();
|
||||
builder.Services.AddScoped<IProductionRunService, ProductionRunService>();
|
||||
|
||||
// Health checks (EF Core DB)
|
||||
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
|
||||
|
||||
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" }));
|
||||
builder.Services.AddSwaggerGen(o =>
|
||||
{
|
||||
o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" });
|
||||
o.CustomSchemaIds(t => t.FullName!.Replace("+", "."));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private readonly IGeneralLedgerClient _client;
|
||||
|
||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
||||
}
|
||||
@@ -33,9 +33,9 @@ public sealed class GrnService : IGrnService
|
||||
private readonly IRepository<Bin> _bins;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<UomConversion> _conversions;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -45,7 +45,7 @@ public sealed class GrnService : IGrnService
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_grns = grns;
|
||||
@@ -57,9 +57,9 @@ public sealed class GrnService : IGrnService
|
||||
_bins = bins;
|
||||
_vendors = vendors;
|
||||
_batches = batches;
|
||||
_conversions = conversions;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_uomConverter = uomConverter;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
@@ -345,19 +345,14 @@ public sealed class GrnService : IGrnService
|
||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||
}
|
||||
|
||||
private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
/// <summary>
|
||||
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
|
||||
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
|
||||
/// identical, so receive costing is unchanged.
|
||||
/// </summary>
|
||||
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
return (qty, unitCostPerUom);
|
||||
|
||||
var conv = await _conversions.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
|
||||
|
||||
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
||||
}
|
||||
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
|
||||
|
||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Append an immutable ledger entry (value = qtyBase × unitCost).</summary>
|
||||
/// <summary>
|
||||
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
||||
/// </summary>
|
||||
/// <param name="valueOverride">
|
||||
/// Posts this exact value instead of deriving it from qty × unit cost. Needed when the
|
||||
/// authoritative figure is a total rather than a rate: a production receipt must carry the
|
||||
/// run's cost pool exactly, but <c>unitCost = pool / goodQty</c> rounds to 6 dp, and at
|
||||
/// 100+ units that rounding error exceeds the ledger's 4 dp tick — so the derived value
|
||||
/// would drift from the pool (FR-MFG-13). Also used by leftover and cancel returns, whose
|
||||
/// value is the exact consumed residual. Omit for every rate-driven movement.
|
||||
/// </param>
|
||||
Task<StockLedger> PostLedgerAsync(
|
||||
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default);
|
||||
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
|
||||
decimal? valueOverride = null);
|
||||
|
||||
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
|
||||
Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Single entry point into the external General Ledger service — the one function
|
||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
||||
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
|
||||
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// No business logic lives here yet; this pass only connects the transport.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerService
|
||||
{
|
||||
Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19). Every stock-affecting
|
||||
/// action runs inside a single <c>ExecuteInTransactionAsync</c> scope and consumes stock
|
||||
/// only through <c>IFifoCostingService</c> (NFR-02/NFR-05).
|
||||
/// </summary>
|
||||
public interface IProductionRunService
|
||||
{
|
||||
Task<PagedResponse<RunSummaryDto>> ListAsync(
|
||||
PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
|
||||
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
|
||||
/// <c>PRD-…</c> document number, and leaves entry stages Ready with the rest Waiting.
|
||||
/// </summary>
|
||||
Task<ETagged<RunGraphDto>> CreateAsync(CreateRunRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Per-run quantity override on a stage that has not started
|
||||
/// (<c>409 STAGE_NOT_EDITABLE</c> once it has). Re-evaluates the stage's readiness,
|
||||
/// since raising an upstream input's planned quantity can un-ready it.
|
||||
/// </summary>
|
||||
Task<RunStageDto> UpdateStageQuantitiesAsync(
|
||||
int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// <c>Ready → InProgress</c> (FR-MFG-10). FIFO-consumes every Stock input from the run
|
||||
/// warehouse in one transaction and stamps <c>actualStartAt</c>.
|
||||
/// <para>Consumes <c>max(0, plannedBase − consumedQty)</c> per input, so a rework
|
||||
/// restart with an unchanged planned quantity consumes nothing and one with a raised
|
||||
/// planned quantity consumes only the delta (FR-MFG-16).</para>
|
||||
/// </summary>
|
||||
Task<StartStageResultDto> StartStageAsync(int runId, int runStageId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// <c>InProgress → Done</c> (FR-MFG-11). Records produced and scrapped quantities per
|
||||
/// output plus the custom field values. A re-complete after a rework <b>overwrites</b>
|
||||
/// the previous figures rather than adding to them.
|
||||
/// </summary>
|
||||
Task<RunStageDto> CompleteStageAsync(
|
||||
int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// <c>Done → Approved</c> (FR-MFG-12/13). Non-terminal: hands WIP to the children,
|
||||
/// defaulting to the full available quantity. Terminal: posts the production receipt —
|
||||
/// a finished-goods layer costed at <c>costPool / goodQty</c> — and completes the run.
|
||||
/// </summary>
|
||||
Task<ApproveStageResultDto> ApproveStageAsync(
|
||||
int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Later partial transfer of a remainder held on an already-Approved stage (FR-MFG-12),
|
||||
/// never exceeding produced − scrapped − already transferred.
|
||||
/// </summary>
|
||||
Task<TransferResultDto> TransferAsync(
|
||||
int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns unconsumed material to stock before the receipt closes the pool (FR-MFG-14).
|
||||
/// The inbound layer is created at the <b>weighted cost actually consumed</b> for that
|
||||
/// input, so the move is cost-preserving and the pool reduces by exactly what leaves it.
|
||||
/// <c>409 RUN_COST_CLOSED</c> once the run has completed.
|
||||
/// </summary>
|
||||
Task<ReturnLeftoverResultDto> ReturnLeftoverAsync(
|
||||
int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downstream reject (FR-MFG-15): this stage rejects the work it received, its delivering
|
||||
/// parents revert <c>Approved → InProgress</c> with their transferred quantities pulled
|
||||
/// back, and this stage returns to Waiting. Consumed stock stays consumed.
|
||||
/// </summary>
|
||||
Task<RejectIntakeResultDto> RejectIntakeAsync(
|
||||
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Terminal reject (FR-MFG-16): resets the whole run to its starting stages, increments
|
||||
/// <c>reworkCount</c> and snapshots the discarded figures into the event history.
|
||||
/// Already-consumed material remains in the cost pool.
|
||||
/// </summary>
|
||||
Task<TerminalRejectResultDto> RejectTerminalAsync(
|
||||
int runId, int runStageId, RejectRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels an in-progress run (FR-MFG-17). Net consumed-and-not-returned stock goes back
|
||||
/// at its consumed weighted cost; scrapped output quantities are written off on the event.
|
||||
/// <c>409 RUN_NOT_CANCELLABLE</c> for a completed run.
|
||||
/// </summary>
|
||||
Task<CancelRunResultDto> CancelAsync(int runId, CancelRunRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Production template CRUD and graph validation (docs/30 §D.1, FR-MFG-01..07).
|
||||
/// </summary>
|
||||
public interface IProductionTemplateService
|
||||
{
|
||||
Task<PagedResponse<TemplateSummaryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the whole graph. Requires the current row version (<c>If-Match</c>) and is
|
||||
/// refused with <c>409 TEMPLATE_IN_USE</c> while any run of this template is
|
||||
/// InProgress — edit-lock stands in for versioning (FR-MFG-06).
|
||||
/// </summary>
|
||||
Task<ETagged<TemplateGraphDto>> UpdateAsync(
|
||||
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Activate/deactivate (FR-MFG-01). Deliberately <b>not</b> edit-locked: deactivating is
|
||||
/// the "never delete a referenced master" path (FR-MD-08) and only stops <i>new</i> runs
|
||||
/// being started, so it must stay available while runs are in flight.
|
||||
/// </summary>
|
||||
Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
|
||||
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
|
||||
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
|
||||
/// one place that bridges the two.</para>
|
||||
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
|
||||
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
|
||||
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
|
||||
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
|
||||
/// mis-cost the run).</para>
|
||||
/// </remarks>
|
||||
public interface IUomConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
|
||||
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
|
||||
/// conversion is defined for the item from that UOM to its base.
|
||||
/// </summary>
|
||||
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
|
||||
/// production stage input declares a quantity; its cost comes from the FIFO layers it
|
||||
/// consumes, not from the document).
|
||||
/// </summary>
|
||||
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services.Production;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a template stage graph (FR-MFG-02, FR-MFG-04, FR-MFG-05) and throws the
|
||||
/// matching <c>422 GRAPH_*</c> domain error on the first violation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>This is an <b>algorithm, not a service</b> — pure, synchronous, no database and
|
||||
/// no DI, in the spirit of <c>FifoCostingService</c> being the one place FIFO lives. It
|
||||
/// deliberately has no interface: <c>Services/Interfaces/</c> exists for things that get
|
||||
/// injected, and registering this would buy nothing.</para>
|
||||
/// <para>It works entirely in <b>keys</b>, never database ids, so the identical code path
|
||||
/// runs for a POST (where nothing has an id yet) and a PUT (where most things do). All
|
||||
/// database-dependent checks — does this item exist, is it Active, does this UOM exist —
|
||||
/// stay in <c>ProductionTemplateService</c> so this stays free of I/O.</para>
|
||||
/// <para>Checks run cheapest-first and fail fast, and every message names the offending
|
||||
/// stage or edge so the canvas can focus it (docs/21 §2).</para>
|
||||
/// </remarks>
|
||||
public static class ProductionGraphValidator
|
||||
{
|
||||
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
|
||||
|
||||
public sealed record OutputDraft(string Key, string Name, int? ItemId);
|
||||
|
||||
public sealed record StageDraft(
|
||||
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
|
||||
|
||||
public readonly record struct EdgeDraft(string ParentKey, string ChildKey);
|
||||
|
||||
/// <summary>
|
||||
/// Throws on the first rule violation; returns silently for a valid graph.
|
||||
/// </summary>
|
||||
public static void Validate(IReadOnlyList<StageDraft> stages, IReadOnlyList<EdgeDraft> edges)
|
||||
{
|
||||
// 1 — structural hygiene. These are all blocked in the canvas at draw time
|
||||
// (docs/21 §2); this is the server backstop for a hand-rolled request.
|
||||
if (stages.Count == 0)
|
||||
throw Invalid("A template must have at least one stage.");
|
||||
|
||||
var stageByKey = new Dictionary<string, StageDraft>(StringComparer.Ordinal);
|
||||
foreach (var s in stages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s.Key))
|
||||
throw Invalid("Every stage requires a key.");
|
||||
if (!stageByKey.TryAdd(s.Key, s))
|
||||
throw Invalid($"Duplicate stage key '{s.Key}'.");
|
||||
}
|
||||
|
||||
// Output keys are unique across the whole template, not just within a stage —
|
||||
// an Upstream input names one by key alone, so a collision would be ambiguous.
|
||||
var outputOwner = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var s in stages)
|
||||
foreach (var o in s.Outputs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(o.Key))
|
||||
throw Invalid($"Every output of stage '{s.Name}' requires a key.");
|
||||
if (!outputOwner.TryAdd(o.Key, s.Key))
|
||||
throw Invalid($"Duplicate output key '{o.Key}' (stage '{s.Name}').");
|
||||
}
|
||||
|
||||
var edgeSet = new HashSet<(string, string)>();
|
||||
foreach (var e in edges)
|
||||
{
|
||||
if (!stageByKey.ContainsKey(e.ParentKey) || !stageByKey.ContainsKey(e.ChildKey))
|
||||
throw Invalid($"Edge '{e.ParentKey}' → '{e.ChildKey}' references a stage that is not in the payload.");
|
||||
if (string.Equals(e.ParentKey, e.ChildKey, StringComparison.Ordinal))
|
||||
throw Invalid($"Stage '{stageByKey[e.ParentKey].Name}' cannot connect to itself.");
|
||||
if (!edgeSet.Add((e.ParentKey, e.ChildKey)))
|
||||
throw Invalid($"Duplicate edge '{stageByKey[e.ParentKey].Name}' → '{stageByKey[e.ChildKey].Name}'.");
|
||||
}
|
||||
|
||||
var children = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
|
||||
var parents = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
|
||||
foreach (var (parent, child) in edgeSet)
|
||||
{
|
||||
children[parent].Add(child);
|
||||
parents[child].Add(parent);
|
||||
}
|
||||
|
||||
// 2 — cycle detection by Kahn's algorithm. If the toposort can't reach every
|
||||
// stage, the unreached set is exactly the stages trapped in (or downstream of)
|
||||
// a cycle, which is what the client highlights.
|
||||
var indegree = stages.ToDictionary(s => s.Key, s => parents[s.Key].Count, StringComparer.Ordinal);
|
||||
var queue = new Queue<string>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||
var sorted = 0;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var key = queue.Dequeue();
|
||||
sorted++;
|
||||
foreach (var child in children[key])
|
||||
if (--indegree[child] == 0)
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
|
||||
if (sorted != stages.Count)
|
||||
{
|
||||
var trapped = indegree.Where(kv => kv.Value > 0).Select(kv => stageByKey[kv.Key].Name);
|
||||
throw new DomainException(ErrorCodes.GraphCycle,
|
||||
$"The stage graph contains a cycle involving: {string.Join(", ", trapped)}.", 422);
|
||||
}
|
||||
|
||||
// 3 — exactly one terminal stage (multiple starts may converge, but the line must
|
||||
// end in one place, because that single stage is what receives the finished good).
|
||||
var terminals = stages.Where(s => children[s.Key].Count == 0).ToList();
|
||||
if (terminals.Count != 1)
|
||||
throw new DomainException(ErrorCodes.GraphTerminalCount,
|
||||
terminals.Count == 0
|
||||
? "The stage graph has no final stage."
|
||||
: $"The stage graph must converge to exactly one final stage, but {terminals.Count} have no outgoing connection: {string.Join(", ", terminals.Select(t => t.Name))}.",
|
||||
422);
|
||||
|
||||
var terminal = terminals[0];
|
||||
var entries = stages.Where(s => parents[s.Key].Count == 0).ToList();
|
||||
|
||||
// 4 — connectivity. One traversal each way subsumes all three of "no disconnected
|
||||
// stages", "every stage reachable from an entry" and "every stage reaches the
|
||||
// terminal": an isolated stage simply appears in neither set. A lone stage is both
|
||||
// an entry and the terminal, so it falls out correctly with no special case.
|
||||
//
|
||||
// Kept as defence in depth, but note it is unreachable once checks 2 and 3 pass:
|
||||
// in an acyclic graph every stage is reachable from some source, and if exactly one
|
||||
// stage lacks an outbound edge then every stage necessarily reaches it. An isolated
|
||||
// stage therefore surfaces as GRAPH_TERMINAL_COUNT (it is a second terminal), which
|
||||
// is the better message anyway because it names both offenders. Verified by the M2
|
||||
// smoke test, which asserts that behaviour explicitly.
|
||||
var fromEntry = Reach(entries.Select(s => s.Key), children);
|
||||
var toTerminal = Reach([terminal.Key], parents);
|
||||
|
||||
var stranded = stages.Where(s => !fromEntry.Contains(s.Key) || !toTerminal.Contains(s.Key)).ToList();
|
||||
if (stranded.Count > 0)
|
||||
throw new DomainException(ErrorCodes.GraphDisconnected,
|
||||
$"Every stage must sit on a path from a starting stage to '{terminal.Name}', but these do not: {string.Join(", ", stranded.Select(s => s.Name))}.",
|
||||
422);
|
||||
|
||||
// 5 — input sources (FR-MFG-04). An Upstream input may only draw from an output of
|
||||
// a DIRECT parent: allowing a grandparent's output would mean WIP skipping a stage.
|
||||
foreach (var s in stages)
|
||||
{
|
||||
var allowed = parents[s.Key]
|
||||
.SelectMany(p => stageByKey[p].Outputs.Select(o => o.Key))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var input in s.Inputs)
|
||||
{
|
||||
if (input.Source == StageInputSource.Upstream)
|
||||
{
|
||||
if (input.ItemId is not null)
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream and cannot also reference an item.", 422);
|
||||
if (string.IsNullOrWhiteSpace(input.FromOutputKey))
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream but names no source output.", 422);
|
||||
if (!allowed.Contains(input.FromOutputKey))
|
||||
{
|
||||
var owner = outputOwner.TryGetValue(input.FromOutputKey, out var ownerKey)
|
||||
? $"'{stageByKey[ownerKey].Name}' is not a direct parent of '{s.Name}'"
|
||||
: $"output '{input.FromOutputKey}' does not exist";
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' must draw from a direct parent's output — {owner}.", 422);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (input.ItemId is null)
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and requires an item.", 422);
|
||||
if (!string.IsNullOrWhiteSpace(input.FromOutputKey))
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6 — outputs (FR-MFG-05). The terminal stage produces exactly one real Item (the
|
||||
// finished good the receipt creates a layer for); every other output is internal
|
||||
// WIP and must stay item-less, or it would imply stock that never exists.
|
||||
if (terminal.Outputs.Count != 1)
|
||||
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
|
||||
$"The final stage '{terminal.Name}' must have exactly one output, but has {terminal.Outputs.Count}.", 422);
|
||||
|
||||
if (terminal.Outputs[0].ItemId is null)
|
||||
throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
|
||||
$"The output of the final stage '{terminal.Name}' must reference the finished item.", 422);
|
||||
|
||||
foreach (var s in stages.Where(s => !ReferenceEquals(s, terminal)))
|
||||
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
|
||||
throw Invalid(
|
||||
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
|
||||
}
|
||||
|
||||
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
|
||||
private static HashSet<string> Reach(IEnumerable<string> roots, Dictionary<string, List<string>> next)
|
||||
{
|
||||
var seen = new HashSet<string>(roots, StringComparer.Ordinal);
|
||||
var queue = new Queue<string>(seen);
|
||||
while (queue.Count > 0)
|
||||
foreach (var n in next[queue.Dequeue()])
|
||||
if (seen.Add(n))
|
||||
queue.Enqueue(n);
|
||||
return seen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structural violations the client already prevents and docs/30 §D.4 assigns no
|
||||
/// dedicated code to — still 422, still carrying a message that names the culprit.
|
||||
/// </summary>
|
||||
private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ERPCore.Services.Production;
|
||||
|
||||
/// <summary>
|
||||
/// The one serializer for every jsonb column in the manufacturing module —
|
||||
/// <c>template_stages.field_defs</c>, <c>run_stages.field_defs</c>/<c>field_values</c>
|
||||
/// and <c>run_stage_events.payload</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Those columns hold a pre-serialized <c>string</c> rather than a mapped POCO,
|
||||
/// following the <c>AuditLog.ChangeSet</c> precedent. Beyond consistency this keeps
|
||||
/// <c>AuditScribe</c> honest: an owned/typed jsonb mapping would surface the nested
|
||||
/// objects as their own change-tracker entries and scatter spurious audit rows across
|
||||
/// the new tables.</para>
|
||||
/// <para>Everything is written through here so a column can only ever contain canonical
|
||||
/// JSON — the raw client string is never stored, which means a malformed
|
||||
/// <c>fieldDefs</c> can't reach the database and can't later break a run created from
|
||||
/// that template.</para>
|
||||
/// </remarks>
|
||||
public static class ProductionJson
|
||||
{
|
||||
/// <summary>Matches the API's own wire format (docs/11 §1.3): camelCase, enums as strings.</summary>
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a stored column back. Returns <paramref name="fallback"/> for null/blank so a
|
||||
/// caller never has to null-check; a parse failure is a genuine data-integrity problem
|
||||
/// and is allowed to throw.
|
||||
/// </summary>
|
||||
public static T Deserialize<T>(string? json, T fallback)
|
||||
=> string.IsNullOrWhiteSpace(json) ? fallback : JsonSerializer.Deserialize<T>(json, Options) ?? fallback;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Production;
|
||||
|
||||
/// <summary>
|
||||
/// Production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07).
|
||||
/// Graph rules live in <see cref="ProductionGraphValidator"/>; everything needing the
|
||||
/// database (referential checks, the edit-lock, reconciliation) lives here.
|
||||
/// </summary>
|
||||
public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
{
|
||||
private readonly IRepository<ProductionTemplate> _templates;
|
||||
private readonly IRepository<TemplateStage> _stages;
|
||||
private readonly IRepository<StageInput> _inputs;
|
||||
private readonly IRepository<StageOutput> _outputs;
|
||||
private readonly IRepository<StageEdge> _edges;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<ProductionRun> _runs;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ProductionTemplateService(
|
||||
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
|
||||
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
|
||||
IUnitOfWork uow, ICurrentUser currentUser)
|
||||
{
|
||||
_templates = templates;
|
||||
_stages = stages;
|
||||
_inputs = inputs;
|
||||
_outputs = outputs;
|
||||
_edges = edges;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_runs = runs;
|
||||
_uow = uow;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<TemplateSummaryDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _templates.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%"));
|
||||
}
|
||||
|
||||
if (status is not null) q = q.Where(t => t.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
|
||||
// Projected to an anonymous type first, then mapped client-side: EF Core 10 cannot
|
||||
// translate a record constructor sitting alongside aggregates and a collection
|
||||
// projection (same limitation recorded for WarehouseValuationDto, 2026-07-28).
|
||||
var rows = await q
|
||||
.OrderBy(t => t.Code)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(t => new
|
||||
{
|
||||
t.TemplateId,
|
||||
t.Code,
|
||||
t.Name,
|
||||
t.Status,
|
||||
StageCount = t.Stages.Count,
|
||||
Stages = t.Stages.Select(s => new { s.StageId, s.Name }).ToList(),
|
||||
Edges = t.Edges.Select(e => new { e.ParentStageId, e.ChildStageId }).ToList(),
|
||||
// Drives the canvas edit-lock banner (docs/21 §2) and mirrors the
|
||||
// condition UpdateAsync enforces server-side.
|
||||
ActiveRunCount = t.Runs.Count(r => r.Status == ProductionRunStatus.InProgress),
|
||||
t.CreatedBy,
|
||||
t.CreatedAt
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var items = rows.Select(t => new TemplateSummaryDto(
|
||||
t.TemplateId, t.Code, t.Name, t.Status, t.StageCount,
|
||||
FlowOrderedNames(
|
||||
t.Stages.Select(s => (s.StageId, s.Name)).ToList(),
|
||||
t.Edges.Select(e => (e.ParentStageId, e.ChildStageId)).ToList()),
|
||||
t.ActiveRunCount, t.CreatedBy, t.CreatedAt)).ToList();
|
||||
|
||||
return PagedResponse<TemplateSummaryDto>.Create(items, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage names in <b>flow order</b> — upstream first — for the overview canvas, which
|
||||
/// draws each template as a line left to right (docs/21 §1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ordering by <c>StageId</c> would be insertion order, which routinely puts the terminal
|
||||
/// stage first and renders the line backwards. Kahn's algorithm over the edge set gives the
|
||||
/// real sequence, tie-broken by stage id so parallel branches come out deterministically.
|
||||
/// Falls back to id order if the graph somehow contains a cycle, so a listing can never
|
||||
/// fail because of bad data.
|
||||
/// </remarks>
|
||||
private static List<string> FlowOrderedNames(
|
||||
List<(int StageId, string Name)> stages, List<(int ParentStageId, int ChildStageId)> edges)
|
||||
{
|
||||
var nameById = stages.ToDictionary(s => s.StageId, s => s.Name);
|
||||
var children = stages.ToDictionary(s => s.StageId, _ => new List<int>());
|
||||
var indegree = stages.ToDictionary(s => s.StageId, _ => 0);
|
||||
|
||||
foreach (var (parent, child) in edges)
|
||||
{
|
||||
if (!children.ContainsKey(parent) || !indegree.ContainsKey(child)) continue;
|
||||
children[parent].Add(child);
|
||||
indegree[child]++;
|
||||
}
|
||||
|
||||
// SortedSet keeps the frontier in id order, so the output is stable run to run.
|
||||
var frontier = new SortedSet<int>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||
var ordered = new List<string>(stages.Count);
|
||||
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
var next = frontier.Min;
|
||||
frontier.Remove(next);
|
||||
ordered.Add(nameById[next]);
|
||||
|
||||
foreach (var child in children[next])
|
||||
if (--indegree[child] == 0)
|
||||
frontier.Add(child);
|
||||
}
|
||||
|
||||
return ordered.Count == stages.Count
|
||||
? ordered
|
||||
: stages.OrderBy(s => s.StageId).Select(s => s.Name).ToList();
|
||||
}
|
||||
|
||||
public async Task<ETagged<TemplateGraphDto>?> GetAsync(int templateId, CancellationToken ct = default)
|
||||
{
|
||||
var template = await LoadGraphQuery().AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TemplateId == templateId, ct);
|
||||
|
||||
if (template is null) return null;
|
||||
|
||||
// Counted separately rather than Included: the graph query already fans out over four
|
||||
// collections, and adding Runs would multiply those rows again for a single integer.
|
||||
var activeRuns = await _runs.Query().AsNoTracking()
|
||||
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, ct);
|
||||
|
||||
return new ETagged<TemplateGraphDto>(ToGraphDto(template, activeRuns), template.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<TemplateGraphDto>> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var code = request.Code.Trim();
|
||||
if (await _templates.Query().AnyAsync(t => t.Code == code, ct))
|
||||
throw new ConflictException($"A production template with code '{code}' already exists.");
|
||||
|
||||
var template = new ProductionTemplate
|
||||
{
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description?.Trim(),
|
||||
Status = EntityStatus.Active,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await ValidateAsync(request, ct);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
await _templates.AddAsync(template, token);
|
||||
BuildGraph(template, request);
|
||||
}, ct);
|
||||
|
||||
return await RequireGraphAsync(template.TemplateId, ct);
|
||||
}
|
||||
|
||||
public async Task<ETagged<TemplateGraphDto>> UpdateAsync(
|
||||
int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
await ValidateAsync(request, ct);
|
||||
|
||||
var code = request.Code.Trim();
|
||||
if (await _templates.Query().AnyAsync(t => t.Code == code && t.TemplateId != templateId, ct))
|
||||
throw new ConflictException($"A production template with code '{code}' already exists.");
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var template = await LoadGraphQuery().FirstOrDefaultAsync(t => t.TemplateId == templateId, token)
|
||||
?? throw new NotFoundException($"Production template {templateId} was not found.");
|
||||
|
||||
if (template.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict,
|
||||
"The template was modified by another request.", 412);
|
||||
|
||||
// FR-MFG-06: edit-lock instead of versioning. Checked inside the transaction to
|
||||
// keep the window small; the residual race is benign because a run copies
|
||||
// everything it needs at creation and never reads the template again.
|
||||
var activeRuns = await _runs.Query().AsNoTracking()
|
||||
.CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, token);
|
||||
if (activeRuns > 0)
|
||||
throw new DomainException(ErrorCodes.TemplateInUse,
|
||||
$"This template has {activeRuns} run(s) in progress and cannot be edited until they finish.", 409);
|
||||
|
||||
template.Code = code;
|
||||
template.Name = request.Name.Trim();
|
||||
template.Description = request.Description?.Trim();
|
||||
|
||||
// Two passes, deliberately. Tearing the old graph down and flushing before
|
||||
// rebuilding removes any dependence on how EF happens to order a mixed batch of
|
||||
// inserts and deletes — which matters because stage_inputs → stage_outputs is a
|
||||
// Restrict FK and stage_edges carries a unique (parent, child) index.
|
||||
var keptStages = TearDownGraph(template, request);
|
||||
await _uow.SaveChangesAsync(token);
|
||||
|
||||
BuildGraph(template, request, keptStages);
|
||||
}, ct);
|
||||
|
||||
return await RequireGraphAsync(templateId, ct);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var template = await _templates.Query().FirstOrDefaultAsync(t => t.TemplateId == templateId, ct)
|
||||
?? throw new NotFoundException($"Production template {templateId} was not found.");
|
||||
|
||||
template.Status = status;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// --- graph reconciliation ------------------------------------------------
|
||||
|
||||
private IQueryable<ProductionTemplate> LoadGraphQuery() =>
|
||||
_templates.Query()
|
||||
.Include(t => t.Stages).ThenInclude(s => s.Inputs)
|
||||
.Include(t => t.Stages).ThenInclude(s => s.Outputs)
|
||||
.Include(t => t.Edges);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the pure graph rules then the referential ones. Item/UOM existence needs the
|
||||
/// database, so it can't live in the validator — but it has to run before any write.
|
||||
/// </summary>
|
||||
private async Task ValidateAsync(SaveTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
ProductionGraphValidator.Validate(
|
||||
request.Stages.Select(s => new ProductionGraphValidator.StageDraft(
|
||||
s.Key, s.Name,
|
||||
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(),
|
||||
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(),
|
||||
request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList());
|
||||
|
||||
var itemIds = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId)))
|
||||
.OfType<int>().Distinct().ToList();
|
||||
|
||||
if (itemIds.Count > 0)
|
||||
{
|
||||
var found = await _items.Query().AsNoTracking()
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => new { i.ItemId, i.Status })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item(s) {string.Join(", ", missing)} do not exist.", 422);
|
||||
|
||||
var inactive = found.Where(f => f.Status != EntityStatus.Active).Select(f => f.ItemId).ToList();
|
||||
if (inactive.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
|
||||
}
|
||||
|
||||
var uomIds = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
|
||||
.Distinct().ToList();
|
||||
|
||||
var knownUoms = await _uoms.Query().AsNoTracking()
|
||||
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
|
||||
|
||||
var missingUoms = uomIds.Except(knownUoms).ToList();
|
||||
if (missingUoms.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||
|
||||
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
|
||||
// client renders off. Unknown kinds would round-trip fine but draw nothing.
|
||||
var badKinds = request.Annotations
|
||||
.Select(a => a.Kind)
|
||||
.Where(k => k is not ("box" or "line"))
|
||||
.Distinct().ToList();
|
||||
if (badKinds.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Unknown canvas annotation kind(s): {string.Join(", ", badKinds)}. Expected 'box' or 'line'.", 422);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes everything the incoming payload replaces and returns the surviving stages
|
||||
/// by key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Inputs and outputs are always replaced wholesale — nothing outside the template
|
||||
/// references them, because a run copies into its own <c>run_stage_input</c>/
|
||||
/// <c>run_stage_output</c> rows. Stages are <b>diffed</b>, not replaced, because
|
||||
/// <c>run_stages.TemplateStageId</c> points at them; a stage that disappears from the
|
||||
/// payload is deleted and that FK is set null for any historical run (see
|
||||
/// <c>RunStageConfiguration</c>).
|
||||
/// </remarks>
|
||||
private Dictionary<string, TemplateStage> TearDownGraph(ProductionTemplate template, SaveTemplateRequest request)
|
||||
{
|
||||
var payloadKeys = request.Stages.Select(s => s.Key).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var stage in template.Stages)
|
||||
{
|
||||
foreach (var input in stage.Inputs.ToList()) _inputs.Remove(input);
|
||||
foreach (var output in stage.Outputs.ToList()) _outputs.Remove(output);
|
||||
}
|
||||
|
||||
var kept = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
|
||||
var doomed = new List<TemplateStage>();
|
||||
foreach (var stage in template.Stages.ToList())
|
||||
{
|
||||
var key = stage.StageId.ToString();
|
||||
if (payloadKeys.Contains(key)) kept[key] = stage;
|
||||
else doomed.Add(stage);
|
||||
}
|
||||
|
||||
// Edges are diffed rather than replaced: stage_edges has a unique (parent, child)
|
||||
// index, and dropping then re-adding an unchanged edge in the same round trip can
|
||||
// trip it depending on statement order.
|
||||
var wanted = request.Edges
|
||||
.Select(e => (Parent: ResolveKeptId(kept, e.ParentKey), Child: ResolveKeptId(kept, e.ChildKey)))
|
||||
.Where(e => e.Parent is not null && e.Child is not null)
|
||||
.Select(e => (e.Parent!.Value, e.Child!.Value))
|
||||
.ToHashSet();
|
||||
|
||||
// Edges must go before stages. StageEdge.ParentStage/ChildStage are required
|
||||
// relationships, so deleting a stage that a still-live edge points at makes EF throw
|
||||
// "the association ... has been severed". Any edge touching a doomed stage is
|
||||
// necessarily absent from `wanted` — the payload cannot reference a stage it dropped
|
||||
// — so this ordering never orphans an edge the caller wanted to keep.
|
||||
foreach (var edge in template.Edges.ToList())
|
||||
if (!wanted.Contains((edge.ParentStageId, edge.ChildStageId)))
|
||||
_edges.Remove(edge);
|
||||
|
||||
foreach (var stage in doomed)
|
||||
_stages.Remove(stage);
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static int? ResolveKeptId(Dictionary<string, TemplateStage> kept, string key)
|
||||
=> kept.TryGetValue(key, out var stage) ? stage.StageId : null;
|
||||
|
||||
/// <summary>
|
||||
/// Materialises the payload onto the template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything is wired through <b>navigation properties</b> rather than foreign-key
|
||||
/// ints, so EF resolves the ids itself during a single <c>SaveChanges</c>. That is what
|
||||
/// lets a brand-new Upstream input point at a brand-new output without an intermediate
|
||||
/// save to materialise generated keys — outputs are built in the first pass precisely
|
||||
/// so the second pass has the entity instances to hand.
|
||||
/// </remarks>
|
||||
private void BuildGraph(
|
||||
ProductionTemplate template, SaveTemplateRequest request,
|
||||
Dictionary<string, TemplateStage>? keptStages = null)
|
||||
{
|
||||
// Replaced wholesale — annotations are opaque client state, not part of the graph, so
|
||||
// there is nothing to diff and nothing else can reference them.
|
||||
template.Annotations = request.Annotations.Count == 0
|
||||
? null
|
||||
: ProductionJson.Serialize(request.Annotations);
|
||||
|
||||
var stagesByKey = new Dictionary<string, TemplateStage>(StringComparer.Ordinal);
|
||||
var outputsByKey = new Dictionary<string, StageOutput>(StringComparer.Ordinal);
|
||||
|
||||
// Pass 1 — stages and their outputs.
|
||||
foreach (var s in request.Stages)
|
||||
{
|
||||
if (keptStages is null || !keptStages.TryGetValue(s.Key, out var stage))
|
||||
{
|
||||
stage = new TemplateStage { Template = template };
|
||||
template.Stages.Add(stage);
|
||||
}
|
||||
|
||||
stage.Name = s.Name.Trim();
|
||||
stage.RoleLabel = string.IsNullOrWhiteSpace(s.RoleLabel) ? null : s.RoleLabel.Trim();
|
||||
stage.EstimatedMinutes = s.EstimatedMinutes;
|
||||
stage.PosX = s.PosX;
|
||||
stage.PosY = s.PosY;
|
||||
stage.FieldDefs = ProductionJson.Serialize(s.FieldDefs);
|
||||
|
||||
stagesByKey[s.Key] = stage;
|
||||
|
||||
foreach (var o in s.Outputs)
|
||||
{
|
||||
var output = new StageOutput
|
||||
{
|
||||
Stage = stage,
|
||||
ItemId = o.ItemId,
|
||||
Name = o.Name.Trim(),
|
||||
UomId = o.UomId,
|
||||
QtyPerBatch = o.QtyPerBatch
|
||||
};
|
||||
stage.Outputs.Add(output);
|
||||
outputsByKey[o.Key] = output;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — inputs, which may reference any output built above.
|
||||
foreach (var s in request.Stages)
|
||||
{
|
||||
var stage = stagesByKey[s.Key];
|
||||
foreach (var i in s.Inputs)
|
||||
{
|
||||
stage.Inputs.Add(new StageInput
|
||||
{
|
||||
Stage = stage,
|
||||
Source = i.Source,
|
||||
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
|
||||
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
|
||||
UomId = i.UomId,
|
||||
QtyPerBatch = i.QtyPerBatch
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3 — edges not already present.
|
||||
var existing = template.Edges
|
||||
.Select(e => (e.ParentStageId, e.ChildStageId)).ToHashSet();
|
||||
|
||||
foreach (var e in request.Edges)
|
||||
{
|
||||
var parent = stagesByKey[e.ParentKey];
|
||||
var child = stagesByKey[e.ChildKey];
|
||||
if (parent.StageId != 0 && child.StageId != 0 && existing.Contains((parent.StageId, child.StageId)))
|
||||
continue;
|
||||
|
||||
template.Edges.Add(new StageEdge
|
||||
{
|
||||
Template = template,
|
||||
ParentStage = parent,
|
||||
ChildStage = child
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- mapping -------------------------------------------------------------
|
||||
|
||||
private async Task<ETagged<TemplateGraphDto>> RequireGraphAsync(int templateId, CancellationToken ct)
|
||||
=> await GetAsync(templateId, ct)
|
||||
?? throw new NotFoundException($"Production template {templateId} was not found.");
|
||||
|
||||
private static TemplateGraphDto ToGraphDto(ProductionTemplate t, int activeRunCount)
|
||||
{
|
||||
// The key a client sends back is just the id as a string; computing it here keeps
|
||||
// that contract in exactly one place.
|
||||
var outputKeyById = t.Stages
|
||||
.SelectMany(s => s.Outputs)
|
||||
.ToDictionary(o => o.OutputId, o => o.OutputId.ToString());
|
||||
|
||||
var stages = t.Stages
|
||||
.OrderBy(s => s.StageId)
|
||||
.Select(s => new TemplateStageDto(
|
||||
s.StageId, s.StageId.ToString(), s.Name, s.RoleLabel, s.EstimatedMinutes, s.PosX, s.PosY,
|
||||
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
|
||||
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
|
||||
i.InputId, i.Source, i.ItemId, i.FromOutputId,
|
||||
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
|
||||
i.UomId, i.QtyPerBatch)).ToList(),
|
||||
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
|
||||
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
|
||||
.ToList();
|
||||
|
||||
var edges = t.Edges
|
||||
.OrderBy(e => e.EdgeId)
|
||||
.Select(e => new TemplateEdgeDto(
|
||||
e.EdgeId, e.ParentStageId, e.ChildStageId,
|
||||
e.ParentStageId.ToString(), e.ChildStageId.ToString()))
|
||||
.ToList();
|
||||
|
||||
return new TemplateGraphDto(
|
||||
t.TemplateId, t.Code, t.Name, t.Description, t.Status, stages, edges,
|
||||
ProductionJson.Deserialize<List<CanvasAnnotationDto>>(t.Annotations, []),
|
||||
activeRunCount, t.CreatedBy, t.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,8 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
public async Task<StockLedger> PostLedgerAsync(
|
||||
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default)
|
||||
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
|
||||
decimal? valueOverride = null)
|
||||
{
|
||||
var entry = new StockLedger
|
||||
{
|
||||
@@ -134,7 +135,9 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
Direction = direction,
|
||||
QtyBase = qtyBase,
|
||||
UnitCost = unitCost,
|
||||
Value = Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero),
|
||||
Value = valueOverride is not null
|
||||
? Math.Round(valueOverride.Value, 4, MidpointRounding.AwayFromZero)
|
||||
: Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero),
|
||||
RunningBalance = runningBalance,
|
||||
SourceDocType = sourceDocType,
|
||||
SourceDocId = sourceDocId,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// Shared UOM → base-UOM conversion (see <see cref="IUomConverter"/>). Behaviour is
|
||||
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
|
||||
/// receive path keeps costing exactly as before.
|
||||
/// </summary>
|
||||
public sealed class UomConverter : IUomConverter
|
||||
{
|
||||
private readonly IRepository<UomConversion> _conversions;
|
||||
|
||||
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
|
||||
|
||||
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
return (qty, unitCostPerUom);
|
||||
|
||||
var conv = await _conversions.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
|
||||
|
||||
// Quantity scales up by the factor, so the per-unit cost scales down by it —
|
||||
// total value is preserved.
|
||||
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
||||
}
|
||||
|
||||
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
|
||||
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
|
||||
}
|
||||
@@ -47,4 +47,26 @@ public static class ErrorCodes
|
||||
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
|
||||
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
|
||||
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
|
||||
|
||||
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
|
||||
public const string TemplateInUse = "TEMPLATE_IN_USE";
|
||||
public const string TemplateInactive = "TEMPLATE_INACTIVE";
|
||||
public const string GraphCycle = "GRAPH_CYCLE";
|
||||
public const string GraphTerminalCount = "GRAPH_TERMINAL_COUNT";
|
||||
public const string GraphDisconnected = "GRAPH_DISCONNECTED";
|
||||
public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID";
|
||||
public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED";
|
||||
public const string StageNotReady = "STAGE_NOT_READY";
|
||||
public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS";
|
||||
public const string StageNotDone = "STAGE_NOT_DONE";
|
||||
public const string StageNotEditable = "STAGE_NOT_EDITABLE";
|
||||
public const string StageRejectInvalid = "STAGE_REJECT_INVALID";
|
||||
public const string RequiredFieldMissing = "REQUIRED_FIELD_MISSING";
|
||||
public const string TransferExceedsAvailable = "TRANSFER_EXCEEDS_AVAILABLE";
|
||||
public const string LeftoverExceedsConsumed = "LEFTOVER_EXCEEDS_CONSUMED";
|
||||
public const string RunCostClosed = "RUN_COST_CLOSED";
|
||||
public const string RunNotCancellable = "RUN_NOT_CANCELLABLE";
|
||||
|
||||
// General Ledger service proxy (GeneralLedgerController → external GL service, docs/12)
|
||||
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=dbuser"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
"RequiredRoleCode": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
"BaseUrl": "http://localhost:5602"
|
||||
},
|
||||
"FileStorage": {
|
||||
"RootPath": "App_Data/hr-documents",
|
||||
"MaxSizeBytes": 10485760
|
||||
},
|
||||
"GeneralLedgerService": {
|
||||
"BaseUrl": "https://localhost:7024/api/v1/",
|
||||
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -105,6 +105,44 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
|
||||
- [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified.
|
||||
|
||||
## 7. External Integrations
|
||||
> **General Ledger service** (separate microservice, own repo/DB) — connected 2026-07-20 as a generic reverse-proxy only; no ERPCore business logic posts to it yet. Full contract + progress detail: `docs/12-GENERAL-LEDGER-INTEGRATION.md`.
|
||||
- [~] Generic proxy `GET|POST|PUT /api/v1/gl/{**path}` (`GeneralLedgerController` → `IGeneralLedgerService` → `IGeneralLedgerClient`) — forwards method/path/query/body/content-type verbatim to the GL service with a server-attached `X-Api-Key`; GL's response (status + body) returned unchanged. ErpAccess-door-policy-gated like every other v1 endpoint. Config: `GeneralLedgerService:BaseUrl`/`ApiKey` in `appsettings.json`. Build verified clean; **not yet live-smoke-tested** (no running GL instance this pass).
|
||||
- [ ] Internal wiring — ERPCore services (GRN confirm, adjustments, etc.) calling `IGeneralLedgerService` directly to post real journal entries. Deliberately deferred.
|
||||
|
||||
> ### 2026-07-20 — RBAC nav seed for the frontend's new "Ledgers" section
|
||||
> The `Frontend/PROGRESS.md` §8 "Ledgers" sidebar section (docs/21-GENERAL-LEDGER-FRONTEND.md) needs a matching `NavItem`/`SubNavItem`/`Permission` row for every entry, or the sidebar filters it out for every role regardless of the frontend change (docs/10 C.8, `GET /auth/me`'s `navCodes`). Added via `NavItemConfiguration.cs`/`SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` `HasData`: `NavItem` `ledgers` (id 11), 7 `SubNavItem` rows (ids 9–15, `ledgers.trial-balance` … `ledgers.bank-accounts`), 8 `Permission` rows (ids 19–26) — same one-`Permission`-per-nav-entry convention as every existing nav row. Migration `AddLedgersNavSeed`.
|
||||
> **Build note:** a locally running `ERPCore.exe` (PID 29692) held the default `bin/Debug` output locked for the whole session, so `dotnet ef migrations add` twice produced an empty no-op migration off a stale assembly (`--no-build` silently reused pre-edit code) before the real cause was found. Fixed by building to a scratch output directory (unaffected by the lock), copying the fresh `ERPCore.dll` over the locked `bin/Debug` copy (the running process only locks the `.exe`, not the `.dll`), then re-scaffolding — the resulting migration's `Up`/`Down` were verified by inspection against the identical, already-applied `AddRolesNavPermissions` migration's `InsertData`/`DeleteData` shape. The stray process was left running rather than killed, since it wasn't started by this work and may be in active use elsewhere.
|
||||
> **Not yet applied to a live database** — no Postgres instance was available in this pass to run `dotnet ef database update` against. `dotnet build` is clean (0 warnings/0 errors).
|
||||
> **Operational step still needed post-deploy (not code):** a new `NavItem`/`SubNavItem` carries no `RolePermission` grants by default — an administrator must check the new Ledgers permissions for the relevant role(s) via **Settings → Roles** before anyone sees the sidebar entry, same as every previous nav addition.
|
||||
|
||||
> ### 2026-07-30 — RBAC nav seed: 8th sub-item for the new "Tax Report" screen
|
||||
> The frontend's GL-revision pass (`docs/21-GENERAL-LEDGER-FRONTEND.md`, Frontend/PROGRESS.md §8) added a Tax Report screen to the Ledgers sidebar section — needs the same nav-seed treatment as every other entry (docs/10 C.8). Added `SubNavItem` id 16 (`ledgers.tax-report`, `/dashboard/ledgers/tax-report`, sort order 7) and `Permission` id 27 (`NAV:ledgers.tax-report`); re-sequenced the existing `ledgers.bank-accounts` row's `SortOrder` from 7→8 so Tax Report sits before it, matching the sidebar array's actual order. Migration `AddTaxReportNavSeed` — no locked-process issue this time (confirmed no stray `ERPCore.exe` running before scaffolding), generated cleanly on the first attempt with real `InsertData`/`UpdateData`/`DeleteData` (`Down()` correctly restores `bank-accounts`' `SortOrder` to 7). `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same open item as the original `AddLedgersNavSeed` migration; both are still pending `dotnet ef database update` against a real Postgres instance.
|
||||
|
||||
> ### 2026-07-30 (2) — Fixed a real `SubNavItemId`/`PermissionId` collision between Procurement and Ledgers seed data
|
||||
> **Root cause:** when the 2026-07-20 `AddLedgersNavSeed` migration was authored, its `SubNavItem`/`Permission` IDs were picked by looking at the *actual DB row count*, not the config source — but `SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` already had `HasData` entries for Procurement's 4 sub-items (`procurement.requisitions`/`.rfqs`/`.purchase-orders`/`.purchase-returns`, ids 9–12/19–22) that **had never actually been migrated into any database** (no migration `Up()` anywhere ever inserts them — confirmed by grep across every migration file). Ledgers then claimed the same ids (9–12 sub-nav, 19–22 permission) for its own rows, so the config ended up with two `HasData` entries sharing the same primary key per table. `ErpDbContextModelSnapshot.cs` had silently absorbed both (`dotnet ef migrations add` doesn't hard-fail on this at scaffold time), but **EF's runtime model validator does** — `dotnet ef migrations add` for anything touching these tables, and by extension normal app startup/first `DbContext` use, throws `InvalidOperationException: A seed entity ... has the same key value as another seed entity mapped to the same table`. This is very likely the crash the user was hitting.
|
||||
> **Fix:** moved Procurement's 4 sub-nav rows off the colliding ids onto **17–20** (`SubNavItemId`) and **28–31** (`PermissionId`), past every id already claimed by Ledgers/Tax-Report (max 16/27). Removed the phantom duplicate Procurement entries from `ErpDbContextModelSnapshot.cs` (they never reflected real DB state) so the differ could compute a clean diff, then generated migration **`FixProcurementNavIdCollision`** — pure `InsertData` for the 4 sub-nav rows + 4 permission rows at their new ids (this is also the *first* migration that actually creates Procurement's sub-nav-item/permission rows in the database at all). `Down()` is a clean `DeleteData` reversal.
|
||||
> **Verified:** running `dotnet ef migrations add` against the pre-fix config reproduced the exact `InvalidOperationException` above (scaffold failed outright, no migration file produced), confirming this was a real, reproducible crash and not a false alarm; after the fix, the same command succeeded and `dotnet ef migrations list` builds the full model with no error, listing all 7 migrations (the last 2 — `AddTaxReportNavSeed`, `FixProcurementNavIdCollision` — still `(Pending)`, no Postgres instance available this session); `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same standing blocker as the two prior nav-seed migrations.
|
||||
> Also fixed, same pass: `Frontend/erp-system/components/Layouts/AppSidebar.tsx`'s auto-expand-active-parent logic tripped `react-hooks/set-state-in-effect` (`setExpanded` called synchronously inside a `useEffect`) — converted to the same "adjust state during render" pattern used for the Ledgers report pages, keyed on a `pathname + item-codes` composite key (tracked via a `lastAutoExpandKey` state var) so it still re-fires once `items` populates after the RBAC `navCodes` fetch resolves. `npx eslint components/Layouts/AppSidebar.tsx` clean.
|
||||
|
||||
> ### 2026-07-31 — RBAC nav seed: new "Accounts" nav item (Cheque Management screens + Cash/Bank Accounts moved off Ledgers)
|
||||
> The frontend added a new "Accounts" sidebar section (`Frontend/PROGRESS.md` §8) for the new Cheque Management screens and to hold Cash/Bank Accounts, which moved out of Ledgers into it (user-requested — Cheque Books/Received Cheques/Cash-Bank Accounts are all the same kind of operational account bookkeeping, not a statutory report). Migration **`AddAccountsNavSeed`**: `InsertData` for `NavItem` `accounts` (id 12) and two new `SubNavItem`/`Permission` pairs (`accounts.cheque-books` id 21/33, `accounts.received-cheques` id 22/34); **`UpdateData`, not delete-and-recreate**, for the existing Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) — same ids, just new `Code`/`Href`/`NavItemId` — so a role that had already been granted this permission under its old `ledgers.bank-accounts` code doesn't silently lose it just because the section changed. `Down()` correctly reverses both the inserts and the renamed-row update back to its Ledgers-era values.
|
||||
> **No locked-process issue avoided this time** — `ERPCore.exe` was found running twice during this pass (the user had restarted it between turns to test the Tax Report fix); confirmed with the user before killing it each time, per this session's standing caution around stopping their dev server. `dotnet build` clean (0 warnings/0 errors); `dotnet ef migrations list` shows all 8 migrations with none pending. **Applied to the live database this session** (`dotnet ef database update`) — unlike every prior nav-seed migration this session, this one did not have to wait for a live Postgres instance to become available.
|
||||
> **Operational step still needed post-deploy (not code):** same as every previous nav addition — an administrator must grant the new `NAV:accounts`/`NAV:accounts.cheque-books`/`NAV:accounts.received-cheques` permissions to the relevant role(s) via **Settings → Roles** before anyone sees the new sidebar entries (the re-homed `NAV:accounts.bank-accounts` keeps whatever grants it already had).
|
||||
|
||||
> ### 2026-07-31 (2) — Root-caused and fixed a repo-wide bug: 36 tables (all of HRM + all of Manufacturing) existed in the EF model but not in the actual database, and no `dotnet ef migrations add` could ever surface it
|
||||
> **User-reported:** after rebasing `feat/general-ledger-service` onto `origin/Dev`, some tables from the other branch weren't being created by `migrations add` + `database update`. Ground-truthed against the live Postgres instance (queried `pg_tables`/`__EFMigrationsHistory` directly, since EF's own diff tooling only ever compares the compiled model against `ErpDbContextModelSnapshot.cs` — never the real database — so it's structurally blind to this class of bug): the database had 45 tables; the current model/snapshot expects 80. All 25 `hr_*` tables and all 11 `production_runs`/`production_templates`/`run_*`/`stage_*`/`template_stages` tables were completely absent, despite `ErpDbContext`/`Infra/Persistence/Configurations` fully describing them and `ErpDbContextModelSnapshot.cs` already listing them.
|
||||
> **Root cause: a `.gitignore` rule (`**/Migrations/`, added early on to stop *new* EF migrations from being committed) combined disastrously with `ErpDbContextModelSnapshot.cs` staying tracked** (`.gitignore` doesn't retroactively untrack already-tracked files, and the snapshot was one of the original 4 tracked migrations). Every `dotnet ef migrations add` after that point updated the snapshot (which **did** get committed normally, since it was already tracked) but wrote its actual migration `.cs`/`.Designer.cs` pair as new, gitignored, never-committed files. Confirmed via `git show --stat` on every historical commit touching the snapshot: several — including the commit that added the entire HRM module and the one that added Manufacturing (`7d6e597`) — show large snapshot insertions with **zero** migration files in the same commit. Net effect: the snapshot has been silently lying about the applied-migration history for a long time; `dotnet ef migrations add` never detects a "missing" table because, as far as the (already-tracked, already-correct-looking) snapshot is concerned, nothing has changed — the actual `CreateTable` migration simply never existed anywhere in git, on any machine that didn't happen to still have it sitting locally, ungitignored-but-untracked.
|
||||
> **Fix, in order:**
|
||||
> 1. Confirmed the exact 36-table gap by comparing `pg_tables` against every `b.ToTable(...)` call in the snapshot (script, not archaeology — this is the only way to get ground truth once the snapshot itself is suspect).
|
||||
> 2. Temporarily removed just those 36 entities' blocks from `ErpDbContextModelSnapshot.cs` (verified 2–3 balanced-brace occurrences per entity removed cleanly, nothing else touched), so `dotnet ef migrations add` would have something real to diff against.
|
||||
> 3. Generated **`AddMissingHrmAndManufacturingTables`** — verified its `Up()` contains exactly 36 `CreateTable` calls (matching the missing-table list precisely, no more/fewer) and its `Down()` exactly 36 matching `DropTable` calls; no `AlterColumn`/`DropColumn`/`RenameColumn` against any pre-existing table, confirming this was a pure addition with zero collateral schema drift.
|
||||
> 4. Applied it (`dotnet ef database update`); re-queried `pg_tables` live — all 81 tables (80 + `__EFMigrationsHistory`) now present. Confirmed fully settled by scaffolding one more throwaway migration afterward and checking it came back empty (no remaining model/snapshot drift), then removing it.
|
||||
> 5. **Fixed the actual root cause, not just this one symptom:** reverted the `.gitignore` rule — EF Core migrations are now tracked like any other source file, so this can't recur the same way. Every migration created since the rule was added (`AddLedgersNavSeed`, `AddTaxReportNavSeed`, `FixProcurementNavIdCollision`, `AddAccountsNavSeed`, the empty `production` migration, and this pass's `AddMissingHrmAndManufacturingTables`) was sitting on disk ungitignored-but-uncommitted the whole time — now staged to actually join the repo.
|
||||
> **Verified:** `dotnet build` clean (0 errors, pre-existing `CS8981` naming warning on the already-present `production` migration class only); `dotnet ef migrations list` shows all 10 migrations, none pending. **A locally running `ERPCore.exe` had to be stopped mid-session (user's explicit approval obtained first) to free the build lock**, same recurring issue as every previous migration pass this week.
|
||||
> **Left as-is, deliberately:** the empty `production` migration (`20260731123720_production.cs`) — it's a harmless no-op (it was the user's own prior attempt to fix this exact bug, which came back empty for the reason explained above) and renaming/removing it now would just be churn; the real fix landed in the next migration.
|
||||
> **Action needed from the user:** the `.gitignore` fix means these migration files are no longer excluded, but nothing has been `git add`ed or committed yet — per standing instruction, commits only happen when explicitly asked.
|
||||
|
||||
## Deferred (Phase 2+ — do NOT build now, hooks only)
|
||||
- [ ] Vendor invoice + three-way match
|
||||
- [ ] Reservation/allocation fulfilment
|
||||
@@ -178,9 +216,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] Leave balance report (`.../leave-balances?year=`), document expiry report (`.../document-expiry?withinDays=`)
|
||||
|
||||
---
|
||||
|
||||
# Manufacturing — Production Lines (Phase 2)
|
||||
|
||||
Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike Phase 1). Frontend consumption: `docs/21-FRONTEND-PHASE2.md`. Security: `docs/02-SECURITY.md §B.6` (narrow DTOs — statuses are never client-settable) + `§B.7` (FIFO row-locking inside the UoW txn).
|
||||
|
||||
> **§11–§16 code complete, migration applied, and live smoke-tested (2026-07-30).** `dotnet build` clean (0 errors; the only warnings are the two pre-existing `CS8981` from the badly-named `chages` migration). Migration `AddManufacturingPhase2` — 11 `CreateTable`, zero `AlterColumn`, applied and verified against `information_schema`. **312 smoke assertions, all green**, via re-runnable scripts in `Backend/smoke/` against local Postgres + a real AuthHex session.
|
||||
|
||||
## 11. Sub-phase 2.1 — Schema + enums
|
||||
- [x] 11 entities (`ProductionTemplate`, `TemplateStage`, `StageEdge`, `StageInput`, `StageOutput`, `ProductionRun`, `RunStage`, `RunEdge`, `RunStageInput`, `RunStageOutput`, `RunStageEvent`) + `ProductionConfiguration.cs` (all 11 configs in one file, per the `StockConfiguration.cs` precedent). snake_case tables, PascalCase columns, enums as `varchar(20)`, qty/value `(18,4)`, unit cost + scale factor `(18,6)`, `xmin` RowVersion on template/run/run-stage
|
||||
- [x] Enums `ProductionRunStatus`, `ProductionStageStatus`, `StageInputSource`, `RunStageEventType`, `CustomFieldType`; `ReasonContext` += `Production`; `DocumentTypes.Production = "PRD"`; new `Domain/LedgerSourceTypes.cs`
|
||||
- [x] 4 Production reason codes seeded idempotently (`PRD-SCRAP`, `PRD-LEFTOVER`, `PRD-CANCEL`, `PRD-REWORK-LOSS`) — verified live via `GET /reason-codes?context=Production`
|
||||
- [x] jsonb (`field_defs`, `field_values`, `payload`) as CLR `string` + `HasColumnType("jsonb")`, always written through `ProductionJson` so a column can only hold canonical JSON. Follows the `AuditLog.ChangeSet` precedent; a typed/owned mapping would make `AuditScribe` emit spurious audit rows for the nested entries
|
||||
|
||||
## 12. Sub-phase 2.2 — Templates + graph validation (FR-MFG-01..07)
|
||||
> **Smoke: 38/38** (`Backend/smoke/m2_templates.py`). Zero stock touched.
|
||||
- [x] `ProductionGraphValidator` — a `public static class`, deliberately not an injected service (pure, synchronous, no DI). Kahn toposort → `GRAPH_CYCLE`; terminal count → `GRAPH_TERMINAL_COUNT`; one combined bidirectional-reachability check → `GRAPH_DISCONNECTED`; direct-parent check → `GRAPH_INPUT_SOURCE_INVALID`; terminal output → `TERMINAL_OUTPUT_ITEM_REQUIRED`. Works in **keys**, not ids, so identical code serves POST and PUT
|
||||
- [x] `/production-templates` list/get/create/update/status, ETag + `If-Match` (428 missing, 412 stale), `409 TEMPLATE_IN_USE` on PUT while a run is InProgress
|
||||
- [x] Full-graph PUT reconciliation: stages **diffed** (a run references them), inputs/outputs **replaced**, edges **diffed** (unique `(parent, child)` index). Rebuilt through navigation properties so EF resolves generated keys in one `SaveChanges`
|
||||
|
||||
## 13. Sub-phase 2.3 — Run creation, board, detail, quantities (FR-MFG-08/09/18)
|
||||
> **Smoke: 54/54** (`m3_runs.py`). Zero stock touched.
|
||||
- [x] `POST /production-runs` — copies stages/inputs/outputs/edges in three passes, scales from the **unrounded** ratio (rounding each quantity once, so a repeating scale factor doesn't compound), `PRD-2026-0000N` from `NumberSequenceService` inside the transaction, entry stages `Ready`
|
||||
- [x] `GET /production-runs` with `stageSummary` computed server-side; projected to an anonymous type first then mapped client-side (EF Core 10 cannot translate a record ctor alongside aggregates — same failure as `WarehouseValuationDto`, 2026-07-28)
|
||||
- [x] `GET /production-runs/{id}` full graph incl. events, derived `isTerminal`/`isEntry`/`availableToTransfer`/`actualMinutes`/`costPool`
|
||||
- [x] `PUT .../stages/{sid}/quantities` — `409 STAGE_NOT_EDITABLE` once started, and re-evaluates readiness (raising an upstream planned qty demotes a Ready stage back to Waiting)
|
||||
|
||||
## 14. Sub-phase 2.4 — Stage execution (FR-MFG-10/11/12) · first stock-touching
|
||||
> **Smoke: 66/66** (`m4_stage_actions.py`) **+ 14/14** (`m4b_uom_conversion.py`). Isolated `SMOKE-PRD` warehouse.
|
||||
- [x] `…/start` — FIFO-consumes Stock inputs via `IFifoCostingService.ConsumeAsync`, `PRDI` ledger, `actualStartAt`. Consumes `max(0, plannedBase − consumedQty)` so a rework restart draws only the delta
|
||||
- [x] `…/complete` — produced/scrapped per output + custom field values; `400 REQUIRED_FIELD_MISSING`, `400 REASON_CODE_REQUIRED`, Production-context reason enforced. **Overwrites** on a re-complete
|
||||
- [x] `…/approve` (non-terminal) + `…/transfer` — default full transfer, optional partial, `422 TRANSFER_EXCEEDS_AVAILABLE`, child readiness recomputed. Routes by `fromRunOutputId`, not by edge
|
||||
- [x] **`IUomConverter` extracted** from `GrnService.ToBaseAsync` into `Services/Stock/UomConverter.cs`; `GrnService` delegates to it, behaviour unchanged. Verified: a stage input declared in a 12× UOM consumes **360** base units, not 30; the ledger records base; an undefined conversion is `422`, never assumed 1:1
|
||||
|
||||
## 15. Sub-phase 2.5 — Terminal receipt + cost pool (FR-MFG-13)
|
||||
> **Smoke: 36/36** (`m5_receipt.py`).
|
||||
- [x] Terminal approve creates the finished layer at `costPool / goodQty`, posts `PRDR`, completes the run and closes the pool (`409 RUN_COST_CLOSED`)
|
||||
- [x] **`decimal? valueOverride` added to `IFifoCostingService.PostLedgerAsync`** (default keeps `round(qty × unitCost, 4)`; every existing call site unaffected). Empirically necessary, not theoretical: at 300 units the 6 dp unit cost gives a naive value of `3405.5553` against a pool of `3405.5552` — a real 0.0001 drift. The smoke test asserts the naive product *would* have drifted, so the fixture cannot silently go blind
|
||||
- [x] Batch/serial-tracked finished goods refused with `422` (this phase defines no batch creation on receipt). **Untested** — no tracked item exists in the dev DB; noted in the script
|
||||
|
||||
## 16. Sub-phase 2.6 — Leftover return, rework, cancel (FR-MFG-14..17)
|
||||
> **Smoke: 104/104** (`m6_m7_leftover_rework_cancel.py`).
|
||||
- [x] `…/return-leftover` — inbound at the input's consumed weighted cost, `PRDL`, bin null (raw material, not the finished-goods bin). Value computed from the **unrounded** weighted cost and rounded once; a *full* return takes the exact residual so `returnedValue == consumedValue` precisely. `422 LEFTOVER_EXCEEDS_CONSUMED`, `409 RUN_COST_CLOSED`
|
||||
- [x] `…/reject-intake` — parent `transferredQty` **decremented** (not zeroed, so a parent that also fed another child stays consistent), parent `Approved → InProgress` with `ActualStartAt` preserved, rejecting stage → `Waiting`. Allowed from `Ready` **or** `Waiting` with delivered intake
|
||||
- [x] `…/reject` (terminal) — whole-run reset with one snapshot event per pass; `plannedQty` and `consumed*`/`returned*` **preserved**, stock untouched. Verified across two consecutive rework passes
|
||||
- [x] `POST .../cancel` — returns `consumed − returned` per input at the consumed weighted cost (`PRDC`), `balances` dictionary accumulated per item (layers created in-transaction are invisible to `GetOnHandAsync` until `SaveChanges`), scrapped output qty recorded as `scrappedWrittenOff`. `409 RUN_NOT_CANCELLABLE`
|
||||
- [x] Event history + estimated-vs-actual (FR-MFG-19) — every action writes one `RUN_STAGE_EVENT`; failed actions write none (the write rolls back with the change)
|
||||
|
||||
**Bugs found and fixed during this phase (not silently patched):**
|
||||
- **Template PUT 500** — deleting a `TemplateStage` while a `StageEdge` still referenced it severed a required EF relationship. Edges are now removed before stages; any edge touching a deleted stage is by construction absent from the payload, so nothing the caller wanted is orphaned.
|
||||
- **`receipt.layerId` returned 0** — the `ReceiptDto` was built inside the transaction, before `SaveChanges` generated the id. Now mapped after the commit (same fix as the `ledgerRefs:[0]` issue recorded 2026-07-13).
|
||||
- **Runtime messages carried U+2212** (typographic minus) and broke console/log encoding on Windows cp1252. Exception strings now use ASCII hyphens; comments keep the typographic form, matching the rest of the codebase.
|
||||
|
||||
**Deviations / decisions (recorded, not silently assumed) — all mirrored into `docs/30`:**
|
||||
- **§A.1 was a no-op.** `StockLayer.GrnLineId` was already nullable in entity, config, snapshot **and** database. Phase-1 schema was not altered at all; NFR-08 holds without exception.
|
||||
- **Ledger codes are `PRDI`/`PRDR`/`PRDL`/`PRDC`**, not the doc's 15–22-char names — `SourceDocType` is `varchar(10)` on both `stock_ledger` and `journal_entry_stubs`, and widening it would have been a second Phase-1 schema change.
|
||||
- **Three additions to Part C:** `RUN_EDGE` (a run must own its edges or a later template edit rewrites completed-run history), `RUN_STAGE.pos_x/pos_y` (the run canvas renders from them), `RUN_STAGE_EVENT.run_id` + nullable `run_stage_id` (run-level events, single-query timeline). `RUN_STAGE.template_stage_id` made nullable + `SET NULL` so a template stays editable after runs complete.
|
||||
- **UOM conversion is unspecified in docs/30** but essential. Contract consequence: `plannedQty` is in the input's declared UOM while `consumed*`/`returned*` are in the item's **base** UOM.
|
||||
- **`Idempotency-Key` accepted and ignored**, matching `GrnService.ConfirmAsync`. Status guards are the replay story; `RunStage.RowVersion` (xmin) prevents two concurrent terminal approves double-posting a receipt.
|
||||
- **FR-MFG-17's "− scrapped" is not computable** at the input level (scrap lives on outputs, in output UOM). Scrap never entered stock, so nothing is deducted; scrapped quantities are recorded on the cancel event instead.
|
||||
- **`GRAPH_DISCONNECTED` is unreachable** once cycle + terminal-count pass; kept as defence in depth. An isolated stage surfaces as `GRAPH_TERMINAL_COUNT`.
|
||||
- **Edit-lock TOCTOU accepted** — checked inside the transaction, but under READ COMMITTED a run could still be created against a template mid-edit. Benign: runs copy everything at creation and never re-read the template.
|
||||
|
||||
**Not done this pass (tracked, not silently skipped):**
|
||||
- [x] **Frontend wiring** (`docs/21-FRONTEND-PHASE2.md` §8) — done in the same session; see `Frontend/PROGRESS.md` §§11–13. Not browser-verified (same AuthHex blocker).
|
||||
- **Batch/serial-tracked finished goods** — guarded with a 422, and that guard is unexercised (no tracked item in the dev DB).
|
||||
- **`NAV:production` permission** is not seeded; the sidebar still relies on the `bypassCodes` stopgap (same as `procurement`/`hrm`).
|
||||
- **No automated test project** — verification is the `Backend/smoke/` scripts, per house practice.
|
||||
|
||||
> ### 2026-07-30 — Dev-database repair + a drift audit worth repeating
|
||||
> **`users."Email"` was missing from the database** while present in the entity and the model snapshot, so `ShadowUserClaimsTransformation`'s JIT insert failed with `42703` on **every authenticated request** — surfacing to callers as a confusing `InvalidOperationException: Sequence contains no elements`. `GET /items` and everything else 500'd. Fixed by the hand-written migration `RepairUserEmailColumn` (idempotent `ADD COLUMN IF NOT EXISTS` + the unique index, matching `UserConfiguration`'s `HasMaxLength(320)`).
|
||||
>
|
||||
> **Root cause — four migrations recorded as applied with zero operations:** `ini2`, `initial2`, `chages`, `chages1` each advanced the model snapshot without emitting any DDL. Anything added to the model in those windows exists in the snapshot but never reached the database.
|
||||
>
|
||||
> **Method note (this is the reusable part):** a scaffolded probe migration coming back **empty proves only `model == snapshot`, never `snapshot == database`** — which is exactly how this hid. The real audit was `dotnet ef dbcontext script` (which renders the *current model*) diffed against `information_schema.columns`.
|
||||
>
|
||||
> **Still outstanding — not fixed here, deliberately:** the same four empty migrations mean **all 25 HRM tables (`hr_*`) exist in the model and snapshot but not in this database**, so every HRM endpoint fails. Creating 25 tables of another module as a side effect of manufacturing work would be worse than reporting it; it needs its own repair migration and its own verification.
|
||||
>
|
||||
> **Also worth knowing:** `.gitignore:38` is `**/Migrations/`, so **no migration in this repo is version-controlled** — `AddManufacturingPhase2` and `RepairUserEmailColumn` exist only on the machine that created them. Anyone else must regenerate them.
|
||||
|
||||
> ### 2026-07-30 (later) — Three server-side additions the frontend wiring needed
|
||||
> All three are amended into `docs/30` as built. None changes an existing endpoint's behaviour.
|
||||
>
|
||||
> - **`TemplateGraphDto.activeRunCount`** — the builder derives its edit-locked state from it. Counted with its own scalar query rather than an `Include`, because the graph query already fans out over four collections and adding `Runs` would multiply those rows again for one integer.
|
||||
> - **`production_templates."Annotations"` (jsonb)** + `SaveTemplateRequest.Annotations`, migration **`AddTemplateCanvasAnnotations`** (exactly one `AddColumn`, applied and verified). The builder canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so **every save would have silently discarded the user's layout**. Stored through `ProductionJson` like every other jsonb column, so the column can only ever hold canonical JSON; `List<CanvasAnnotationDto>` capped at 200 by `[MaxLength]`, and `Kind` validated to `box`/`line` in `ValidateAsync` because nothing else constrains free-form client state going into jsonb. Deliberately invisible to `ProductionGraphValidator` — annotations carry no graph semantics.
|
||||
> - **Wholesale replacement is the flip side and is now pinned by an assertion:** a PUT that omits `annotations` clears them. `m2_templates.py` asserts preserve → clear → restore explicitly, because silent data loss is worse than an error.
|
||||
>
|
||||
> **Smoke suite: extended but NOT re-run.** `m2_templates.py` gained 10 assertions (annotation round-trip incl. geometry/label/rotation, `activeRunCount` on the graph, unknown-kind rejection, and the clear/restore pair). **These are unverified.** AuthHex cannot issue a token — its configured MySQL host `187.127.102.190:3306` is unreachable from this machine (`MySqlConnector … Connect Timeout expired` on `POST /api/user`), and the localhost alternative in its `appsettings.json` is commented out. `dotnet build` is clean and the migration applied cleanly, but the last full green run of the suite (312/312) predates these additions.
|
||||
>
|
||||
> **HRM schema gap CLOSED** (by the repo owner, not this work): migrations `production` (another empty one — the fifth) and **`AddHrmTables`** now exist, the latter creating all 25 `hr_*` tables. The "still outstanding" note in the entry above is resolved; the underlying lesson about empty migrations is not.
|
||||
|
||||
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 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`)
|
||||
- **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.
|
||||
|
||||
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}")
|
||||
@@ -83,6 +83,46 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [ ] `412` conflict → prompt refetch before retry — `apiRequestWithETag` surfaces the ETag but no screen edits a GRN yet (GRN has no PUT), so untested in practice
|
||||
- [x] No client-side gating on stock/availability/status (server-authoritative) — GRN create always submits to the server and surfaces `OVER_RECEIPT_TOLERANCE`/etc. via `error-map.ts` rather than pre-blocking
|
||||
|
||||
## 8. General Ledger (Ledgers + Accounts sections)
|
||||
> Two sidebar sections (`app/dashboard/ledgers/*`, `app/dashboard/accounts/*`), sourced entirely from the external General Ledger service via ERPCore's generic proxy (`docs/12-GENERAL-LEDGER-INTEGRATION.md`). Full detail, decisions, and known gaps: `docs/21-GENERAL-LEDGER-FRONTEND.md`.
|
||||
- [x] Ledgers: reports hub + 7 report screens (Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, Tax Report) — statutory-format header/table, PDF **and CSV** download via the same endpoint with `outputFormat=Pdf`/`Csv`
|
||||
- [x] Accounts: hub + Cash/Bank Accounts — unified list (GL's own server-side `accountType` union + client-side text search) + create (Cash/Bank toggle; GL account is now auto-created server-side, **no picker** — see 2026-07-31 (6) below). **Moved here from Ledgers (2026-07-31 (5))**
|
||||
- [ ] Cash/Bank Accounts — edit: **not built**, GL has no `GET`/`PUT` by id for either table to build it against (list shows a disabled Edit affordance with an explanatory tooltip instead of a broken form)
|
||||
- [x] Accounts: Cheque Books — list/filter, create (auto-generates every leaf), drill-down to a book's own pages list, per-page details/issue/status-update in a modal
|
||||
- [x] Accounts: Received Cheques — list/filter, create, per-row details/status-update in a modal
|
||||
- [x] Sidebar "Ledgers" (7 sub-items) + new "Accounts" (3 sub-items) nav items (`components/Layouts/AppSidebar.tsx`) + header title mappings (`components/Layouts/Header.tsx`)
|
||||
- [x] Dedicated GL fetch client (`lib/api/general-ledger.ts`) — GL's envelope differs from ERPCore's own `ProblemDetails`, so this does not reuse `lib/api-client.ts`; now also covers Cheque Management (`chequeBooksApi`/`chequePagesApi`/`receivedChequesApi`)
|
||||
|
||||
> **2026-07-30 — GL's 2026-07-22 backend revision built out (large pass).** Five reports restructured (Trial Balance flattened, Profit & Loss → nested named sections with a Gross Profit subtotal, Cash Flow → a real structured statement replacing the four `StatCard`s), a new CSV export on all seven reports (`components/reports/DownloadCsvButton.tsx`), a brand-new **Tax Report** screen (Income Tax Computation, collapsible optional-adjustments panel, payable/refundable sign-dependent final row), and Cash/Bank accounts split into two real GL endpoints (`POST /bank-accounts` vs `POST /cash-accounts`, unified `GET /bank-accounts?accountType=`) with a two-choice create-form toggle and a Cash Account Type picker that can create a new type on the fly. Extracted `components/reports/{ReportSection,ReportSubtotal}.tsx` — shared by Profit & Loss and Cash Flow rather than duplicating the "bordered section + bold subtotal" markup twice. Three response shapes (`ProfitAndLossResponse`/`CashFlowResponse`/`TaxSummaryResponse`) are **inferred** where GL's own reference doesn't spell out every field verbatim — flagged in `types/general-ledger.ts`'s own comments and `docs/21-GENERAL-LEDGER-FRONTEND.md`, same posture as the original inferred `BankAccount` shape. Backend: migration `AddTaxReportNavSeed` adds the 8th sidebar sub-item + its permission row. Verified: `tsc --noEmit` clean, `eslint` clean across every touched file, `npm run build` succeeds with all 9 `/dashboard/ledgers/*` routes (incl. `/tax-report`), `dotnet build` clean. **Not done:** live smoke test against a running GL instance (still no instance available this session) — the three inferred response shapes are the highest-value thing to verify first.
|
||||
|
||||
> **2026-07-20, same-day fixes (user-reported):** (1) General Ledger report was wrongly calling `GET /accounts` to populate an account picker — GL documents `accountId` on this report as a raw id, not a code-lookup value, so the picker is gone; the screen now only ever calls `/reports`, entering `accountId` directly and reading the account's code/name for display off the report's own returned rows instead. (2) `ReportType`/`ReportOutputFormat`/`GlAccountTypeId` converted from string/numeric literal unions to real TS enums. (3) Fixed a UI-only bug where a selected `<Select>` (Bank Account create's GL-account picker, Budget vs Actual's budget picker) displayed the raw numeric value instead of its label after selection — the underlying value sent to the server was always correct; `@base-ui/react/select`'s `Select.Value` needs an explicit `label` prop per `<SelectItem>` (separate from `children`) to resolve display text, which neither picker was passing. Fixed at the two call sites, not the shared `components/ui/select.tsx` primitive (out of scope — other numeric-valued `<Select>`s elsewhere in the app likely share this latent bug; flagged in `docs/21-GENERAL-LEDGER-FRONTEND.md` §4 for whoever next touches one). Verified: `tsc --noEmit` and `eslint` clean.
|
||||
>
|
||||
> **2026-07-20 (2) — `react-hooks/set-state-in-effect` errors resolved, Ledgers pages only (scope confirmed with the user — this is not an app-wide lint pass; the same error is pre-existing on ~35 other files elsewhere in the app, left untouched).** All 7 report/list screens called `setState` synchronously as the first statement of a data-fetching effect (clearing stale results before the async call) — flagged as an error, not just a warning, by this project's current eslint config. Fixed with React's own "adjust state during render" pattern instead of an effect: each page now tracks the key it last loaded for (`asOfDate`/period/`accountId`/`budgetId`) in a small extra piece of state, and resets the result/error state **during render** when that key changes (before the effect below ever runs) rather than synchronously inside the effect. Behavior is unchanged — stale results still clear the instant a filter changes. `bank-accounts/page.tsx`'s mount-only `load()` had a redundant `setError(null)` (state already starts `null`; nothing else ever recalls `load()`), removed outright rather than worked around. Verified: `npx eslint app/dashboard/ledgers` produces zero output, `tsc --noEmit` clean, `npm run build` succeeds.
|
||||
>
|
||||
> **2026-07-31 — Fixed a live runtime crash on Cash Flow: GL omits empty list/section fields entirely instead of sending `[]`/`{lines:[],total:0}`.** User-reported error clicking into the page: `TypeError: Cannot read properties of undefined (reading 'map')` at `bucketOperatingLines` → `report.nonCashAdjustments.map(...)`, confirming the exact risk `CashFlowResponse` had been flagged with since it was built (inferred shape, never verified live). Root cause: GL's serializer drops a list/section property from the JSON body altogether when there's nothing to report for the period, rather than emitting an empty array/zero-totalled object. Fixed defensively in `cash-flow/page.tsx` (`?? []` on `nonCashAdjustments`/`workingCapitalChanges`, a new `activitySectionLines()` helper + optional chaining for `investingActivities`/`financingActivities`/their `.total`) and, proactively, in `profit-and-loss/page.tsx` (`isEmpty` check and every section's `.total` access) since `ProfitAndLossResponse` shares the identical nested-section shape and was equally exposed — not yet crashed on, but certain to under the same conditions (a section with nothing posted for the period). `types/general-ledger.ts`'s `CashFlowResponse`/`ProfitAndLossResponse` fields updated from required to optional to match, with comments pointing back at this confirmed-live behavior. Verified: `tsc --noEmit`/`eslint` clean on all touched files; `npm run build`'s TypeScript step fails, but only on a pre-existing, unrelated `app/dashboard/hrm/employees/[id]/page.tsx` error present before this pass — out of scope per standing instruction to keep fixes scoped to Ledgers. **Tax Report's `TaxSummaryResponse` is the one remaining inferred shape not yet defensively hardened or live-verified** — same class of risk, flagged for the next time that screen is touched.
|
||||
>
|
||||
> **2026-07-31 (2) — Corrected against GL's own authoritative API reference (`04_API_Reference_And_Scenarios.md`, user-supplied): Cash Flow's shape was fundamentally wrong, not just missing defensive guards; Tax Report was missing five real fields.** With the actual GL API reference in hand (not inference), checked every report's response shape against it: Trial Balance, Balance Sheet, General Ledger, Profit & Loss, and Budget vs Actual all match exactly, confirming those five were built correctly. Two did not: **(1) `CashFlowResponse` doesn't have `netEarnings`/`nonCashAdjustments`/`workingCapitalChanges`/`netCashFromOperations` as flat top-level fields at all — everything genuinely nests under `operatingActivities` (`{ profitForPeriod, nonCashAdjustments[], workingCapitalChanges[], netCashFromOperatingActivities }`), and `investingActivities`/`financingActivities` each carry their own differently-named total (`netCashFromInvestingActivities`/`netCashFromFinancingActivities`), not a shared `total`.** This — not just "the field might be missing" — was the real cause of the crash fixed in the previous entry; the previous fix's defensive `?? []` guards were correct in spirit but pointed at the wrong (nonexistent) top-level fields, so the page would have kept rendering an empty operating-activities section forever even without crashing. Rewrote `cash-flow/page.tsx` and `CashFlowResponse`/added `CashFlowOperatingActivities`/`CashFlowInvestingActivities`/`CashFlowFinancingActivities` to `types/general-ledger.ts` to match the confirmed contract exactly; also caught that `workingCapitalChanges[]` entries use `changeAmount`, not `amount`. **(2) `TaxSummaryResponse`/the Tax Report's `ROWS` table were missing `nonDeductibleExpenses`, `corporateIncomeTax`, `apitCredit`, `whtCredit`, and `quarterlyTaxPayments` entirely** — real GL-computed figures that were silently never rendered, not just a wrong guess at a field name. Added all five in their correct position in the confirmed row order (`profitBeforeTax` → `balanceTaxPayable`). Verified: `tsc --noEmit`/`eslint` clean on every touched file.
|
||||
>
|
||||
> **2026-07-31 (3) — Balance Sheet regrouped into a proper LKAS Statement of Financial Position layout (user-reported).** `BalanceSheetRow`'s shape was already correct (confirmed against GL's reference above), but the flat one-table rendering made a rollup total visually indistinguishable from the leaf amounts it already sums — e.g. "Cash and Bank"'s balance already includes "Petty Cash"/"Main Operating Bank Account"/"Savings Bank Account" beneath it, but every row read the same weight (only `depth===0` did any, subtle, bolding), inviting a user to double-count by adding up everything they see. Rewrote `balance-sheet/page.tsx`: rows now group by `accountType` into ASSETS/LIABILITIES/EQUITY sections, each ending in a bold "Total {Section Name}" row (summed from that section's depth-0 rows only — a depth-0 row's balance already rolls up its own descendants, so summing depth-0 rows avoids double-counting), any row with a deeper row immediately following it is bolded as a rollup regardless of its own depth (not just the very top level), and a final "Total Liabilities and Equity" row for the standard balance-check. One quirk handled explicitly: GL's synthetic "Current Year Earnings" balancing row is documented to always carry `depth: 1` even though it's a peer Equity entry, not a child of whatever real account happens to precede it — a new `effectiveDepth()` helper special-cases it to 0 so it isn't mis-rendered as nested under (and excluded from the total alongside) an unrelated account. Manually verified the new grouping/summing logic against the actual numbers from the reported screenshot: Total Assets (5,880,466.50) = Total Liabilities (2,025,000.00) + Total Equity (3,855,466.50), exact match. Verified: `tsc --noEmit`/`eslint` clean.
|
||||
>
|
||||
> **2026-07-31 (4) — Superseded by GL's own retrofit: `BalanceSheet` is a genuinely different, classified response shape now, not just a re-grouping of the same flat array.** GL's own API reference (user-supplied) documents a 2026-07-31 backend retrofit: the flat recursive-rollup array (`{depth, lineItem, accountType, balance}`, what entry (3) above regrouped client-side) is replaced entirely by a **pre-classified nested object** — `{ asOfDate, nonCurrentAssets: {lines[], total}, currentAssets: {lines[], total}, unclassifiedAssets: {lines[], total}, totalAssets, equity: {lines[], total}, nonCurrentLiabilities: {lines[], total}, currentLiabilities: {lines[], total}, unclassifiedLiabilities: {lines[], total}, totalEquityAndLiabilities }`, driven by a new `accounts.balance_sheet_classification` tag GL now maintains server-side. This means entry (3)'s client-side grouping/rollup logic (`effectiveDepth`, `sectionTotal`, the `depth`-based rollup-bolding) is entirely obsolete — GL now does the Non-Current/Current classification itself, the frontend just renders the sections it's given. Replaced `BalanceSheetRow` with `BalanceSheetLine`/`BalanceSheetSection`/`BalanceSheetResponse` in `types/general-ledger.ts` (every section marked optional, same defensive posture adopted for `CashFlowResponse`/`ProfitAndLossResponse` after the Cash Flow crash, since this exact shape isn't live-verified against this frontend yet) and rewrote `balance-sheet/page.tsx` from scratch to consume it. **Also changed the layout to match a user-supplied reference Statement of Financial Position image** (a real classified SOFP: Non-Current Assets/Current Assets each their own subtotaled block, then Equity and Liabilities the same way, ending in a Total Assets vs Total Equity-and-Liabilities check) — rather than inventing new one-off markup for this, reused the same `ReportSection`/`ReportSubtotal` shared components Profit & Loss and Cash Flow already use (one `ReportSection` per GL-provided section, a `ReportSubtotal` for each side's grand total), keeping Balance Sheet visually and structurally consistent with the rest of the Ledgers screens rather than a bespoke table. Account codes are deliberately not shown per line (the reference template shows plain line-item names only). Verified: `tsc --noEmit`/`eslint` clean; grepped the codebase to confirm no lingering references to the removed `BalanceSheetRow`/flat shape.
|
||||
>
|
||||
> **2026-07-31 (5) — New "Accounts" nav section: Cheque Management built out, Cash/Bank Accounts moved under it.** New Cheque Management module (`04_API_Reference_And_Scenarios.md`, Module: Cheque Management — added to GL 2026-07-30, beyond its original plan): two independent sub-areas, **Cheque Books/Pages** (cheques issued from this company's own supply) and **Received Cheques** (cheques received from others, deliberately unlinked to any cheque book). Added `PayeeType`/`ReceivedFromType`/`ChequeBookStatus`/`ChequePageIssueStatus`/`ChequePageStatusAction`/`ReceivedChequeStatus`/`ReceivedChequeStatusAction` enums and `ChequeBook`/`ChequePage`/`ReceivedCheque` (+ their create/status-update request types) to `types/general-ledger.ts`, and `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` to `lib/api/general-ledger.ts`. `branchId`/`companyId`/`payeeId`/`voucherId`/`referenceId` are GL's own documented "loose references" (no Branch/Company/Customer/Supplier table exists in that service) — taken as plain numeric inputs, not picker dropdowns, matching GL's stated design rather than fabricating master data that doesn't exist.
|
||||
>
|
||||
> **Cheque Books** (`app/dashboard/accounts/cheque-books/{page,new,[chequeBookNo]/page}.tsx`): list with a status filter, create form (bank account picker restricted to `Bank`-type accounts only — GL's own module note says cheque books are bank-account-only, never cash-account), and a book-detail page showing every leaf (`GET /cheque-books/{chequeBookNo}?expand=pages`) — clicking a leaf opens `components/accounts/ChequePageDialog.tsx`, a modal with read-only details plus status-appropriate actions (`Unused` → Issue/Cancel/Void; `Issued` → Clear/Bounce/Cancel; terminal statuses → read-only), each action revealing only the fields that specific transition actually needs (e.g. Clear asks for `clearedDate`, Cancel asks for `cancelReason`, Bounce/Void need nothing beyond an optional `performedBy`). A modal was chosen over a second-level page for the leaf-details view (left open in the request) so working through several leaves in one book doesn't lose the list's scroll position/context each time.
|
||||
>
|
||||
> **Received Cheques** (`app/dashboard/accounts/received-cheques/{page,new/page}.tsx` + `components/accounts/ReceivedChequeDialog.tsx`): same list-then-modal shape — status filter, create form, and a details/status-update modal (`Received` → Deposit/Cancel; `Deposited` → Clear/Return), Deposit asking for a bank-account picker + date, the rest needing nothing beyond an optional note.
|
||||
>
|
||||
> **2026-07-31 (6) — Two user-reported fixes: `glAccountCode` removed from Cash/Bank Account creation (further GL retrofit), and the three GL create-form pages widened to fill the page.** (1) GL's reference now documents that `POST /bank-accounts`/`POST /cash-accounts` no longer accept `glAccountCode` — the backing GL account (a `Bank`/`Cash` root, plus a type-header node for Cash) is always found-or-created server-side, never caller-selected. Removed the field from `CreateBankAccountRequest`/`CreateCashAccountRequest`, deleted the "GL account" `Select` and its `glAccountsApi.list()` fetch from `bank-accounts/new/page.tsx` outright, and dropped the check from `validateBankAccountForm`. Typed the create response as a new `CreateCashOrBankAccountResponse` (`glAccount` nested, confirmed from GL's doc) so the success toast can surface the auto-generated GL account code. The Cash/Bank **list** page is untouched — GL's list endpoint still returns a flat `glAccountId` per row, still resolved via `glAccountsApi.list()` there. (2) `bank-accounts/new`, `cheque-books/new`, and `received-cheques/new` each wrapped their form in a `max-w-lg` card, leaving roughly half of any normal desktop screen blank. Dropped the `max-w-lg` cap (now full-width, matching the un-capped card convention every report page already uses) and replaced the vertical one-field-per-row stacking (plus scattered ad-hoc `grid grid-cols-2` pairs) with one consistent `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` wrapper per form. Left the two modals (`ChequePageDialog`/`ReceivedChequeDialog`) at their existing fixed width on purpose — the complaint was about full-page create forms, not dialogs. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build`'s Turbopack compile succeeds, its TypeScript step fails only on the same pre-existing, unrelated `app/dashboard/hrm/employees/[id]/page.tsx` error noted in earlier entries.
|
||||
>
|
||||
> **Cash/Bank Accounts moved from Ledgers to the new Accounts section** (user-requested), since it's the same kind of "operational account bookkeeping" as cheques, not a statutory report — `app/dashboard/ledgers/bank-accounts/*` relocated verbatim to `app/dashboard/accounts/bank-accounts/*` (internal links updated, no behavior change), removed from the Ledgers hub's card grid.
|
||||
>
|
||||
> **Backend:** migration `AddAccountsNavSeed` adds `NavItem` `accounts` (id 12) and two new `SubNavItem`/`Permission` pairs (`accounts.cheque-books`, `accounts.received-cheques`), and **re-homes** the existing Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) from Ledgers to Accounts via `UpdateData` (new `Code`/`Href`/`NavItemId`) rather than delete-and-recreate — keeps the same ids so any role already granted that permission doesn't silently lose it just because the section it lives under changed. Applied to the live database this session (`dotnet ef database update`).
|
||||
>
|
||||
> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `<entity>Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error.
|
||||
>
|
||||
> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds.
|
||||
|
||||
## 7. UX states
|
||||
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
|
||||
- [x] Transactional actions show server-returned side effects as confirmation — GRN confirm renders `createdLayers`/`ledgerRefs`/`poStatus` from the response
|
||||
@@ -115,9 +155,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.
|
||||
- **A pre-existing, unrelated syntax error in `app/dashboard/receiving/grn/new/page.tsx`** (unclosed JSX, last touched 2026-07-23 before this HRM pass started) blocks a clean whole-project `tsc --noEmit` run. Not introduced by this work and not fixed by it — confirmed via `git status`/`git log` that this file was untouched this session; scoped `eslint`/`tsc` checks against every HRM file individually (and the fact this is the *only* file `tsc` reports) confirm the HRM additions themselves are clean.
|
||||
|
||||
---
|
||||
|
||||
# Manufacturing — Production Lines (Phase 2)
|
||||
|
||||
Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND-PHASE2.md` (§D.1–D.3). Validation posture: `docs/20-FRONTEND.md §3` — client checks are UX only.
|
||||
|
||||
> **Every production screen now runs on the real API. Both mock modules are deleted.** `npx tsc --noEmit` reports 0 errors in this module (the only 4 project errors are pre-existing HRM ones — see the note at the end of §13), and `npx next build` reports **"Compiled successfully"** before failing type-check on those same HRM files. **Nothing has been driven in a browser** — see the honesty note at the end.
|
||||
|
||||
## 11. Contract layer (F1) — DONE
|
||||
- [x] `types/production.ts` **fully rewritten** against docs/30 Part D — every request/response DTO, all six enums, and the stage-action result shapes. Replaces the frontend-only placeholder shapes entirely
|
||||
- [x] Three contract corrections carried through: templates are keyed by **`code`** (not `docNo` — only runs get a document number); quantities use **`itemId`/`uomId`/`qtyPerBatch`** numeric FKs (not free-text uom/qty); stages carry **`posX`/`posY`**, so canvas layout round-trips through the server
|
||||
- [x] `lib/api/production-templates.ts` — list/get/create/update/updateStatus with ETag + `If-Match`
|
||||
- [x] `lib/api/production-runs.ts` — the full §D.3 surface (start, complete, approve, transfer, reject-intake, reject, return-leftover, cancel, quantities), every action taking an `idempotencyKey`; plus `isStaleStageError()` for the docs/21 §6 "409 on a stage-status code → refetch silently" rule
|
||||
- [x] `lib/error-map.ts` — all 17 docs/30 §D.4 codes. **Also fixed a real mechanism gap:** `errorMessage()` let any mapped domain code override the server's `detail`, which would have thrown away the specifics the user needs — the graph validator names the offending stages, and the transfer/leftover guards quote the actual figures. Added `DETAIL_PREFERRED_CODES` so those eight codes let `detail` win and keep their map entry as a fallback
|
||||
|
||||
## 12. Screens (F2–F5) — DONE
|
||||
- [x] **Template overview** (`app/dashboard/production/templates/page.tsx`) — real `productionTemplatesApi.list` with a 300 ms debounced search, status filter, pagination and real `activeRunCount`. One-row-per-template canvas labelled from live data
|
||||
- [x] **Template builder** (`templates/[id]/page.tsx`) — **fully rewired.** GETs the graph, holds the ETag, and the former `handleSave()` toast stub is now a real create/update. Node ids **are** the server's stage keys (`tmp-<uuid>` for stages drawn this session), so a PUT diffs stages in place and keeps historical runs linked; `node.position` persists as `posX`/`posY`; real `itemsApi`/`uomsApi` pickers replaced `MOCK_ITEMS`; `/templates/new` renders an unsaved draft seeded from the overview dialog's query params and swaps its URL on first save. Also gained a Deactivate/Activate control — `productionTemplatesApi.updateStatus` previously had no UI path at all
|
||||
- [x] **Run board** (`runs/page.tsx`) — real list with debounced doc-no search, template/warehouse/status filters and pagination. Start dialog posts `productionRunsApi.create` and **navigates to the run**
|
||||
- [x] **Run detail** (`runs/[id]/page.tsx`) — canvas built from the run's own `posX`/`posY` and run edges, with per-stage intake (`delivered/planned`) and available-to-transfer badges, live cost pool, and a `stageSummary` computed from real stage statuses
|
||||
- [x] **Stage drawer** (`runs/[id]/StageDrawer.tsx`) — the whole of docs/21 §5: per-status bodies (Waiting → explanation · Ready → editable planned quantities + Start · InProgress → produced/scrapped per output with a required Production reason + custom fields + Complete · Done non-terminal → approve with optional partial transfer · Done terminal → receipt preview + Approve & receive + Reject for rework · Approved → transfer remainder), reject-intake from Ready **or** Waiting-with-deliveries, and the per-stage event timeline
|
||||
- [x] **Runtime custom-field renderer** (`runs/[id]/CustomFieldForm.tsx`) — `fieldDefs` → typed inputs for all five types, plus `missingRequiredFields()` which **mirrors the server's rule exactly**, including the part that surprises people: an unchecked Checkbox counts as *provided* (`false`), so a required checkbox does not force a tick
|
||||
- [x] **Run-level actions** (`runs/[id]/RunActions.tsx`) — Return leftover (per consumed Stock input, showing consumed/returned/weighted cost, in base UOM and capped at the unreturned remainder) and Cancel run (previewing what goes back to stock). Both hidden once the run leaves InProgress, because `RUN_COST_CLOSED`/`RUN_NOT_CANCELLABLE` mean offering them could only produce an error
|
||||
- [x] `lib/production-status-colors.ts` kept untouched — it already matches docs/21 §3 exactly and is the single source for status colour everywhere
|
||||
- [x] **Deleted `lib/production-mock-runs.ts` and `lib/production-mock-templates.ts`**, including `buildStagePlan()`
|
||||
|
||||
**Deviations / decisions (recorded):**
|
||||
- **The drawer is one file, not the seven the plan sketched.** Each per-status panel is ~30 lines and they all share the same lookup helpers, `submit()` wrapper and error handling; splitting them would mean threading that shared context through seven prop lists for no isolation benefit. `CustomFieldForm` and `RunActions` *are* separate, because both stand alone and neither needs the drawer's form state.
|
||||
- **The board shows per-status counts, not named stages.** The list projection carries `stageSummary` only, so naming stages there would mean guessing which stage holds which count — exactly what the deleted `buildStagePlan()` did. Named per-stage state lives on the run detail, where the server actually returns it.
|
||||
- **Non-terminal outputs have their `itemId` stripped on save, not rejected.** A stage that *was* terminal and then gained a child keeps its picked item in local state with the field no longer rendered; an issue-list message about an invisible field would be unactionable, so the builder drops it silently (FR-MFG-05 forbids it on a WIP output anyway).
|
||||
- **The terminal receipt preview is computed client-side.** There is no preview endpoint and every input (cost pool, produced, scrapped) is already on the page, so the drawer mirrors the server's arithmetic to show the layer *before* creating it. Preview only — the server recomputes.
|
||||
- **A status toggle re-reads the ETag.** `PATCH /status` bumps the row's `xmin`, invalidating the token the builder holds. It re-GETs and takes *only* the etag and status, deliberately not reloading the canvas, because a full reload there would silently discard unsaved edits.
|
||||
- **"New Template" opens an unsaved draft rather than creating immediately.** A template cannot exist without a valid graph — the server requires ≥1 stage and a terminal output naming a real item (FR-MFG-02/05) — so there is nothing sensible to POST from a name alone.
|
||||
- **`templateGraphToSaveRequest()` was deleted from `lib/api/production-templates.ts`.** It converted a fetched graph into a save payload, but the builder's canvas — not the last GET — is the source of truth for what gets saved, so it had no caller and would have drifted.
|
||||
|
||||
**Backend additions made for these screens** (all amended into docs/30 as built):
|
||||
- **`TemplateSummaryDto.stageNames`, in flow order.** The overview draws each template as a line left-to-right and needs the names for every row; without the field the client would fetch every template's full graph just to label boxes. Ordering by stage id turned out to be insertion order, which put the *terminal* stage first and drew lines backwards — so the server toposorts (Kahn, tie-broken by id for stability, falling back to id order if the graph is ever cyclic so a listing can't fail on bad data).
|
||||
- **`TemplateGraphDto.activeRunCount`.** The builder reads its edit-locked state straight off the graph; without it, it would need a second request to the list endpoint purely to know whether to disable itself.
|
||||
- **`production_templates.Annotations` (jsonb) + `SaveTemplateRequest.annotations`.** The canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so every save would have silently discarded the user's layout notes. Round-tripped verbatim, capped at 200 entries, `kind` validated to `box`/`line`, and invisible to the graph validator. Migration `AddTemplateCanvasAnnotations`. Note the flip side, pinned by a smoke assertion: replacement is wholesale, so a client that forgets to echo `annotations` back on a PUT clears them.
|
||||
|
||||
## 13. Validation posture (F6) — DONE
|
||||
- [x] Domain-code → message map complete (§11), with `detail` preferred where the server is more specific
|
||||
- [x] `412 CONCURRENCY_CONFLICT` amber conflict banner + Reload on the builder (the `app/dashboard/vendors/[id]/page.tsx` pattern)
|
||||
- [x] `409 TEMPLATE_IN_USE` edit-lock banner driven by the server. Two distinct messages: locked on load (`activeRunCount > 0`) versus locked *while editing* — the FR-MFG-06 TOCTOU, where a run starts between the GET and the PUT. The second locks the canvas rather than reloading, so nothing the user just drew disappears without an explanation
|
||||
- [x] `422 GRAPH_*` focuses the offending stage — a best-effort substring match of the server's `detail` against stage names, which is why the validator quotes them. Advisory by design: the full message is always in the banner too, so an ambiguous name costs a highlight, never the explanation
|
||||
- [x] `Idempotency-Key` per action (`useRef(crypto.randomUUID())`, re-minted after each success and whenever the drawer switches stage)
|
||||
- [x] Silent refetch on stage-status 409s — `submit()` in the drawer routes every action through `isStaleStageError()`
|
||||
|
||||
**Not done, flagged rather than silently skipped:**
|
||||
- **No browser verification of any production screen, and no live end-to-end run.** The contract layer is written against a backend whose every endpoint is smoke-verified, the tree type-checks and Turbopack compiles it, but **nothing has been clicked.** Blocked on AuthHex: its configured MySQL host (`187.127.102.190:3306`) is unreachable from this machine, so no token can be issued — which also means the backend smoke suite could not be re-run after this pass's backend additions.
|
||||
- **`components/Layouts/AppSidebar.tsx:351` still lists `"production"` in `bypassCodes`.** Correct for now — no role is seeded with a `NAV:production` permission, so removing the bypass would hide the section from everyone. Seeding that permission is the real fix (same outstanding item as `procurement`/`hrm`).
|
||||
- **4 pre-existing `tsc` errors, unrelated to this work — and they block `next build` for the whole app:** `hrm/employees/[id]/page.tsx` (`UpdateEmployeeRequest` missing `hireDate`) and three `hrm/settings/*` pages (an `Api<T>` generic expecting `{value}` where `ApiResult<T>` is returned). None of these files import anything added or changed by this pass, so they were left alone rather than fixed as a side effect of manufacturing work.
|
||||
- **10 `react-hooks/set-state-in-effect` lint errors across the five production files.** Same rule fires 42 times repo-wide (`app/dashboard/receiving/grn/page.tsx` included); these are the load effects, the hydration-mismatch guards and the builder's stale-upstream repair. No other rule fires in this module.
|
||||
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 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
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, cashAccountTypesApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateBankAccountForm } from "@/lib/validations/general-ledger"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CashAccountType, CashBankAccountType } from "@/types/general-ledger"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const OTHER_CASH_TYPE = "__other__"
|
||||
|
||||
export default function NewBankAccountPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [accountType, setAccountType] = useState<CashBankAccountType>(CashBankAccountType.Bank)
|
||||
|
||||
const [cashAccountTypes, setCashAccountTypes] = useState<CashAccountType[] | null>(null)
|
||||
const [cashAccountTypesError, setCashAccountTypesError] = useState<string | null>(null)
|
||||
|
||||
const [accountName, setAccountName] = useState("")
|
||||
const [bankName, setBankName] = useState("")
|
||||
const [cashAccountTypeChoice, setCashAccountTypeChoice] = useState("")
|
||||
const [customCashAccountTypeName, setCustomCashAccountTypeName] = useState("")
|
||||
const [accountNumber, setAccountNumber] = useState("")
|
||||
const [currencyCode, setCurrencyCode] = useState("LKR")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (accountType !== CashBankAccountType.Cash || cashAccountTypes !== null) return
|
||||
cashAccountTypesApi
|
||||
.list()
|
||||
.then(setCashAccountTypes)
|
||||
.catch((err) => setCashAccountTypesError(errorMessage(err)))
|
||||
// Only fetched once, lazily, the first time "Cash" is selected.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountType])
|
||||
|
||||
const cashAccountTypeName =
|
||||
cashAccountTypeChoice === OTHER_CASH_TYPE ? customCashAccountTypeName.trim() : cashAccountTypeChoice
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextErrors = validateBankAccountForm({ accountName })
|
||||
if (accountType === CashBankAccountType.Cash && !cashAccountTypeName) {
|
||||
nextErrors.cashAccountTypeName = "Select or enter a cash account type"
|
||||
}
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created =
|
||||
accountType === CashBankAccountType.Bank
|
||||
? await bankAccountsApi.createBank({
|
||||
accountName,
|
||||
bankName: bankName || null,
|
||||
accountNumber: accountNumber || null,
|
||||
currencyCode: currencyCode || undefined,
|
||||
})
|
||||
: await bankAccountsApi.createCash({
|
||||
accountName,
|
||||
cashAccountTypeName,
|
||||
accountNumber: accountNumber || null,
|
||||
currencyCode: currencyCode || undefined,
|
||||
})
|
||||
toast.success(`${accountType} account created`, `${created.accountName} — GL account ${created.glAccount.accountCode}`)
|
||||
router.push("/dashboard/accounts/bank-accounts")
|
||||
} catch (err) {
|
||||
toast.error(`Could not create ${accountType.toLowerCase()} account`, errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Cash / Bank Account</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Its ledger account is created automatically — no need to pick one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<div className="mb-6 flex max-w-sm gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={accountType === CashBankAccountType.Bank ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setAccountType(CashBankAccountType.Bank)}
|
||||
>
|
||||
Bank
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={accountType === CashBankAccountType.Cash ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setAccountType(CashBankAccountType.Cash)}
|
||||
>
|
||||
Cash
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field data-invalid={!!errors.accountName}>
|
||||
<FieldLabel htmlFor="ba-name">Account name</FieldLabel>
|
||||
<Input
|
||||
id="ba-name"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
placeholder={accountType === CashBankAccountType.Bank ? "Main Account" : "Head Office Petty Cash"}
|
||||
aria-invalid={!!errors.accountName}
|
||||
/>
|
||||
<FieldError errors={[errors.accountName ? { message: errors.accountName } : undefined]} />
|
||||
</Field>
|
||||
|
||||
{accountType === CashBankAccountType.Bank ? (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-bank">Bank name (optional)</FieldLabel>
|
||||
<Input id="ba-bank" value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="Commercial Bank" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
|
||||
<Input id="ba-acct-no" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="8001234567" />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field data-invalid={!!errors.cashAccountTypeName}>
|
||||
<FieldLabel htmlFor="ba-cash-type">Cash account type</FieldLabel>
|
||||
<Select<string> value={cashAccountTypeChoice} onValueChange={(v) => setCashAccountTypeChoice(v ?? "")}>
|
||||
<SelectTrigger id="ba-cash-type" className="w-full text-base" aria-invalid={!!errors.cashAccountTypeName}>
|
||||
<SelectValue placeholder={cashAccountTypes === null ? "Loading…" : "Select a type"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(cashAccountTypes ?? []).map((t) => (
|
||||
<SelectItem key={t.cashAccountTypeId} value={t.name} label={t.name} className="text-base">
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={OTHER_CASH_TYPE} label="Other, please specify" className="text-base">
|
||||
Other, please specify…
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{cashAccountTypeChoice === OTHER_CASH_TYPE && (
|
||||
<Input
|
||||
value={customCashAccountTypeName}
|
||||
onChange={(e) => setCustomCashAccountTypeName(e.target.value)}
|
||||
placeholder="e.g. Site Cash"
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
{cashAccountTypesError && (
|
||||
<p className="text-sm text-destructive">{cashAccountTypesError}</p>
|
||||
)}
|
||||
<FieldError errors={[errors.cashAccountTypeName ? { message: errors.cashAccountTypeName } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
|
||||
<Input
|
||||
id="ba-acct-no"
|
||||
value={accountNumber}
|
||||
onChange={(e) => setAccountNumber(e.target.value)}
|
||||
placeholder="Auto-generated if left blank"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ba-currency">Currency</FieldLabel>
|
||||
<Input id="ba-currency" value={currencyCode} onChange={(e) => setCurrencyCode(e.target.value)} maxLength={3} placeholder="LKR" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
type AccountTypeFilter = CashBankAccountType | "Both"
|
||||
|
||||
export default function BankAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
|
||||
|
||||
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
|
||||
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
|
||||
.then(([banks, gl]) => {
|
||||
if (cancelled) return
|
||||
setAccounts(banks)
|
||||
setGlAccounts(gl.items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accountType])
|
||||
|
||||
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!accounts) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return accounts
|
||||
return accounts.filter((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
a.accountName.toLowerCase().includes(q) ||
|
||||
(a.bankName ?? "").toLowerCase().includes(q) ||
|
||||
(a.cashAccountTypeName ?? "").toLowerCase().includes(q) ||
|
||||
(a.accountNumber ?? "").toLowerCase().includes(q) ||
|
||||
a.currencyCode.toLowerCase().includes(q) ||
|
||||
(gl?.accountCode ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [accounts, search, glAccountsById])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
||||
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name, bank/type, account no. or currency…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search cash/bank accounts"
|
||||
/>
|
||||
</div>
|
||||
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
|
||||
<SelectTrigger className="h-14! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && filtered === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && filtered !== null && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Wallet className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && filtered !== null && filtered.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
<TableRow key={`${a.accountType}-${a.accountId}`}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{a.accountType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Editing isn't available yet — the General Ledger service has no update endpoint for
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ArrowLeft, BookText } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
|
||||
|
||||
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
|
||||
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
|
||||
[ChequePageIssueStatus.Issued]: "bg-primary/10 text-primary",
|
||||
[ChequePageIssueStatus.Cleared]: "bg-success/10 text-success",
|
||||
[ChequePageIssueStatus.Bounced]: "bg-destructive/10 text-destructive",
|
||||
[ChequePageIssueStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
[ChequePageIssueStatus.Void]: "bg-muted text-muted-foreground",
|
||||
}
|
||||
|
||||
export default function ChequeBookDetailPage() {
|
||||
const params = useParams<{ chequeBookNo: string }>()
|
||||
const chequeBookNo = decodeURIComponent(params.chequeBookNo)
|
||||
|
||||
const [book, setBook] = useState<ChequeBook | null>(null)
|
||||
const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
chequeBooksApi
|
||||
.get(chequeBookNo, true)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setBook(res)
|
||||
return bankAccountsApi.list(CashBankAccountType.Bank).then((banks) => {
|
||||
if (cancelled) return
|
||||
setBankAccount(banks.find((b) => b.accountId === res.bankAccountId) ?? null)
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [chequeBookNo])
|
||||
|
||||
function handlePageUpdated(updated: ChequePage) {
|
||||
setBook((prev) => (prev ? { ...prev, pages: prev.pages.map((p) => (p.chequeNo === updated.chequeNo ? updated : p)) } : prev))
|
||||
setSelectedPage(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Book {chequeBookNo}</h1>
|
||||
<p className="text-base text-muted-foreground">Every leaf in this book — click one to view details or take an action.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && book === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && book !== null && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Bank account</p>
|
||||
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Branch</p>
|
||||
<p className="text-base font-medium">#{book.branchId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Range</p>
|
||||
<p className="text-base font-medium">
|
||||
{book.startChequeNo} – {book.endChequeNo}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Received</p>
|
||||
<p className="text-base font-medium">{formatReportDate(book.receivedDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{book.pages.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BookText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No pages found for this book.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Payee</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Issue date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{book.pages.map((p) => (
|
||||
<TableRow
|
||||
key={p.chequeNo}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedPage(p)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{p.chequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[p.issueStatus])}>
|
||||
{p.issueStatus}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
{p.amount !== null ? formatAmount(p.amount) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChequePageDialog page={selectedPage} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handlePageUpdated} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateChequeBookForm } from "@/lib/validations/general-ledger"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function NewChequeBookPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [bankAccountsError, setBankAccountsError] = useState<string | null>(null)
|
||||
|
||||
const [branchId, setBranchId] = useState("")
|
||||
const [bankAccountId, setBankAccountId] = useState("")
|
||||
const [chequeBookNo, setChequeBookNo] = useState("")
|
||||
const [startChequeNo, setStartChequeNo] = useState("")
|
||||
const [endChequeNo, setEndChequeNo] = useState("")
|
||||
const [totalLeaves, setTotalLeaves] = useState("")
|
||||
const [receivedDate, setReceivedDate] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [createdBy, setCreatedBy] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Cheque books can only be tied to a real bank account — GL's own module note (§5.8) says
|
||||
// statement import/reconcile, and by extension cheque books, are bank_account-only, never cash_account.
|
||||
useEffect(() => {
|
||||
bankAccountsApi
|
||||
.list(CashBankAccountType.Bank)
|
||||
.then(setBankAccounts)
|
||||
.catch((err) => setBankAccountsError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextErrors = validateChequeBookForm({
|
||||
branchId,
|
||||
bankAccountId,
|
||||
chequeBookNo,
|
||||
startChequeNo,
|
||||
endChequeNo,
|
||||
totalLeaves,
|
||||
receivedDate,
|
||||
})
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await chequeBooksApi.create({
|
||||
branchId: Number(branchId),
|
||||
bankAccountId: Number(bankAccountId),
|
||||
chequeBookNo,
|
||||
startChequeNo,
|
||||
endChequeNo,
|
||||
totalLeaves: Number(totalLeaves),
|
||||
receivedDate,
|
||||
description: description || undefined,
|
||||
createdBy: createdBy || undefined,
|
||||
})
|
||||
toast.success("Cheque book created", `${created.chequeBookNo} — ${created.totalLeaves} leaves`)
|
||||
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(created.chequeBookNo)}`)
|
||||
} catch (err) {
|
||||
toast.error("Could not create cheque book", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Cheque Book</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Every leaf from the start to end cheque number is generated automatically, all "Unused".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bankAccountsError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{bankAccountsError}</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field data-invalid={!!errors.bankAccountId}>
|
||||
<FieldLabel htmlFor="cb-bank">Bank account</FieldLabel>
|
||||
<Select<string> value={bankAccountId} onValueChange={(v) => setBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="cb-bank" className="w-full text-base" aria-invalid={!!errors.bankAccountId}>
|
||||
<SelectValue placeholder={bankAccounts === null ? "Loading…" : "Select a bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(bankAccounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName}
|
||||
{a.bankName ? ` — ${a.bankName}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.branchId}>
|
||||
<FieldLabel htmlFor="cb-branch">Branch ID</FieldLabel>
|
||||
<Input
|
||||
id="cb-branch"
|
||||
type="number"
|
||||
value={branchId}
|
||||
onChange={(e) => setBranchId(e.target.value)}
|
||||
placeholder="1"
|
||||
aria-invalid={!!errors.branchId}
|
||||
/>
|
||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.chequeBookNo}>
|
||||
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
|
||||
<Input
|
||||
id="cb-no"
|
||||
value={chequeBookNo}
|
||||
onChange={(e) => setChequeBookNo(e.target.value)}
|
||||
placeholder="CB-0001"
|
||||
aria-invalid={!!errors.chequeBookNo}
|
||||
/>
|
||||
<FieldError errors={[errors.chequeBookNo ? { message: errors.chequeBookNo } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.startChequeNo}>
|
||||
<FieldLabel htmlFor="cb-start">Start cheque no.</FieldLabel>
|
||||
<Input
|
||||
id="cb-start"
|
||||
value={startChequeNo}
|
||||
onChange={(e) => setStartChequeNo(e.target.value)}
|
||||
placeholder="000001"
|
||||
aria-invalid={!!errors.startChequeNo}
|
||||
/>
|
||||
<FieldError errors={[errors.startChequeNo ? { message: errors.startChequeNo } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.endChequeNo}>
|
||||
<FieldLabel htmlFor="cb-end">End cheque no.</FieldLabel>
|
||||
<Input
|
||||
id="cb-end"
|
||||
value={endChequeNo}
|
||||
onChange={(e) => setEndChequeNo(e.target.value)}
|
||||
placeholder="000025"
|
||||
aria-invalid={!!errors.endChequeNo}
|
||||
/>
|
||||
<FieldError errors={[errors.endChequeNo ? { message: errors.endChequeNo } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.totalLeaves}>
|
||||
<FieldLabel htmlFor="cb-leaves">Total leaves</FieldLabel>
|
||||
<Input
|
||||
id="cb-leaves"
|
||||
type="number"
|
||||
value={totalLeaves}
|
||||
onChange={(e) => setTotalLeaves(e.target.value)}
|
||||
placeholder="25"
|
||||
aria-invalid={!!errors.totalLeaves}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Must equal end − start + 1.</p>
|
||||
<FieldError errors={[errors.totalLeaves ? { message: errors.totalLeaves } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.receivedDate}>
|
||||
<FieldLabel htmlFor="cb-received">Received date</FieldLabel>
|
||||
<Input
|
||||
id="cb-received"
|
||||
type="date"
|
||||
value={receivedDate}
|
||||
onChange={(e) => setReceivedDate(e.target.value)}
|
||||
aria-invalid={!!errors.receivedDate}
|
||||
/>
|
||||
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cb-desc">Description (optional)</FieldLabel>
|
||||
<Input id="cb-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cb-by">Created by (optional)</FieldLabel>
|
||||
<Input id="cb-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, BookText, Plus } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = ChequeBookStatus | "All"
|
||||
|
||||
const STATUS_BADGE: Record<ChequeBookStatus, string> = {
|
||||
[ChequeBookStatus.Active]: "bg-success/10 text-success",
|
||||
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
|
||||
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
export default function ChequeBooksPage() {
|
||||
const router = useRouter()
|
||||
const [books, setBooks] = useState<ChequeBook[] | null>(null)
|
||||
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
chequeBooksApi.list(status === "All" ? undefined : { status }),
|
||||
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
|
||||
])
|
||||
.then(([result, banks]) => {
|
||||
if (cancelled) return
|
||||
setBooks(result.items)
|
||||
setBankAccounts(banks)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status])
|
||||
|
||||
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
||||
<p className="text-base text-muted-foreground">Cheque books issued from this company's own supply.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Cheque Book
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && books === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && books !== null && books.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BookText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No cheque books yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && books !== null && books.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{books.map((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
return (
|
||||
<TableRow
|
||||
key={b.chequeBookNo}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.branchId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{b.startChequeNo} – {b.endChequeNo}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link"
|
||||
import { BookText, Inbox, Wallet, type LucideIcon } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
||||
{
|
||||
title: "Cash / Bank Accounts",
|
||||
description: "Cash and Bank accounts linked to a GL account — list, create, and reconcile against them.",
|
||||
href: "/dashboard/accounts/bank-accounts",
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: "Cheque Books",
|
||||
description: "Cheque books issued from this company’s own supply — issue, clear, bounce, cancel or void a leaf.",
|
||||
href: "/dashboard/accounts/cheque-books",
|
||||
icon: BookText,
|
||||
},
|
||||
{
|
||||
title: "Received Cheques",
|
||||
description: "Cheques received from customers/suppliers — deposit, clear, return, or cancel.",
|
||||
href: "/dashboard/accounts/received-cheques",
|
||||
icon: Inbox,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AccountsHubPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Accounts</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Cash/Bank accounts and cheque management, from the General Ledger service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{areas.map((area) => (
|
||||
<Link key={area.href} href={area.href}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<area.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { receivedChequesApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateReceivedChequeForm } from "@/lib/validations/general-ledger"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReceivedFromType } from "@/types/general-ledger"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function NewReceivedChequePage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [companyId, setCompanyId] = useState("")
|
||||
const [branchId, setBranchId] = useState("")
|
||||
const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer)
|
||||
const [receivedFromId, setReceivedFromId] = useState("")
|
||||
const [receivedFromName, setReceivedFromName] = useState("")
|
||||
const [drawerBankName, setDrawerBankName] = useState("")
|
||||
const [drawerBankBranch, setDrawerBankBranch] = useState("")
|
||||
const [accountHolderName, setAccountHolderName] = useState("")
|
||||
const [chequeNo, setChequeNo] = useState("")
|
||||
const [chequeDate, setChequeDate] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [receivedDate, setReceivedDate] = useState("")
|
||||
const [referenceType, setReferenceType] = useState("")
|
||||
const [referenceId, setReferenceId] = useState("")
|
||||
const [notes, setNotes] = useState("")
|
||||
const [createdBy, setCreatedBy] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate })
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await receivedChequesApi.create({
|
||||
companyId: Number(companyId),
|
||||
branchId: branchId ? Number(branchId) : undefined,
|
||||
receivedFromType,
|
||||
receivedFromId: receivedFromId ? Number(receivedFromId) : undefined,
|
||||
receivedFromName,
|
||||
drawerBankName: drawerBankName || undefined,
|
||||
drawerBankBranch: drawerBankBranch || undefined,
|
||||
accountHolderName: accountHolderName || undefined,
|
||||
chequeNo,
|
||||
chequeDate,
|
||||
amount: Number(amount),
|
||||
receivedDate,
|
||||
referenceType: referenceType || undefined,
|
||||
referenceId: referenceId ? Number(referenceId) : undefined,
|
||||
notes: notes || undefined,
|
||||
createdBy: createdBy || undefined,
|
||||
})
|
||||
toast.success("Received cheque recorded", `${created.chequeNo} — ${created.receivedFromName}`)
|
||||
router.push("/dashboard/accounts/received-cheques")
|
||||
} catch (err) {
|
||||
toast.error("Could not record received cheque", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Received Cheque</h1>
|
||||
<p className="text-base text-muted-foreground">Record a cheque received from a customer, supplier, or other party.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field data-invalid={!!errors.companyId}>
|
||||
<FieldLabel htmlFor="rc-company">Company ID</FieldLabel>
|
||||
<Input
|
||||
id="rc-company"
|
||||
type="number"
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
placeholder="1"
|
||||
aria-invalid={!!errors.companyId}
|
||||
/>
|
||||
<FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-branch">Branch ID (optional)</FieldLabel>
|
||||
<Input id="rc-branch" type="number" value={branchId} onChange={(e) => setBranchId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-from-type">Received from type</FieldLabel>
|
||||
<Select<ReceivedFromType> value={receivedFromType} onValueChange={(v) => setReceivedFromType(v ?? ReceivedFromType.Customer)}>
|
||||
<SelectTrigger id="rc-from-type" className="w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(ReceivedFromType).map((t) => (
|
||||
<SelectItem key={t} value={t} label={t} className="text-base">
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.receivedFromName}>
|
||||
<FieldLabel htmlFor="rc-from-name">Received from name</FieldLabel>
|
||||
<Input
|
||||
id="rc-from-name"
|
||||
value={receivedFromName}
|
||||
onChange={(e) => setReceivedFromName(e.target.value)}
|
||||
aria-invalid={!!errors.receivedFromName}
|
||||
/>
|
||||
<FieldError errors={[errors.receivedFromName ? { message: errors.receivedFromName } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-from-id">Received from ID (optional)</FieldLabel>
|
||||
<Input id="rc-from-id" type="number" value={receivedFromId} onChange={(e) => setReceivedFromId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-drawer-bank">Drawer bank (optional)</FieldLabel>
|
||||
<Input id="rc-drawer-bank" value={drawerBankName} onChange={(e) => setDrawerBankName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-drawer-branch">Drawer branch (optional)</FieldLabel>
|
||||
<Input id="rc-drawer-branch" value={drawerBankBranch} onChange={(e) => setDrawerBankBranch(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-holder">Account holder name (optional)</FieldLabel>
|
||||
<Input id="rc-holder" value={accountHolderName} onChange={(e) => setAccountHolderName(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.chequeNo}>
|
||||
<FieldLabel htmlFor="rc-cheque-no">Cheque number</FieldLabel>
|
||||
<Input id="rc-cheque-no" value={chequeNo} onChange={(e) => setChequeNo(e.target.value)} aria-invalid={!!errors.chequeNo} />
|
||||
<FieldError errors={[errors.chequeNo ? { message: errors.chequeNo } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.chequeDate}>
|
||||
<FieldLabel htmlFor="rc-cheque-date">Cheque date</FieldLabel>
|
||||
<Input
|
||||
id="rc-cheque-date"
|
||||
type="date"
|
||||
value={chequeDate}
|
||||
onChange={(e) => setChequeDate(e.target.value)}
|
||||
aria-invalid={!!errors.chequeDate}
|
||||
/>
|
||||
<FieldError errors={[errors.chequeDate ? { message: errors.chequeDate } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="rc-amount">Amount</FieldLabel>
|
||||
<Input id="rc-amount" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!errors.amount} />
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.receivedDate}>
|
||||
<FieldLabel htmlFor="rc-received-date">Received date</FieldLabel>
|
||||
<Input
|
||||
id="rc-received-date"
|
||||
type="date"
|
||||
value={receivedDate}
|
||||
onChange={(e) => setReceivedDate(e.target.value)}
|
||||
aria-invalid={!!errors.receivedDate}
|
||||
/>
|
||||
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-ref-type">Reference type (optional)</FieldLabel>
|
||||
<Input id="rc-ref-type" value={referenceType} onChange={(e) => setReferenceType(e.target.value)} placeholder="Invoice" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-ref-id">Reference ID (optional)</FieldLabel>
|
||||
<Input id="rc-ref-id" type="number" value={referenceId} onChange={(e) => setReferenceId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
|
||||
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-created-by">Created by (optional)</FieldLabel>
|
||||
<Input id="rc-created-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Inbox, Plus } from "lucide-react"
|
||||
|
||||
import { receivedChequesApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
|
||||
|
||||
type StatusFilter = ReceivedChequeStatus | "All"
|
||||
|
||||
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
|
||||
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
|
||||
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
|
||||
[ReceivedChequeStatus.Cleared]: "bg-success/10 text-success",
|
||||
[ReceivedChequeStatus.Returned]: "bg-destructive/10 text-destructive",
|
||||
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
export default function ReceivedChequesPage() {
|
||||
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
receivedChequesApi
|
||||
.list(status === "All" ? undefined : { status })
|
||||
.then((res) => {
|
||||
if (!cancelled) setCheques(res.items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [status])
|
||||
|
||||
function handleUpdated(updated: ReceivedCheque) {
|
||||
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
|
||||
setSelected(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
|
||||
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Received Cheque
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && cheques === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && cheques !== null && cheques.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Inbox className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No received cheques yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && cheques !== null && cheques.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cheques.map((c) => (
|
||||
<TableRow
|
||||
key={c.receivedChequeId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelected(c)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export default function AttendanceBatchDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">{batch.docNo}</h1>
|
||||
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
|
||||
@@ -132,9 +132,9 @@ export default function AttendanceBatchDetailPage() {
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="u-reason">Reason</FieldLabel><Input id="u-reason" value={unlockReason} onChange={(e) => setUnlockReason(e.target.value)} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
<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-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function AttendanceBatchesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Attendance</h1>
|
||||
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll's source of truth.</p>
|
||||
@@ -86,7 +86,7 @@ export default function AttendanceBatchesPage() {
|
||||
<DialogDescription>Columns: Employee Code, Date, Check In, Check Out.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="a-start">Period start</FieldLabel><Input id="a-start" type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} /></Field>
|
||||
<Field><FieldLabel htmlFor="a-end">Period end</FieldLabel><Input id="a-end" type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} /></Field>
|
||||
</div>
|
||||
@@ -95,9 +95,9 @@ export default function AttendanceBatchesPage() {
|
||||
<Input id="a-file" type="file" accept=".xlsx,.csv" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleUpload} disabled={submitting}><Upload className="size-4" />{submitting ? "Uploading…" : "Upload"}</Button>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleUpload} disabled={submitting}><Upload className="size-4" />{submitting ? "Uploading…" : "Upload"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -208,7 +208,7 @@ export default function EmployeeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">{employee.fullName}</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -237,7 +237,7 @@ export default function EmployeeDetailPage() {
|
||||
|
||||
{tab === "Overview" && (
|
||||
<FieldGroup className="max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Full name</FieldLabel><Input value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Email</FieldLabel><Input type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>NIC</FieldLabel><Input value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
|
||||
@@ -363,7 +363,7 @@ export default function EmployeeDetailPage() {
|
||||
<DialogDescription>Supersedes the current open-ended structure from this date.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Effective from</FieldLabel><Input type="date" value={structureEffectiveFrom} onChange={(e) => setStructureEffectiveFrom(e.target.value)} /></Field>
|
||||
<Field><FieldLabel>Basic salary</FieldLabel><Input type="number" min={0} value={structureBasic} onChange={(e) => setStructureBasic(Number(e.target.value))} /></Field>
|
||||
</div>
|
||||
@@ -383,9 +383,9 @@ export default function EmployeeDetailPage() {
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setStructureOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={createStructure} disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
||||
<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-32" onClick={() => setStructureOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={createStructure} disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -433,7 +433,7 @@ export default function EmployeeDetailPage() {
|
||||
<SelectContent><SelectItem value="Loan">Loan</SelectItem><SelectItem value="Advance">Advance</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Principal</FieldLabel><Input type="number" min={0} value={loanPrincipal} onChange={(e) => setLoanPrincipal(Number(e.target.value))} /></Field>
|
||||
<Field><FieldLabel>Installment amount</FieldLabel><Input type="number" min={0} value={loanInstallmentAmount} onChange={(e) => setLoanInstallmentAmount(Number(e.target.value))} /></Field>
|
||||
<Field><FieldLabel># installments</FieldLabel><Input type="number" min={1} value={loanCount} onChange={(e) => setLoanCount(Number(e.target.value))} /></Field>
|
||||
@@ -445,9 +445,9 @@ export default function EmployeeDetailPage() {
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setLoanOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={createLoan} disabled={busy}>{busy ? "Creating…" : "Create"}</Button>
|
||||
<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-32" onClick={() => setLoanOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={createLoan} disabled={busy}>{busy ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function EmployeesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Employees</h1>
|
||||
<p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
|
||||
@@ -134,7 +134,7 @@ export default function EmployeesPage() {
|
||||
<DialogDescription>Not every employee needs a login — a system user account is optional and separate.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.employeeCode}>
|
||||
<FieldLabel htmlFor="e-code">Employee code</FieldLabel>
|
||||
<Input id="e-code" value={form.employeeCode} onChange={(e) => setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} />
|
||||
@@ -170,7 +170,7 @@ export default function EmployeesPage() {
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.departmentId}>
|
||||
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
|
||||
<Select value={form.departmentId ? String(form.departmentId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
|
||||
@@ -205,9 +205,9 @@ export default function EmployeesPage() {
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function LeaveRequestsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Leave</h1>
|
||||
<p className="text-base text-muted-foreground">Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.</p>
|
||||
@@ -129,15 +129,15 @@ export default function LeaveRequestsPage() {
|
||||
<SelectContent>{leaveTypes.map((t) => <SelectItem key={t.leaveTypeId} value={String(t.leaveTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Start date</FieldLabel><Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} /></Field>
|
||||
<Field><FieldLabel>End date</FieldLabel><Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} /></Field>
|
||||
</div>
|
||||
<Field><FieldLabel>Reason (optional)</FieldLabel><Input value={reason} onChange={(e) => setReason(e.target.value)} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Submitting…" : "Submit"}</Button>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Submitting…" : "Submit"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function PayrollLineDetailPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm text-muted-foreground">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground sm:grid-cols-3">
|
||||
<div>Present days: {line.presentDays}</div>
|
||||
<div>Absent days: {line.absentDays}</div>
|
||||
<div>Leave days: {line.leaveDays}</div>
|
||||
|
||||
@@ -88,12 +88,12 @@ export default function PayrollRunDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">{run.docNo}</h1>
|
||||
<p className="text-base text-muted-foreground">{run.periodMonth.toString().padStart(2, "0")}/{run.periodYear} · {run.employeeCount} employees</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline" className="h-7 px-3 text-sm">{run.status}</Badge>
|
||||
{run.status === "Draft" && <Button onClick={approve} disabled={busy}>Approve</Button>}
|
||||
{run.status === "Approved" && <Button onClick={lock} disabled={busy}>Lock Payroll</Button>}
|
||||
@@ -110,9 +110,9 @@ export default function PayrollRunDetailPage() {
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="p-reason">Reason</FieldLabel><Input id="p-reason" value={unlockReason} onChange={(e) => setUnlockReason(e.target.value)} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
<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-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function PayrollRunsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Payroll</h1>
|
||||
<p className="text-base text-muted-foreground">Generate → Review → Approve → Lock → Payslips.</p>
|
||||
@@ -76,14 +76,14 @@ export default function PayrollRunsPage() {
|
||||
<DialogDescription>Blocked if attendance for this period isn't fully Confirmed yet.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Year</FieldLabel><Input type="number" value={periodYear} onChange={(e) => setPeriodYear(Number(e.target.value))} /></Field>
|
||||
<Field><FieldLabel>Month</FieldLabel><Input type="number" min={1} max={12} value={periodMonth} onChange={(e) => setPeriodMonth(Number(e.target.value))} /></Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleGenerate} disabled={submitting}>{submitting ? "Generating…" : "Generate"}</Button>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleGenerate} disabled={submitting}>{submitting ? "Generating…" : "Generate"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function DepartmentsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Departments</h1>
|
||||
<p className="text-base text-muted-foreground">Org structure — unlimited nesting, optionally scoped to a branch.</p>
|
||||
@@ -140,9 +140,9 @@ export default function DepartmentsPage() {
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function DocumentTypesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Document Types</h1>
|
||||
<p className="text-base text-muted-foreground">The staff document catalog — NIC, contracts, certificates, etc.</p>
|
||||
@@ -120,9 +120,9 @@ export default function DocumentTypesPage() {
|
||||
<FieldLabel htmlFor="dt-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function LeaveTypesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">Leave Types</h1>
|
||||
<p className="text-base text-muted-foreground">Annual, Casual, Medical, Unpaid, etc. — drives Attendance's OnLeave classification and Payroll's No-Pay calc.</p>
|
||||
@@ -121,9 +121,9 @@ export default function LeaveTypesPage() {
|
||||
<FieldLabel htmlFor="lt-carry">Carry-forward allowed</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user