Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99b669395e | |||
| 8835ba9500 | |||
| 251b23f4b5 | |||
| 283a56723f | |||
| 1c5b05e315 |
@@ -24,6 +24,19 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
|
||||
|
||||
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
|
||||
/// something, plus a summary total over every filtered row.</summary>
|
||||
[HttpGet("produced")]
|
||||
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? templateId,
|
||||
[FromQuery] int? warehouseId,
|
||||
[FromQuery] ProductionRunStatus? status,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
|
||||
|
||||
[HttpGet("{runId:int}")]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
|
||||
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
|
||||
JsonElement? Payload, int UserId, DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// One row of the produced-items report (<c>GET /production-runs/produced</c>) — the
|
||||
/// terminal output of a run that has produced something, costed the same way
|
||||
/// <c>PostReceiptAsync</c> costs the finished layer at approve (FR-MFG-13):
|
||||
/// <c>unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty)</c>.
|
||||
/// One row per run — server validation (<see cref="ProductionGraphValidator"/> via
|
||||
/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
|
||||
/// one, so run and produced line coincide 1:1.
|
||||
/// <para><c>UnitCost</c>/<c>TotalCost</c> are final only once <c>Status</c> is
|
||||
/// <c>Completed</c> — a leftover return can still shrink the pool between the terminal
|
||||
/// stage's Done and its Approve, same caveat as <see cref="CostPoolDto"/>.</para>
|
||||
/// </summary>
|
||||
public sealed record ProducedLineDto(
|
||||
int RunId, string DocNo, ProductionRunStatus Status,
|
||||
int TemplateId, string TemplateName,
|
||||
int WarehouseId, string WarehouseName,
|
||||
int ItemId, string ItemName,
|
||||
decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
|
||||
decimal UnitCost, decimal TotalCost,
|
||||
DateTime CreatedAt, DateTime? CompletedAt);
|
||||
|
||||
/// <summary>Aggregate footer for the produced-items report — totals over every row the filter
|
||||
/// matched, not just the current page (there is no pagination here, matching the Sales report
|
||||
/// convention of flat, filter-bounded rows).</summary>
|
||||
public sealed record ProducedSummaryDto(
|
||||
int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
|
||||
|
||||
public sealed record ProducedListResponseDto(IReadOnlyList<ProducedLineDto> Items, ProducedSummaryDto Summary);
|
||||
|
||||
/// <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,
|
||||
@@ -99,8 +128,10 @@ public sealed class CreateRunRequest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
|
||||
/// (<c>409 STAGE_NOT_EDITABLE</c>).
|
||||
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
|
||||
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
|
||||
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
|
||||
/// STAGE_NOT_EDITABLE</c> for every other status).
|
||||
/// </summary>
|
||||
public sealed class UpdateStageQuantitiesRequest
|
||||
{
|
||||
@@ -116,4 +147,11 @@ public sealed class StageQuantityLine
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required only when this line lowers a running Stock input below what it has already
|
||||
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
|
||||
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
|
||||
/// </summary>
|
||||
public int? ReasonCodeId { get; set; }
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
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 },
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
|
||||
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
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 }
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
|
||||
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
|
||||
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 24 },
|
||||
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
// 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 }
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
|
||||
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
|
||||
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductionNavPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[] { 14, "production", "/dashboard/production", null, "Production", 14 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 35, "NAV:production", 14, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 24, "production.templates", "/dashboard/production/templates", null, "Templates", 14, 1 },
|
||||
{ 25, "production.runs", "/dashboard/production/runs", null, "Runs", 14, 2 },
|
||||
{ 26, "production.produced", "/dashboard/production/produced", null, "Produced", 14, 3 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 36, "NAV:production.templates", null, 24 },
|
||||
{ 37, "NAV:production.runs", null, 25 },
|
||||
{ 38, "NAV:production.produced", null, 26 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 35);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 36);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 37);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 38);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 24);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 25);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 26);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 14);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2334,6 +2334,15 @@ namespace ERPCore.Migrations
|
||||
Label = "Sales",
|
||||
SortOrder = 13,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 14,
|
||||
Code = "production",
|
||||
Href = "/dashboard/production",
|
||||
Label = "Production",
|
||||
SortOrder = 14,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2880,6 +2889,30 @@ namespace ERPCore.Migrations
|
||||
PermissionId = 34,
|
||||
Code = "NAV:accounts.received-cheques",
|
||||
SubNavItemId = 22
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 35,
|
||||
Code = "NAV:production",
|
||||
NavItemId = 14
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 36,
|
||||
Code = "NAV:production.templates",
|
||||
SubNavItemId = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 37,
|
||||
Code = "NAV:production.runs",
|
||||
SubNavItemId = 25
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 38,
|
||||
Code = "NAV:production.produced",
|
||||
SubNavItemId = 26
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5296,6 +5329,36 @@ namespace ERPCore.Migrations
|
||||
NavItemId = 12,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 24,
|
||||
Code = "production.templates",
|
||||
Href = "/dashboard/production/templates",
|
||||
Label = "Templates",
|
||||
NavItemId = 14,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 25,
|
||||
Code = "production.runs",
|
||||
Href = "/dashboard/production/runs",
|
||||
Label = "Runs",
|
||||
NavItemId = 14,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 26,
|
||||
Code = "production.produced",
|
||||
Href = "/dashboard/production/produced",
|
||||
Label = "Produced",
|
||||
NavItemId = 14,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ public interface IProductionRunService
|
||||
|
||||
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Produced-items report (FR-MFG-13): one row per run that has produced something,
|
||||
/// terminal-output quantities and roll-up cost, plus a summary total over every
|
||||
/// filtered row. No pagination — bounded by the filters like the Sales reports.
|
||||
/// </summary>
|
||||
Task<ProducedListResponseDto> ListProducedAsync(
|
||||
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
|
||||
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
|
||||
|
||||
@@ -124,6 +124,75 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
return PagedResponse<RunSummaryDto>.Create(items, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ProducedListResponseDto> ListProducedAsync(
|
||||
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _runs.Query().AsNoTracking()
|
||||
// FR-MFG-05 (ProductionGraphValidator) guarantees exactly one item-bearing
|
||||
// output per run, always the terminal one — a row exists once that output has
|
||||
// actually produced something.
|
||||
.Where(r => r.Stages.Any(s => s.Outputs.Any(o => o.ItemId != null && o.ProducedQty > 0)));
|
||||
|
||||
if (from is not null) q = q.Where(r => r.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(r => r.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
if (templateId is not null) q = q.Where(r => r.TemplateId == templateId);
|
||||
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
|
||||
if (status is not null) q = q.Where(r => r.Status == status);
|
||||
|
||||
// Projected to an anonymous type first, then mapped client-side — EF Core 10 cannot
|
||||
// translate a record constructor call inside a projection alongside aggregates (same
|
||||
// failure mode noted in ListAsync above).
|
||||
var rows = await q
|
||||
.OrderByDescending(r => r.RunId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.RunId,
|
||||
r.DocNo,
|
||||
r.Status,
|
||||
r.TemplateId,
|
||||
TemplateName = r.Template!.Name,
|
||||
r.WarehouseId,
|
||||
WarehouseName = r.Warehouse!.Name,
|
||||
Output = r.Stages.SelectMany(s => s.Outputs)
|
||||
.Where(o => o.ItemId != null)
|
||||
.Select(o => new { o.ItemId, ItemName = o.Item!.Name, o.ProducedQty, o.ScrappedQty })
|
||||
.First(),
|
||||
ConsumedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue),
|
||||
ReturnedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue),
|
||||
r.CreatedAt,
|
||||
r.CompletedAt
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var items = rows.Select(r =>
|
||||
{
|
||||
var good = r.Output.ProducedQty - r.Output.ScrappedQty;
|
||||
// Same formula PostReceiptAsync uses to cost the finished layer at approve
|
||||
// (FR-MFG-13): pool / good qty, 6dp. TotalCost carries the pool exactly rather
|
||||
// than good * unitCost, which drifts from it past ~100 units (see the ledgerValue
|
||||
// rounding note in PostReceiptAsync).
|
||||
var pool = r.ConsumedValue - r.ReturnedValue;
|
||||
var unitCost = good > 0 ? Math.Round(pool / good, 6, MidpointRounding.AwayFromZero) : 0m;
|
||||
return new ProducedLineDto(
|
||||
r.RunId, r.DocNo, r.Status, r.TemplateId, r.TemplateName,
|
||||
r.WarehouseId, r.WarehouseName,
|
||||
r.Output.ItemId!.Value, r.Output.ItemName,
|
||||
r.Output.ProducedQty, r.Output.ScrappedQty, good,
|
||||
unitCost, pool,
|
||||
r.CreatedAt, r.CompletedAt);
|
||||
}).ToList();
|
||||
|
||||
var summary = new ProducedSummaryDto(
|
||||
items.Count,
|
||||
items.Sum(i => i.ProducedQty),
|
||||
items.Sum(i => i.ScrappedQty),
|
||||
items.Sum(i => i.GoodQty),
|
||||
items.Sum(i => i.TotalCost));
|
||||
|
||||
return new ProducedListResponseDto(items, summary);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default)
|
||||
{
|
||||
var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
|
||||
@@ -305,17 +374,58 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
|
||||
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
|
||||
|
||||
// FR-MFG-08: editable until the stage starts. Once started, consumption has
|
||||
// already happened against the planned figures.
|
||||
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
|
||||
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
|
||||
// input can still move — raising it draws the extra amount via FIFO (mirroring
|
||||
// StartStageAsync's initial draw), lowering it returns the excess to stock
|
||||
// (mirroring ReturnLeftoverAsync) — but every other status stays locked.
|
||||
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready or ProductionStageStatus.InProgress))
|
||||
throw new DomainException(ErrorCodes.StageNotEditable,
|
||||
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);
|
||||
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts or while it is running.", 409);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var balances = new Dictionary<int, decimal>();
|
||||
var consumed = new List<ConsumedInputDto>();
|
||||
var returned = new List<object>();
|
||||
|
||||
foreach (var line in request.Inputs)
|
||||
{
|
||||
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
|
||||
|
||||
if (stage.Status == ProductionStageStatus.InProgress && input.Source == StageInputSource.Stock)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
|
||||
|
||||
var plannedBase = _measure.ToPacks(item, line.PlannedQty, input.QtyUnit);
|
||||
var netConsumed = input.ConsumedQty - input.ReturnedQty;
|
||||
var delta = plannedBase - netConsumed;
|
||||
|
||||
if (delta > 0)
|
||||
{
|
||||
var (dto, _) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
|
||||
consumed.Add(dto);
|
||||
}
|
||||
else if (delta < 0)
|
||||
{
|
||||
var qty = -delta;
|
||||
if (line.ReasonCodeId is null)
|
||||
throw new DomainException(ErrorCodes.ReasonCodeRequired,
|
||||
$"A reason code is required to lower input '{input.RunInputId}' below what has already been consumed.", 400);
|
||||
await EnsureProductionReasonAsync(line.ReasonCodeId.Value, token);
|
||||
|
||||
var (unitCost, returnValue, _, _) = await ReturnInputAsync(run, input, qty, balances, now, token);
|
||||
returned.Add(new
|
||||
{
|
||||
runInputId = input.RunInputId, qty, unitCost, value = returnValue,
|
||||
reasonCodeId = line.ReasonCodeId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
input.PlannedQty = line.PlannedQty;
|
||||
}
|
||||
|
||||
@@ -330,12 +440,15 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
// Raising an upstream input's planned quantity means the deliveries that made
|
||||
// this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
|
||||
// does not state this; it is the only behaviour consistent with FR-MFG-09.
|
||||
// No-ops once the stage is running (RecomputeReadiness only moves Waiting/Ready).
|
||||
RecomputeReadiness(stage);
|
||||
|
||||
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
|
||||
{
|
||||
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
|
||||
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
|
||||
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }),
|
||||
consumed = consumed.Select(c => new { c.RunInputId, c.ItemId, c.Qty, c.Value }),
|
||||
returned
|
||||
});
|
||||
}, ct);
|
||||
|
||||
@@ -344,6 +457,87 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FIFO-consumes <paramref name="delta"/> base units of a Stock input, posts the Out
|
||||
/// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by
|
||||
/// <see cref="StartStageAsync"/> (initial draw) and <see cref="UpdateStageQuantitiesAsync"/>
|
||||
/// (raising planned qty on a running stage) so the two never drift apart.
|
||||
/// </summary>
|
||||
private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync(
|
||||
ProductionRun run, RunStageInput input, Item item, decimal delta,
|
||||
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, ct);
|
||||
var value = segments.Sum(s => s.Qty * s.UnitCost);
|
||||
var unitCost = delta == 0 ? 0m : value / delta;
|
||||
|
||||
if (!balances.TryGetValue(item.ItemId, out var balance))
|
||||
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct);
|
||||
balance -= delta;
|
||||
balances[item.ItemId] = balance;
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, _currentUser.AuditUserId,
|
||||
Direction.Out, delta, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionIssue, run.RunId, now, ct);
|
||||
|
||||
input.ConsumedQty += delta;
|
||||
input.ConsumedValue += value;
|
||||
|
||||
var dto = new ConsumedInputDto(
|
||||
input.RunInputId, item.ItemId, delta, value,
|
||||
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList());
|
||||
return (dto, entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="qty"/> base units of a Stock input's consumption back to
|
||||
/// stock: a new inbound FIFO layer at the input's weighted consumed cost, plus the In
|
||||
/// ledger entry. Cost-preserving — a full return zeroes the input's contribution to the
|
||||
/// cost pool exactly, with no rounding crumb left behind (see the full-return branch
|
||||
/// below). Shared by <see cref="ReturnLeftoverAsync"/> and
|
||||
/// <see cref="UpdateStageQuantitiesAsync"/> (lowering planned qty on a running stage).
|
||||
/// </summary>
|
||||
private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync(
|
||||
ProductionRun run, RunStageInput input, decimal qty,
|
||||
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
|
||||
{
|
||||
// Weighted cost actually consumed for THIS input — cost-preserving by construction,
|
||||
// so what leaves the pool is exactly what entered it.
|
||||
var weighted = input.ConsumedValue / input.ConsumedQty;
|
||||
var unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and then
|
||||
// multiplying lets successive partial returns accumulate drift until the last one
|
||||
// can exceed ConsumedValue.
|
||||
var returnValue = input.ReturnedQty + qty == input.ConsumedQty
|
||||
// Full return: take the exact residual so the input's contribution to the pool
|
||||
// lands on precisely zero rather than a rounding crumb.
|
||||
? input.ConsumedValue - input.ReturnedValue
|
||||
: Math.Round(qty * weighted, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
|
||||
qty, unitCost, now, ct);
|
||||
|
||||
if (!balances.TryGetValue(input.ItemId.Value, out var balance))
|
||||
balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, ct);
|
||||
balance += qty;
|
||||
balances[input.ItemId.Value] = balance;
|
||||
|
||||
// binId stays null: this is raw material going back to general stock, not into the
|
||||
// run's finished-goods output bin.
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
|
||||
_currentUser.AuditUserId, Direction.In, qty, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionReturn, run.RunId, now, ct, valueOverride: returnValue);
|
||||
|
||||
input.ReturnedQty += qty;
|
||||
input.ReturnedValue += returnValue;
|
||||
|
||||
return (unitCost, returnValue, layer, entry);
|
||||
}
|
||||
|
||||
// --- stage actions -------------------------------------------------------
|
||||
|
||||
public async Task<StartStageResultDto> StartStageAsync(
|
||||
@@ -361,7 +555,6 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var actor = _currentUser.AuditUserId;
|
||||
|
||||
// Running balances accumulated per item across the loop: layers written in this
|
||||
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
|
||||
@@ -386,27 +579,9 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
var delta = plannedBase - input.ConsumedQty;
|
||||
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
|
||||
|
||||
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
|
||||
var value = segments.Sum(s => s.Qty * s.UnitCost);
|
||||
var unitCost = delta == 0 ? 0m : value / delta;
|
||||
|
||||
if (!balances.TryGetValue(item.ItemId, out var balance))
|
||||
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
|
||||
balance -= delta;
|
||||
balances[item.ItemId] = balance;
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
|
||||
Direction.Out, delta, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
|
||||
var (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
|
||||
ledgerRefs.Add(entry);
|
||||
|
||||
input.ConsumedQty += delta;
|
||||
input.ConsumedValue += value;
|
||||
|
||||
consumed.Add(new ConsumedInputDto(
|
||||
input.RunInputId, item.ItemId, delta, value,
|
||||
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
|
||||
consumed.Add(dto);
|
||||
}
|
||||
|
||||
stage.Status = ProductionStageStatus.InProgress;
|
||||
@@ -649,36 +824,9 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
var now = DateTime.UtcNow;
|
||||
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
|
||||
|
||||
// Weighted cost actually consumed for THIS input — cost-preserving by
|
||||
// construction, so what leaves the pool is exactly what entered it.
|
||||
var weighted = input.ConsumedValue / input.ConsumedQty;
|
||||
unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
|
||||
returnedQty = request.Qty;
|
||||
|
||||
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
|
||||
// then multiplying lets successive partial returns accumulate drift until the
|
||||
// last one can exceed ConsumedValue.
|
||||
returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
|
||||
// Full return: take the exact residual so the input's contribution to the
|
||||
// pool lands on precisely zero rather than a rounding crumb.
|
||||
? input.ConsumedValue - input.ReturnedValue
|
||||
: Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
layer = await _fifo.CreateInboundLayerAsync(
|
||||
input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
|
||||
request.Qty, unitCost, now, token);
|
||||
|
||||
var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;
|
||||
|
||||
// binId stays null: this is raw material going back to general stock, not into the
|
||||
// run's finished-goods output bin.
|
||||
entry = await _fifo.PostLedgerAsync(
|
||||
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
|
||||
_currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);
|
||||
|
||||
input.ReturnedQty += request.Qty;
|
||||
input.ReturnedValue += returnValue;
|
||||
(unitCost, returnValue, layer, entry) =
|
||||
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
|
||||
|
||||
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
|
||||
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, BookOpen } from "lucide-react"
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function GeneralLedgerReportPage() {
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">General Ledger</h1>
|
||||
<p className="text-base text-muted-foreground">Every posted movement on every account, with a running balance per account.</p>
|
||||
<p className="text-base text-muted-foreground">Every posted movement, grouped by transaction so every leg of a GRN, Sale, or other journal entry sits together.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,37 +108,32 @@ export default function GeneralLedgerReportPage() {
|
||||
<Table className="mt-4 text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Journal No.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Narration</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Running balance</TableHead>
|
||||
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => {
|
||||
const isNewAccount = i === 0 || row.accountCode !== rows[i - 1].accountCode
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{isNewAccount && (
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableCell colSpan={6} className="px-3 py-2 font-semibold">
|
||||
{row.accountCode} — {row.accountName}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 font-medium">{row.journalNo}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right font-medium tabular-nums">{formatAmount(row.runningBalance)}</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
|
||||
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
|
||||
lands on adjacent rows instead of being scattered under separate account blocks. */}
|
||||
{[...rows]
|
||||
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
|
||||
.map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PackageCheck } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { productionRunsApi } from "@/lib/api/production-runs"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = ProductionRunStatus | "All"
|
||||
|
||||
function runStatusBadgeClass(status: ProductionRunStatus) {
|
||||
if (status === "Completed") return "bg-success/10 text-success"
|
||||
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
|
||||
return "bg-info/10 text-info"
|
||||
}
|
||||
|
||||
// 6dp on the wire (matches the server's PostReceiptAsync rounding); shown to 4dp so the unit
|
||||
// cost of a fractional-value item is still legible without dragging the whole table wide.
|
||||
function formatCost(value: number) {
|
||||
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
export default function ProducedItemsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// null = still loading (the codebase convention for "no data yet" vs "empty result").
|
||||
const [data, setData] = useState<ProducedListResponse | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
|
||||
const [from, setFrom] = useState("")
|
||||
const [to, setTo] = useState("")
|
||||
const [templateId, setTemplateId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null)
|
||||
productionRunsApi
|
||||
.listProduced({
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
templateId: templateId ?? undefined,
|
||||
warehouseId: warehouseId ?? undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err) => {
|
||||
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
|
||||
setLoadError(errorMessage(err))
|
||||
})
|
||||
}, [from, to, templateId, warehouseId, status])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
|
||||
.then(([templateRes, warehouseRes]) => {
|
||||
setTemplates(templateRes.items)
|
||||
setWarehouses(warehouseRes.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Template</Label>
|
||||
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All templates" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Status</Label>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
||||
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
setFrom("")
|
||||
setTo("")
|
||||
setTemplateId(null)
|
||||
setWarehouseId(null)
|
||||
setStatus("All")
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{data === null ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</div>
|
||||
) : data.items.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PackageCheck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No produced lines match your filter." : "No runs have produced anything yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((line) => (
|
||||
<TableRow
|
||||
key={line.runId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
||||
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
||||
Cost of Production: {formatCost(data.summary.totalCost)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
RunStage,
|
||||
RunStageInput,
|
||||
RunStageOutput,
|
||||
StageQtyUnit,
|
||||
StageQuantityLine,
|
||||
TransferLine,
|
||||
} from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { OnHand, ReasonCode } from "@/types/stock"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
|
||||
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
|
||||
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
|
||||
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
|
||||
* to ask for a reason up front. The server always recomputes it authoritatively.
|
||||
*/
|
||||
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
|
||||
if (unit === "Pack" || itemId === null) return qty
|
||||
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
|
||||
if (!contentBaseQty) return qty
|
||||
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
|
||||
// stored at that rounded value, so comparing against the raw division here would flag an
|
||||
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
|
||||
// decrease and demand a reason code nothing actually changed.
|
||||
return Math.round((qty / contentBaseQty) * 10000) / 10000
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: StageStatus }) {
|
||||
const color = STAGE_STATUS_COLOR[status]
|
||||
return (
|
||||
@@ -102,9 +120,13 @@ export function StageDrawer({
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Editable planned quantities (Ready only) keyed by run input/output id.
|
||||
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
|
||||
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
|
||||
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
|
||||
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
|
||||
// Reason code for a running Stock input whose planned qty is being lowered below what it
|
||||
// has already consumed — required by the server the same way a leftover return is.
|
||||
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
|
||||
|
||||
// Complete-form state.
|
||||
const [produced, setProduced] = useState<Record<number, string>>({})
|
||||
@@ -135,6 +157,7 @@ export function StageDrawer({
|
||||
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
|
||||
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
|
||||
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
|
||||
setInputReasonCode({})
|
||||
setError(null)
|
||||
idempotencyKey.current = crypto.randomUUID()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -212,7 +235,11 @@ export function StageDrawer({
|
||||
|
||||
function saveQuantities() {
|
||||
const inputs: StageQuantityLine[] = stage!.inputs
|
||||
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
|
||||
.map((i) => ({
|
||||
id: i.runInputId,
|
||||
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
|
||||
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
|
||||
}))
|
||||
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
|
||||
const outputs: StageQuantityLine[] = stage!.outputs
|
||||
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
|
||||
@@ -284,6 +311,21 @@ export function StageDrawer({
|
||||
|
||||
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
|
||||
|
||||
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
|
||||
// lowering it returns the excess and — same rule a leftover return already enforces
|
||||
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
|
||||
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
|
||||
const isInputDecrease = (input: RunStageInput) => {
|
||||
const plannedBase = toPacksClient(
|
||||
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
|
||||
)
|
||||
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
|
||||
}
|
||||
const missingInputReasons =
|
||||
stage.status === "InProgress"
|
||||
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
|
||||
: []
|
||||
|
||||
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
|
||||
const missingScrapReasons = stage.outputs
|
||||
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
|
||||
@@ -339,6 +381,7 @@ export function StageDrawer({
|
||||
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||
itemName={itemName}
|
||||
unitLabel={inputUnit}
|
||||
warehouseId={run.warehouseId}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@@ -424,6 +467,39 @@ export function StageDrawer({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
|
||||
reason for any decrease.
|
||||
</p>
|
||||
{runningStockInputs.map((input) => (
|
||||
<RunningStockInputRow
|
||||
key={input.runInputId}
|
||||
input={input}
|
||||
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
|
||||
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||
itemName={itemName}
|
||||
unitLabel={inputUnit}
|
||||
warehouseId={run.warehouseId}
|
||||
decreasing={isInputDecrease(input)}
|
||||
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
|
||||
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
|
||||
scrapReasons={scrapReasons}
|
||||
/>
|
||||
))}
|
||||
{missingInputReasons.length > 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Pick a reason for lowering: {missingInputReasons.join(", ")}.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
|
||||
Save quantities
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "InProgress" && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Record output</h3>
|
||||
@@ -662,6 +738,56 @@ export function StageDrawer({
|
||||
}
|
||||
|
||||
/** Split out only because the input card has three mutually exclusive quantity presentations. */
|
||||
/**
|
||||
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
|
||||
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
|
||||
*/
|
||||
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
|
||||
const [onHand, setOnHand] = useState<OnHand | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!enabled || itemId === null) {
|
||||
setOnHand(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
stockApi
|
||||
.onHand(itemId, warehouseId)
|
||||
.then(setOnHand)
|
||||
.catch((err) => {
|
||||
setOnHand(null)
|
||||
setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [enabled, itemId, warehouseId])
|
||||
return { onHand, loading, error }
|
||||
}
|
||||
|
||||
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
|
||||
* RunningStockInputRow both show under an editable Planned qty. */
|
||||
function AvailableStockLine({
|
||||
loading, error, onHand,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onHand: OnHand | null
|
||||
}) {
|
||||
return (
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "Checking available stock…"
|
||||
: error
|
||||
? "Available stock unknown"
|
||||
: onHand
|
||||
? `Available: ${fmt(onHand.available)}`
|
||||
: null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function InputCard({
|
||||
input,
|
||||
editable,
|
||||
@@ -669,6 +795,7 @@ function InputCard({
|
||||
onValueChange,
|
||||
itemName,
|
||||
unitLabel,
|
||||
warehouseId,
|
||||
}: {
|
||||
input: RunStageInput
|
||||
editable: boolean
|
||||
@@ -676,10 +803,16 @@ function InputCard({
|
||||
onValueChange: (value: string) => void
|
||||
itemName: (id: number | null) => string
|
||||
unitLabel: (input: RunStageInput) => string
|
||||
warehouseId: number
|
||||
}) {
|
||||
const isUpstream = input.source === "Upstream"
|
||||
const short = isUpstream && input.deliveredQty < input.plannedQty
|
||||
|
||||
// Available stock is only meaningful while the planned qty is still editable (Ready) and
|
||||
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
|
||||
const { onHand, loading: onHandLoading, error: onHandError } =
|
||||
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
@@ -691,17 +824,22 @@ function InputCard({
|
||||
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{editable ? (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{!isUpstream && input.itemId !== null && (
|
||||
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Row label="Planned">{fmt(input.plannedQty)}</Row>
|
||||
)}
|
||||
@@ -735,6 +873,69 @@ function InputCard({
|
||||
)
|
||||
}
|
||||
|
||||
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
|
||||
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
|
||||
function RunningStockInputRow({
|
||||
input,
|
||||
value,
|
||||
onValueChange,
|
||||
itemName,
|
||||
unitLabel,
|
||||
warehouseId,
|
||||
decreasing,
|
||||
reasonCodeId,
|
||||
onReasonChange,
|
||||
scrapReasons,
|
||||
}: {
|
||||
input: RunStageInput
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
itemName: (id: number | null) => string
|
||||
unitLabel: (input: RunStageInput) => string
|
||||
warehouseId: number
|
||||
decreasing: boolean
|
||||
reasonCodeId: number | null
|
||||
onReasonChange: (id: number) => void
|
||||
scrapReasons: ReasonCode[]
|
||||
}) {
|
||||
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{itemName(input.itemId)}</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
|
||||
{decreasing && (
|
||||
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
|
||||
<SelectTrigger className="h-9! w-full text-sm">
|
||||
<SelectValue placeholder="Reason for lowering (required)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scrapReasons.map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
|
||||
{r.code} — {r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
|
||||
function CustomFields({
|
||||
stage,
|
||||
|
||||
@@ -27,6 +27,10 @@ function slugify(label: string) {
|
||||
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
function fmtStock(available: number | undefined) {
|
||||
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
|
||||
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
|
||||
|
||||
@@ -128,6 +132,7 @@ export function StageEditorPanel({
|
||||
upstreamOptions,
|
||||
items,
|
||||
uoms,
|
||||
stockByItem,
|
||||
readOnly,
|
||||
onChange,
|
||||
onDelete,
|
||||
@@ -138,6 +143,8 @@ export function StageEditorPanel({
|
||||
upstreamOptions: UpstreamOutputOption[]
|
||||
items: ItemListItem[]
|
||||
uoms: Uom[]
|
||||
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
|
||||
stockByItem: Map<number, number>
|
||||
readOnly: boolean
|
||||
onChange: (patch: Partial<StageNodeData>) => void
|
||||
onDelete: () => void
|
||||
@@ -239,18 +246,19 @@ export function StageEditorPanel({
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
<Select<string>
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
onValueChange={(v) => v && onChange({ roleLabel: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
@@ -312,6 +320,9 @@ export function StageEditorPanel({
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
<span className="text-muted-foreground">
|
||||
· Stock: {fmtStock(stockByItem.get(i.itemId))}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
|
||||
}, [isNew, templateId, load])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
|
||||
.then(([itemRes, uomRes]) => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
stockApi.onHandList({ pageSize: 200 }),
|
||||
])
|
||||
.then(([itemRes, uomRes, onHandRes]) => {
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
// Templates aren't tied to a warehouse, so the picker shows availability summed across
|
||||
// every warehouse the item holds stock in.
|
||||
const byItem = new Map<number, number>()
|
||||
for (const row of onHandRes.items) {
|
||||
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
|
||||
}
|
||||
setStockByItem(byItem)
|
||||
})
|
||||
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
|
||||
}, [])
|
||||
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
stockByItem={stockByItem}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
|
||||
@@ -154,6 +154,7 @@ const navItems: {
|
||||
children: [
|
||||
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
||||
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
|
||||
{ title: "Produced", code: "production.produced", href: "/dashboard/production/produced", icon: PackageCheck },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
CancelRunResult,
|
||||
CompleteStageRequest,
|
||||
CreateRunRequest,
|
||||
ProducedListResponse,
|
||||
ProductionRunGraph,
|
||||
ProductionRunStatus,
|
||||
ProductionRunSummary,
|
||||
@@ -43,11 +44,24 @@ export interface ListRunsParams {
|
||||
sort?: string
|
||||
}
|
||||
|
||||
export interface ListProducedParams {
|
||||
from?: string
|
||||
to?: string
|
||||
templateId?: number
|
||||
warehouseId?: number
|
||||
status?: ProductionRunStatus
|
||||
}
|
||||
|
||||
export const productionRunsApi = {
|
||||
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
|
||||
return apiRequest<PagedResponse<ProductionRunSummary>>(`/production-runs${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
/** Produced-items report (FR-MFG-13): flat rows + a summary total, no pagination. */
|
||||
listProduced(params: ListProducedParams = {}): Promise<ProducedListResponse> {
|
||||
return apiRequest<ProducedListResponse>(`/production-runs/produced${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(runId: number): Promise<ApiResult<ProductionRunGraph>> {
|
||||
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
|
||||
},
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface GeneralLedgerRow {
|
||||
debitAmount: number
|
||||
creditAmount: number
|
||||
runningBalance: number
|
||||
/** Which module posted this journal (GRN, Sales, ManualLedgerEntry, Acc_Account, ...) — shown as the "Controller" column. */
|
||||
sourceModule: string | null
|
||||
}
|
||||
|
||||
/** One line inside a Profit & Loss section — inferred shape (see the ProfitAndLossResponse note). */
|
||||
|
||||
@@ -306,6 +306,46 @@ export interface ProductionRunSummary {
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the produced-items report (`GET /production-runs/produced`) — the terminal
|
||||
* output of a run that has produced something. `unitCost`/`totalCost` use the same
|
||||
* `pool / goodQty` formula the server applies at approve (FR-MFG-13); they are final only
|
||||
* once `status` is `"Completed"` — a leftover return can still shrink the pool between the
|
||||
* terminal stage's Done and its Approve.
|
||||
*/
|
||||
export interface ProducedLine {
|
||||
runId: number
|
||||
docNo: string
|
||||
status: ProductionRunStatus
|
||||
templateId: number
|
||||
templateName: string
|
||||
warehouseId: number
|
||||
warehouseName: string
|
||||
itemId: number
|
||||
itemName: string
|
||||
producedQty: number
|
||||
scrappedQty: number
|
||||
goodQty: number
|
||||
unitCost: number
|
||||
totalCost: number
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
/** Aggregate footer over every row the filter matched (no pagination). */
|
||||
export interface ProducedSummary {
|
||||
runCount: number
|
||||
totalProducedQty: number
|
||||
totalScrappedQty: number
|
||||
totalGoodQty: number
|
||||
totalCost: number
|
||||
}
|
||||
|
||||
export interface ProducedListResponse {
|
||||
items: ProducedLine[]
|
||||
summary: ProducedSummary
|
||||
}
|
||||
|
||||
export interface ProductionRunGraph {
|
||||
runId: number
|
||||
docNo: string
|
||||
@@ -338,6 +378,8 @@ export interface CreateRunRequest {
|
||||
export interface StageQuantityLine {
|
||||
id: number
|
||||
plannedQty: number
|
||||
/** Required only when this lowers a running Stock input below what it has already consumed. */
|
||||
reasonCodeId?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateStageQuantitiesRequest {
|
||||
|
||||
Reference in New Issue
Block a user