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 // когда два кассира одновременно постят чеки на один и тот же товар: