fix(supplies): сериализуемое проведение приёмки против lost update остатков

Supply.Post шёл на дефолтной изоляции (Read Committed), а
StockService.ApplyMovementAsync делает read-modify-write по Stock.Quantity
без RowVersion. Под конкуренцией это ломало главный инвариант
Stock.Quantity == Σ StockMovement.Quantity:

- двойное проведение ОДНОЙ приёмки (оба запроса читают Status=Draft до
  коммита соседа) применяло остаток дважды — два StockMovement, но Stock
  рос лишь на одну партию (lost update);
- две разные приёмки одного товара могли потерять обновление остатка и
  посчитать скользящее среднее Cost от устаревшего currentQty.

Переводим проведение на IsolationLevel.Serializable (как RetailSale.Post)
и ловим конфликт сериализации (SQLSTATE 40001/40P01) → 409, чтобы клиент
повторил, а не получал 500. Найдено сценарием stock-concurrency
(step03: было stock=32/sum=39 → стало 32/32, statuses 204+409).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-26 11:16:11 +05:00
parent defe6860fc
commit 15f27fd16e

View file

@ -264,7 +264,16 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
await using var tx = await _db.Database.BeginTransactionAsync(ct);
// Serializable: ApplyMovementAsync делает read-modify-write по
// Stock.Quantity без RowVersion. На дефолтной изоляции два
// одновременных проведения (в т.ч. двойное проведение ОДНОГО
// документа, проскочившее проверку статуса выше до коммита соседа)
// дают lost update: остаток отстаёт от Σ StockMovement, приёмка
// применяется дважды, скользящее среднее Cost считается от
// устаревшего currentQty. Serializable заставляет конкурирующую
// транзакцию откатиться (40001) — ловим ниже и отдаём 409.
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in supply.Lines)
@ -322,11 +331,33 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
supply.Status = SupplyStatus.Posted;
supply.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
/// <summary>True, если исключение (или любое вложенное) — конфликт
/// сериализации/дедлок Postgres (SQLSTATE 40001 / 40P01). Возникает, когда
/// Serializable-транзакция откатывается из-за конкурирующего проведения.
/// Используем System.Data.Common.DbException.SqlState (.NET 8), чтобы не
/// тянуть прямую зависимость на Npgsql в API-слой.</summary>
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый