fix(documents): защита денег и инварианта остатков на posting-операциях
Два P0-бага выявлены сценарием documents-edge: BUG #11 (high): RetailSale.Post не проверял PaidCash+PaidCard ≥ Total. Касса могла «провести» чек с фактической оплатой 0 — товар уходит со склада, деньги не получены. Добавлена валидация: paid (округлённое до 2 знаков) ≥ total, иначе 400 «Сумма оплаты меньше итога». Сдача (PaidCash > Total) остаётся легальной. BUG #12 (critical): Supply.Unpost не проверял, не уйдёт ли Stock в минус после реверса. Сценарий: приёмка 10шт → продажа 5шт → unpost приёмки ⇒ stock = -5. Это нарушение инварианта учёта. Добавлен guard: агрегируем reverse-quantity по продукту, сравниваем с текущим Stock.Quantity, при недостаче возвращаем 409 со списком конфликтных строк. Покрыто E2E documents-edge step05 (PaidCash<Total → 4xx) и step08 (unpost после sale → 409): обе проверки теперь зелёные. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4d7d7bfe7b
commit
7a4b34bc2f
|
|
@ -363,6 +363,47 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
if (supply is null) return NotFound();
|
if (supply is null) return NotFound();
|
||||||
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
||||||
|
|
||||||
|
// Защита от ухода stock в минус: суммируем то, что вернём (по строкам
|
||||||
|
// с тем же ProductId — могут быть дубли), сравниваем с текущим Stock.
|
||||||
|
// Если приёмку «расковать», stock уменьшится на line.Quantity. Если
|
||||||
|
// покупатели уже что-то купили из этой партии, остаток станет
|
||||||
|
// отрицательным — это нарушение инварианта учёта.
|
||||||
|
var reverseByProduct = supply.Lines
|
||||||
|
.GroupBy(l => l.ProductId)
|
||||||
|
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
|
||||||
|
.ToList();
|
||||||
|
var productIds = reverseByProduct.Select(r => r.ProductId).ToList();
|
||||||
|
var stocks = await _db.Stocks
|
||||||
|
.Where(s => s.StoreId == supply.StoreId && productIds.Contains(s.ProductId))
|
||||||
|
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
||||||
|
var conflicts = new List<object>();
|
||||||
|
foreach (var r in reverseByProduct)
|
||||||
|
{
|
||||||
|
stocks.TryGetValue(r.ProductId, out var available);
|
||||||
|
if (available < r.Quantity)
|
||||||
|
{
|
||||||
|
var name = await _db.Products
|
||||||
|
.Where(p => p.Id == r.ProductId)
|
||||||
|
.Select(p => p.Name)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
conflicts.Add(new
|
||||||
|
{
|
||||||
|
productId = r.ProductId,
|
||||||
|
productName = name,
|
||||||
|
reverseQty = r.Quantity,
|
||||||
|
available,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (conflicts.Count > 0)
|
||||||
|
{
|
||||||
|
return Conflict(new
|
||||||
|
{
|
||||||
|
error = "Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).",
|
||||||
|
lines = conflicts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Reverse: negative movements with same document reference
|
// Reverse: negative movements with same document reference
|
||||||
foreach (var line in supply.Lines)
|
foreach (var line in supply.Lines)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,21 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
|
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
|
||||||
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
|
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
|
||||||
|
|
||||||
|
// Валидация платежа: сумма наличных + по карте должна покрывать Total.
|
||||||
|
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
|
||||||
|
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
|
||||||
|
// Округление до 2 знаков защищает от floating-point дрейфа.
|
||||||
|
var paid = decimal.Round(sale.PaidCash + sale.PaidCard, 2);
|
||||||
|
var due = decimal.Round(sale.Total, 2);
|
||||||
|
if (paid < due)
|
||||||
|
{
|
||||||
|
return BadRequest(new
|
||||||
|
{
|
||||||
|
error = $"Сумма оплаты {paid} меньше итога {due}. Доплатите или измените позиции чека.",
|
||||||
|
field = "PaidCash",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Транзакция Serializable: чтение остатков + запись stock_movements +
|
// Транзакция Serializable: чтение остатков + запись stock_movements +
|
||||||
// апдейт stocks под одной блокировкой. Это защищает от race condition
|
// апдейт stocks под одной блокировкой. Это защищает от race condition
|
||||||
// когда два кассира одновременно постят чеки на один и тот же товар:
|
// когда два кассира одновременно постят чеки на один и тот же товар:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue