initiating db migration
This commit is contained in:
@@ -78,7 +78,8 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
|
||||
}
|
||||
|
||||
// Row-lock the issuable open layers oldest-first (NFR-02), in two steps.
|
||||
// Row-lock the issuable open layers oldest-first (NFR-02), plus any shortfall (negative)
|
||||
// layers left by an over-stock sale, in two steps.
|
||||
//
|
||||
// Why two steps: InnoDB's locking clause applies to EVERY table a statement reads,
|
||||
// including rows pulled in by the NOT EXISTS subqueries. A single-statement
|
||||
@@ -91,23 +92,27 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
//
|
||||
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
|
||||
// sits quarantined until a Purchase Return consumes it directly (see
|
||||
// PurchaseReturnService), never through this general-issue path.
|
||||
// PurchaseReturnService), never through this general-issue path. The exclusions apply
|
||||
// to positive layers only: a shortfall layer carries no GrnLineId and no BatchId, and
|
||||
// must be settled no matter which batch the caller asked for, so it is never filtered
|
||||
// out here. The batch filter is deliberately NOT in this query for the same reason —
|
||||
// it is applied after settlement, below.
|
||||
var candidateIds = await _db.Database
|
||||
.SqlQuery<int>($"""
|
||||
SELECT sl.`LayerId` AS `Value` FROM `stock_layers` sl
|
||||
WHERE sl.`ItemId` = {itemId} AND sl.`WarehouseId` = {warehouseId} AND sl.`QtyRemaining` > 0
|
||||
AND ({batchId} IS NULL OR sl.`BatchId` = {batchId})
|
||||
AND NOT EXISTS (SELECT 1 FROM `grn_lines` gl WHERE gl.`GrnLineId` = sl.`GrnLineId` AND gl.`HoldStatus` IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM `batches` b WHERE b.`BatchId` = sl.`BatchId` AND b.`ExpiryDate` < {today})
|
||||
WHERE sl.`ItemId` = {itemId} AND sl.`WarehouseId` = {warehouseId}
|
||||
AND (sl.`QtyRemaining` < 0 OR (sl.`QtyRemaining` > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM `grn_lines` gl WHERE gl.`GrnLineId` = sl.`GrnLineId` AND gl.`HoldStatus` IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM `batches` b WHERE b.`BatchId` = sl.`BatchId` AND b.`ExpiryDate` < {today})))
|
||||
ORDER BY sl.`ReceiptDate`, sl.`LayerId`
|
||||
""")
|
||||
.ToListAsync(ct);
|
||||
|
||||
// `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path.
|
||||
List<StockLayer> layers;
|
||||
List<StockLayer> locked;
|
||||
if (candidateIds.Count == 0)
|
||||
{
|
||||
layers = new List<StockLayer>();
|
||||
locked = new List<StockLayer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -123,34 +128,63 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
// not let the `FOR UPDATE` survive. idList is built from List<int>, so it cannot
|
||||
// contain anything but digits and commas.
|
||||
#pragma warning disable EF1002 // Possible SQL injection vulnerability — see above.
|
||||
layers = await _db.StockLayers
|
||||
locked = await _db.StockLayers
|
||||
.FromSqlRaw($"""
|
||||
SELECT sl.* FROM `stock_layers` sl
|
||||
WHERE sl.`LayerId` IN ({idList}) AND sl.`QtyRemaining` > 0
|
||||
WHERE sl.`LayerId` IN ({idList}) AND sl.`QtyRemaining` <> 0
|
||||
ORDER BY sl.`ReceiptDate`, sl.`LayerId`
|
||||
FOR UPDATE
|
||||
""")
|
||||
.ToListAsync(ct);
|
||||
#pragma warning restore EF1002
|
||||
|
||||
// Re-validate under the locks. Between step 1 and step 2 a layer could have gone
|
||||
// on-hold or had its batch expire; now that the rows are locked nothing can change
|
||||
// under us, so this restores the single-statement guarantee.
|
||||
if (layers.Count > 0)
|
||||
//
|
||||
// SqlQueryRaw, NOT SqlQuery: the interpolating overload takes a FormattableString
|
||||
// and turns every hole into a DbParameter, so `IN ({idList})` would bind the whole
|
||||
// list as ONE string — MariaDB coerces '3,4,5' to the integer 3 and silently keeps
|
||||
// only the first layer. Raw keeps the list as SQL; `today` is the one real value
|
||||
// here and goes through SqlQueryRaw's positional placeholder {0}. Note the $$ raw
|
||||
// string: with two $ the interpolation holes are {{...}}, which leaves the literal
|
||||
// {0} alone for EF to bind rather than C# swallowing it.
|
||||
if (locked.Count > 0)
|
||||
{
|
||||
var stillIssuable = await _db.Database
|
||||
.SqlQuery<int>($"""
|
||||
.SqlQueryRaw<int>($$"""
|
||||
SELECT sl.`LayerId` AS `Value` FROM `stock_layers` sl
|
||||
WHERE sl.`LayerId` IN ({idList})
|
||||
AND NOT EXISTS (SELECT 1 FROM `grn_lines` gl WHERE gl.`GrnLineId` = sl.`GrnLineId` AND gl.`HoldStatus` IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM `batches` b WHERE b.`BatchId` = sl.`BatchId` AND b.`ExpiryDate` < {today})
|
||||
""")
|
||||
WHERE sl.`LayerId` IN ({{idList}})
|
||||
AND (sl.`QtyRemaining` < 0 OR (
|
||||
NOT EXISTS (SELECT 1 FROM `grn_lines` gl WHERE gl.`GrnLineId` = sl.`GrnLineId` AND gl.`HoldStatus` IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM `batches` b WHERE b.`BatchId` = sl.`BatchId` AND b.`ExpiryDate` < {0})))
|
||||
""", today)
|
||||
.ToListAsync(ct);
|
||||
var issuable = stillIssuable.ToHashSet();
|
||||
layers = layers.Where(l => issuable.Contains(l.LayerId)).ToList();
|
||||
locked = locked.Where(l => issuable.Contains(l.LayerId)).ToList();
|
||||
}
|
||||
#pragma warning restore EF1002
|
||||
}
|
||||
|
||||
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
|
||||
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
|
||||
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
|
||||
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
|
||||
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
|
||||
foreach (var shortfall in owed)
|
||||
{
|
||||
foreach (var layer in open)
|
||||
{
|
||||
if (shortfall.QtyRemaining >= 0) break;
|
||||
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
|
||||
layer.QtyRemaining -= settle;
|
||||
shortfall.QtyRemaining += settle;
|
||||
}
|
||||
}
|
||||
|
||||
// Only now apply the caller's batch filter: settlement above is deliberately
|
||||
// batch-agnostic (a shortfall layer has no batch), but the issue itself is not.
|
||||
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
|
||||
|
||||
var available = layers.Sum(l => l.QtyRemaining);
|
||||
if (available < qtyBase && !allowShortfall)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user