From 15f27fd16e4df0b9d5bf0d3f393cae771c660bee Mon Sep 17 00:00:00 2001 From: nns Date: Tue, 26 May 2026 11:16:11 +0500 Subject: [PATCH] =?UTF-8?q?fix(supplies):=20=D1=81=D0=B5=D1=80=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D1=83=D0=B5=D0=BC=D0=BE=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=91=D0=BC=D0=BA=D0=B8=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=20lost=20update=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Purchases/SuppliesController.cs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) 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; иначе — первый