From 9eb1a6c69a2c5bccb2ac5a2337a3e5d6dba16d97 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Fri, 8 May 2026 12:01:20 +0500 Subject: [PATCH] =?UTF-8?q?fix(retail-sale):=20=D0=B1=D0=BB=D0=BE=D0=BA=20?= =?UTF-8?q?overselling=20=D0=B2=20Post=20=E2=80=94=20409=20=D0=B5=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=20qty>=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сценарий из e2e: остаток 10, продаём 99999 → /post возвращал 204 [Posted] и стоки уходили в минус. Это критично — кассир мог провести чек на товар, которого нет, и в БД появлялся отрицательный остаток. Что изменено: - В Post перед SaveChanges собираем сумму запрошенного qty по каждому productId (учитываем дубль одного товара в нескольких строках чека). - Читаем stocks.Quantity для всех затронутых productId на конкретном StoreId одним запросом. - Если хоть по одной строке available < requested — возвращаем 409 с body {error, lines:[{productId, productName, qty, available}]}, не делаем SaveChanges. - Всё под BeginTransactionAsync(Serializable): защищает от race condition между двумя одновременными post'ами на один товар (без блокировки оба бы прочли «5», списали по 3, получили бы −1). --- .../Sales/RetailSalesController.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 279b337..8ef4c4d 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -269,6 +269,56 @@ 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 = "Нельзя провести пустой чек." }); + // Транзакция Serializable: чтение остатков + запись stock_movements + + // апдейт stocks под одной блокировкой. Это защищает от race condition + // когда два кассира одновременно постят чеки на один и тот же товар: + // без транзакции оба бы прочли «на складе 5», списали по 3, и в итоге + // получили бы −1. Serializable заставляет вторую транзакцию подождать + // или откатиться при конфликте. + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + + // Если в одном чеке один и тот же продукт встречается несколько раз, + // нужно сравнить с остатком СУММУ всех строк, а не каждую отдельно. + var requested = sale.Lines + .GroupBy(l => l.ProductId) + .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }) + .ToList(); + + var productIds = requested.Select(r => r.ProductId).ToList(); + var stocksByProduct = await _db.Stocks + .Where(s => s.StoreId == sale.StoreId && productIds.Contains(s.ProductId)) + .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); + + var insufficient = new List(); + foreach (var r in requested) + { + stocksByProduct.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); + insufficient.Add(new + { + productId = r.ProductId, + productName = name, + qty = r.Quantity, + available, + }); + } + } + + if (insufficient.Count > 0) + { + return Conflict(new + { + error = "Недостаточно остатка для проведения чека.", + lines = insufficient, + }); + } + foreach (var line in sale.Lines) { await _stock.ApplyMovementAsync(new StockMovementDraft( @@ -286,6 +336,7 @@ public async Task Post(Guid id, CancellationToken ct) sale.Status = RetailSaleStatus.Posted; sale.PostedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); return NoContent(); }