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:
nns 2026-05-23 12:33:40 +05:00
parent 4d7d7bfe7b
commit 7a4b34bc2f
2 changed files with 56 additions and 0 deletions

View file

@ -363,6 +363,47 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
if (supply is null) return NotFound();
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
foreach (var line in supply.Lines)
{

View file

@ -303,6 +303,21 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
if (sale.Status == RetailSaleStatus.Posted) return Conflict(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 +
// апдейт stocks под одной блокировкой. Это защищает от race condition
// когда два кассира одновременно постят чеки на один и тот же товар: