diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index f004e4f..f0c0987 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -264,7 +264,16 @@ public async Task 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 Post(Guid id, CancellationToken ct) supply.Status = SupplyStatus.Posted; supply.PostedAt = now; - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); + try + { + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (Exception ex) when (IsSerializationConflict(ex)) + { + return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." }); + } return NoContent(); } + /// True, если исключение (или любое вложенное) — конфликт + /// сериализации/дедлок Postgres (SQLSTATE 40001 / 40P01). Возникает, когда + /// Serializable-транзакция откатывается из-за конкурирующего проведения. + /// Используем System.Data.Common.DbException.SqlState (.NET 8), чтобы не + /// тянуть прямую зависимость на Npgsql в API-слой. + 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; + } + /// Записывает значение в дефолтный розничный PriceType. Если в списке /// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType /// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый