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(); }