Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -305,17 +305,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 +371,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 +388,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 +486,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 +510,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 +755,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);
|
||||
|
||||
Reference in New Issue
Block a user