From 1c5b05e31554cabfb070933b850eb515210c4bf9 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Mon, 17 Aug 2026 14:30:55 +0530 Subject: [PATCH] feat: enhance stage quantity management for running stock inputs - Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts. - Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases. - Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock. - Added utility functions for stock management and improved user feedback for stock adjustments. --- Backend/ERPCore/Dtos/Production/RunDtos.cs | 13 +- .../Production/ProductionRunService.cs | 189 ++++++++++----- .../production/runs/[id]/StageDrawer.tsx | 229 ++++++++++++++++-- .../templates/[id]/StageEditorPanel.tsx | 33 ++- .../production/templates/[id]/page.tsx | 18 +- Frontend/erp-system/types/production.ts | 2 + 6 files changed, 400 insertions(+), 84 deletions(-) diff --git a/Backend/ERPCore/Dtos/Production/RunDtos.cs b/Backend/ERPCore/Dtos/Production/RunDtos.cs index 9c45f1a..28743c9 100644 --- a/Backend/ERPCore/Dtos/Production/RunDtos.cs +++ b/Backend/ERPCore/Dtos/Production/RunDtos.cs @@ -99,8 +99,10 @@ public sealed class CreateRunRequest } /// -/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started -/// (409 STAGE_NOT_EDITABLE). +/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock +/// input only — while it is running too: raising +/// draws the extra amount from FIFO, lowering it returns the excess (409 +/// STAGE_NOT_EDITABLE for every other status). /// public sealed class UpdateStageQuantitiesRequest { @@ -116,4 +118,11 @@ public sealed class StageQuantityLine [Range(0.0001, double.MaxValue)] public decimal PlannedQty { get; set; } + + /// + /// 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 ReturnLeftoverAsync + /// does, and that always needs a reason (FR-MFG-14). Ignored otherwise. + /// + public int? ReasonCodeId { get; set; } } diff --git a/Backend/ERPCore/Services/Production/ProductionRunService.cs b/Backend/ERPCore/Services/Production/ProductionRunService.cs index 2a00825..5c25218 100644 --- a/Backend/ERPCore/Services/Production/ProductionRunService.cs +++ b/Backend/ERPCore/Services/Production/ProductionRunService.cs @@ -294,17 +294,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(); + var consumed = new List(); + var returned = new List(); 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; } @@ -319,12 +360,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); @@ -333,6 +377,87 @@ public sealed class ProductionRunService : IProductionRunService return refreshed.Value.Stages.First(s => s.RunStageId == runStageId); } + /// + /// FIFO-consumes base units of a Stock input, posts the Out + /// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by + /// (initial draw) and + /// (raising planned qty on a running stage) so the two never drift apart. + /// + private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync( + ProductionRun run, RunStageInput input, Item item, decimal delta, + Dictionary 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); + } + + /// + /// Returns 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 and + /// (lowering planned qty on a running stage). + /// + private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync( + ProductionRun run, RunStageInput input, decimal qty, + Dictionary 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 StartStageAsync( @@ -350,7 +475,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 @@ -375,27 +499,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; @@ -638,36 +744,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(), 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); diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx index c3ce5aa..a66aca3 100644 --- a/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx +++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx @@ -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(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>({}) const [plannedOutputs, setPlannedOutputs] = useState>({}) + // 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>({}) // Complete-form state. const [produced, setProduced] = useState>({}) @@ -135,6 +157,7 @@ export function StageDrawer({ setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId]))) setFieldValues((stage.fieldValues ?? {}) as Record) 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} /> ))} @@ -424,6 +467,39 @@ export function StageDrawer({ )} + {stage.status === "InProgress" && runningStockInputs.length > 0 && ( +
+

Adjust input quantities

+

+ Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a + reason for any decrease. +

+ {runningStockInputs.map((input) => ( + 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 && ( +

+ Pick a reason for lowering: {missingInputReasons.join(", ")}. +

+ )} + +
+ )} + {stage.status === "InProgress" && (

Record output

@@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +

+ {loading + ? "Checking available stock…" + : error + ? "Available stock unknown" + : onHand + ? `Available: ${fmt(onHand.available)}` + : null} +

+ ) +} + 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 (
@@ -691,17 +824,22 @@ function InputCard({
{editable ? ( - + <> + + {!isUpstream && input.itemId !== null && ( + + )} + ) : ( {fmt(input.plannedQty)} )} @@ -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 ( +
+
+

{itemName(input.itemId)}

+ {unitLabel(input)} +
+ + + {decreasing && ( + value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}> + + + + + {scrapReasons.map((r) => ( + + {r.code} — {r.description} + + ))} + + + )} +
+ ) +} + /** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */ function CustomFields({ stage, diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx index 2f8d589..06947e0 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx @@ -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 readOnly: boolean onChange: (patch: Partial) => void onDelete: () => void @@ -239,18 +246,19 @@ export function StageEditorPanel({ Role label - value={data.roleLabel} - disabled={readOnly} - onChange={(e) => onChange({ roleLabel: e.target.value })} - placeholder="e.g. QA" - list="role-suggestions" - /> - - {ROLE_SUGGESTIONS.map((r) => ( - + onValueChange={(v) => v && onChange({ roleLabel: v })} + > + + + + + {ROLE_SUGGESTIONS.map((r) => ( + {r} + ))} + + @@ -312,6 +320,9 @@ export function StageEditorPanel({ {items.map((i) => ( {i.name} · {i.sku} + + · Stock: {fmtStock(stockByItem.get(i.itemId))} + ))} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx index 0b4c68c..f7c27c3 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx @@ -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([]) const [uoms, setUoms] = useState([]) + const [stockByItem, setStockByItem] = useState>(new Map()) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(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() + 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)} diff --git a/Frontend/erp-system/types/production.ts b/Frontend/erp-system/types/production.ts index cdb7a33..4ccf732 100644 --- a/Frontend/erp-system/types/production.ts +++ b/Frontend/erp-system/types/production.ts @@ -338,6 +338,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 {