fix(retail-sale): блок overselling в Post — 409 если qty>остатка

Сценарий из 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).
This commit is contained in:
nns 2026-05-08 12:01:20 +05:00
parent bf53629092
commit 9eb1a6c69a

View file

@ -269,6 +269,56 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." }); if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(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<object>();
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) foreach (var line in sale.Lines)
{ {
await _stock.ApplyMovementAsync(new StockMovementDraft( await _stock.ApplyMovementAsync(new StockMovementDraft(
@ -286,6 +336,7 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
sale.Status = RetailSaleStatus.Posted; sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow; sale.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return NoContent(); return NoContent();
} }