From 7a4b34bc2f3538a63ce3a6c6777281e2d976742b Mon Sep 17 00:00:00 2001 From: nns Date: Sat, 23 May 2026 12:33:40 +0500 Subject: [PATCH] =?UTF-8?q?fix(documents):=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B4=D0=B5=D0=BD=D0=B5=D0=B3=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=BD=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B0=20pos?= =?UTF-8?q?ting-=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два 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 --- .../Purchases/SuppliesController.cs | 41 +++++++++++++++++++ .../Sales/RetailSalesController.cs | 15 +++++++ 2 files changed, 56 insertions(+) diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index 86f55ae..f004e4f 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -363,6 +363,47 @@ public async Task 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(); + 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) { diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index c69b9af..6aeb712 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -303,6 +303,21 @@ public async Task 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 // когда два кассира одновременно постят чеки на один и тот же товар: