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:
parent
defe6860fc
commit
15f27fd16e
|
|
@ -264,7 +264,16 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
|
||||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(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;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var line in supply.Lines)
|
foreach (var line in supply.Lines)
|
||||||
|
|
@ -322,11 +331,33 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
|
||||||
supply.Status = SupplyStatus.Posted;
|
supply.Status = SupplyStatus.Posted;
|
||||||
supply.PostedAt = now;
|
supply.PostedAt = now;
|
||||||
|
try
|
||||||
|
{
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
await tx.CommitAsync(ct);
|
await tx.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsSerializationConflict(ex))
|
||||||
|
{
|
||||||
|
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
|
||||||
|
}
|
||||||
return NoContent();
|
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. Если в списке
|
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
|
||||||
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
|
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
|
||||||
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый
|
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue