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:
2026-08-17 14:30:55 +05:30
parent ee9fa40edf
commit 1c5b05e315
6 changed files with 400 additions and 84 deletions
+11 -2
View File
@@ -99,8 +99,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 +118,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; }
}
@@ -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<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;
}
@@ -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);
}
/// <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(
@@ -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<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);