initiating db migration

This commit is contained in:
2026-10-05 17:15:08 +05:30
parent 9503ea5fe8
commit 8706c2557b
7 changed files with 9458 additions and 18 deletions
@@ -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)
{