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.
This commit is contained in:
@@ -99,8 +99,10 @@ public sealed class CreateRunRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
|
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
|
||||||
/// (<c>409 STAGE_NOT_EDITABLE</c>).
|
/// 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>
|
/// </summary>
|
||||||
public sealed class UpdateStageQuantitiesRequest
|
public sealed class UpdateStageQuantitiesRequest
|
||||||
{
|
{
|
||||||
@@ -116,4 +118,11 @@ public sealed class StageQuantityLine
|
|||||||
|
|
||||||
[Range(0.0001, double.MaxValue)]
|
[Range(0.0001, double.MaxValue)]
|
||||||
public decimal PlannedQty { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,17 +294,58 @@ public sealed class ProductionRunService : IProductionRunService
|
|||||||
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
|
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
|
||||||
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
|
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
|
||||||
|
|
||||||
// FR-MFG-08: editable until the stage starts. Once started, consumption has
|
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
|
||||||
// already happened against the planned figures.
|
// input can still move — raising it draws the extra amount via FIFO (mirroring
|
||||||
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
|
// 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,
|
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)
|
foreach (var line in request.Inputs)
|
||||||
{
|
{
|
||||||
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
|
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
|
||||||
?? throw new DomainException(ErrorCodes.Validation,
|
?? throw new DomainException(ErrorCodes.Validation,
|
||||||
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
|
$"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;
|
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
|
// 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
|
// 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.
|
// 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);
|
RecomputeReadiness(stage);
|
||||||
|
|
||||||
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
|
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
|
||||||
{
|
{
|
||||||
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
|
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);
|
}, ct);
|
||||||
|
|
||||||
@@ -333,6 +377,87 @@ public sealed class ProductionRunService : IProductionRunService
|
|||||||
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
|
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 -------------------------------------------------------
|
// --- stage actions -------------------------------------------------------
|
||||||
|
|
||||||
public async Task<StartStageResultDto> StartStageAsync(
|
public async Task<StartStageResultDto> StartStageAsync(
|
||||||
@@ -350,7 +475,6 @@ public sealed class ProductionRunService : IProductionRunService
|
|||||||
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
|
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var actor = _currentUser.AuditUserId;
|
|
||||||
|
|
||||||
// Running balances accumulated per item across the loop: layers written in this
|
// Running balances accumulated per item across the loop: layers written in this
|
||||||
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
|
// 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;
|
var delta = plannedBase - input.ConsumedQty;
|
||||||
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
|
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 (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, 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);
|
|
||||||
ledgerRefs.Add(entry);
|
ledgerRefs.Add(entry);
|
||||||
|
consumed.Add(dto);
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stage.Status = ProductionStageStatus.InProgress;
|
stage.Status = ProductionStageStatus.InProgress;
|
||||||
@@ -638,36 +744,9 @@ public sealed class ProductionRunService : IProductionRunService
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
|
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;
|
returnedQty = request.Qty;
|
||||||
|
(unitCost, returnValue, layer, entry) =
|
||||||
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
|
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
|
||||||
// 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;
|
|
||||||
|
|
||||||
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
|
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
|
||||||
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
|
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
RunStage,
|
RunStage,
|
||||||
RunStageInput,
|
RunStageInput,
|
||||||
RunStageOutput,
|
RunStageOutput,
|
||||||
|
StageQtyUnit,
|
||||||
StageQuantityLine,
|
StageQuantityLine,
|
||||||
TransferLine,
|
TransferLine,
|
||||||
} from "@/types/production"
|
} from "@/types/production"
|
||||||
import { ItemListItem, Uom } from "@/types/master-data"
|
import { ItemListItem, Uom } from "@/types/master-data"
|
||||||
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
|
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 { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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))
|
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 }) {
|
function StatusPill({ status }: { status: StageStatus }) {
|
||||||
const color = STAGE_STATUS_COLOR[status]
|
const color = STAGE_STATUS_COLOR[status]
|
||||||
return (
|
return (
|
||||||
@@ -102,9 +120,13 @@ export function StageDrawer({
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
|
||||||
const [plannedOutputs, setPlannedOutputs] = 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.
|
// Complete-form state.
|
||||||
const [produced, setProduced] = useState<Record<number, string>>({})
|
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])))
|
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
|
||||||
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
|
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
|
||||||
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
|
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
|
||||||
|
setInputReasonCode({})
|
||||||
setError(null)
|
setError(null)
|
||||||
idempotencyKey.current = crypto.randomUUID()
|
idempotencyKey.current = crypto.randomUUID()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -212,7 +235,11 @@ export function StageDrawer({
|
|||||||
|
|
||||||
function saveQuantities() {
|
function saveQuantities() {
|
||||||
const inputs: StageQuantityLine[] = stage!.inputs
|
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)
|
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
|
||||||
const outputs: StageQuantityLine[] = stage!.outputs
|
const outputs: StageQuantityLine[] = stage!.outputs
|
||||||
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
|
.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")
|
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 missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
|
||||||
const missingScrapReasons = stage.outputs
|
const missingScrapReasons = stage.outputs
|
||||||
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
|
.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 }))}
|
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||||
itemName={itemName}
|
itemName={itemName}
|
||||||
unitLabel={inputUnit}
|
unitLabel={inputUnit}
|
||||||
|
warehouseId={run.warehouseId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
@@ -424,6 +467,39 @@ export function StageDrawer({
|
|||||||
</section>
|
</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" && (
|
{stage.status === "InProgress" && (
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<h3 className="text-sm font-bold text-foreground">Record output</h3>
|
<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. */
|
/** 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({
|
function InputCard({
|
||||||
input,
|
input,
|
||||||
editable,
|
editable,
|
||||||
@@ -669,6 +795,7 @@ function InputCard({
|
|||||||
onValueChange,
|
onValueChange,
|
||||||
itemName,
|
itemName,
|
||||||
unitLabel,
|
unitLabel,
|
||||||
|
warehouseId,
|
||||||
}: {
|
}: {
|
||||||
input: RunStageInput
|
input: RunStageInput
|
||||||
editable: boolean
|
editable: boolean
|
||||||
@@ -676,10 +803,16 @@ function InputCard({
|
|||||||
onValueChange: (value: string) => void
|
onValueChange: (value: string) => void
|
||||||
itemName: (id: number | null) => string
|
itemName: (id: number | null) => string
|
||||||
unitLabel: (input: RunStageInput) => string
|
unitLabel: (input: RunStageInput) => string
|
||||||
|
warehouseId: number
|
||||||
}) {
|
}) {
|
||||||
const isUpstream = input.source === "Upstream"
|
const isUpstream = input.source === "Upstream"
|
||||||
const short = isUpstream && input.deliveredQty < input.plannedQty
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-border p-3">
|
<div className="rounded-lg border border-border p-3">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
@@ -691,17 +824,22 @@ function InputCard({
|
|||||||
|
|
||||||
<div className="mt-2 flex flex-col gap-1">
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
{editable ? (
|
{editable ? (
|
||||||
<label className="flex items-center justify-between gap-3 text-sm">
|
<>
|
||||||
<span className="text-muted-foreground">Planned</span>
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
<Input
|
<span className="text-muted-foreground">Planned</span>
|
||||||
type="number"
|
<Input
|
||||||
min={0}
|
type="number"
|
||||||
step="any"
|
min={0}
|
||||||
value={value}
|
step="any"
|
||||||
onChange={(e) => onValueChange(e.target.value)}
|
value={value}
|
||||||
className="h-8 w-28 text-sm"
|
onChange={(e) => onValueChange(e.target.value)}
|
||||||
/>
|
className="h-8 w-28 text-sm"
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
{!isUpstream && input.itemId !== null && (
|
||||||
|
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Row label="Planned">{fmt(input.plannedQty)}</Row>
|
<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. */
|
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
|
||||||
function CustomFields({
|
function CustomFields({
|
||||||
stage,
|
stage,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ function slugify(label: string) {
|
|||||||
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
|
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 ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
|
||||||
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
|
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
|
||||||
|
|
||||||
@@ -128,6 +132,7 @@ export function StageEditorPanel({
|
|||||||
upstreamOptions,
|
upstreamOptions,
|
||||||
items,
|
items,
|
||||||
uoms,
|
uoms,
|
||||||
|
stockByItem,
|
||||||
readOnly,
|
readOnly,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -138,6 +143,8 @@ export function StageEditorPanel({
|
|||||||
upstreamOptions: UpstreamOutputOption[]
|
upstreamOptions: UpstreamOutputOption[]
|
||||||
items: ItemListItem[]
|
items: ItemListItem[]
|
||||||
uoms: Uom[]
|
uoms: Uom[]
|
||||||
|
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
|
||||||
|
stockByItem: Map<number, number>
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
onChange: (patch: Partial<StageNodeData>) => void
|
onChange: (patch: Partial<StageNodeData>) => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
@@ -239,18 +246,19 @@ export function StageEditorPanel({
|
|||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Role label</FieldLabel>
|
<FieldLabel>Role label</FieldLabel>
|
||||||
<Input
|
<Select<string>
|
||||||
value={data.roleLabel}
|
value={data.roleLabel}
|
||||||
disabled={readOnly}
|
onValueChange={(v) => v && onChange({ roleLabel: v })}
|
||||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
>
|
||||||
placeholder="e.g. QA"
|
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||||
list="role-suggestions"
|
<SelectValue placeholder="Pick a role" />
|
||||||
/>
|
</SelectTrigger>
|
||||||
<datalist id="role-suggestions">
|
<SelectContent>
|
||||||
{ROLE_SUGGESTIONS.map((r) => (
|
{ROLE_SUGGESTIONS.map((r) => (
|
||||||
<option key={r} value={r} />
|
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
@@ -312,6 +320,9 @@ export function StageEditorPanel({
|
|||||||
{items.map((i) => (
|
{items.map((i) => (
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||||
{i.name} · {i.sku}
|
{i.name} · {i.sku}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
· Stock: {fmtStock(stockByItem.get(i.itemId))}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
import { stockApi } from "@/lib/api/stock"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
|
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
|
||||||
import { ItemListItem, Uom } from "@/types/master-data"
|
import { ItemListItem, Uom } from "@/types/master-data"
|
||||||
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
|
|||||||
|
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
|
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
|
|||||||
}, [isNew, templateId, load])
|
}, [isNew, templateId, load])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
|
Promise.all([
|
||||||
.then(([itemRes, uomRes]) => {
|
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
stockApi.onHandList({ pageSize: 200 }),
|
||||||
|
])
|
||||||
|
.then(([itemRes, uomRes, onHandRes]) => {
|
||||||
setItems(itemRes.items)
|
setItems(itemRes.items)
|
||||||
setUoms(uomRes.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)))
|
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
|
|||||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||||
upstreamOptions={upstreamOptions}
|
upstreamOptions={upstreamOptions}
|
||||||
items={items}
|
items={items}
|
||||||
|
stockByItem={stockByItem}
|
||||||
uoms={uoms}
|
uoms={uoms}
|
||||||
readOnly={locked}
|
readOnly={locked}
|
||||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||||
|
|||||||
@@ -338,6 +338,8 @@ export interface CreateRunRequest {
|
|||||||
export interface StageQuantityLine {
|
export interface StageQuantityLine {
|
||||||
id: number
|
id: number
|
||||||
plannedQty: number
|
plannedQty: number
|
||||||
|
/** Required only when this lowers a running Stock input below what it has already consumed. */
|
||||||
|
reasonCodeId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStageQuantitiesRequest {
|
export interface UpdateStageQuantitiesRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user