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:
parent
bf53629092
commit
9eb1a6c69a
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue