fix(products/update): merge barcodes/prices по ключу + 409 на concurrency

Юзер ловил 500 «DbUpdateConcurrencyException: 0 rows affected» при PUT
/api/catalog/products. RemoveRange(всех детей) + Add новых на каждом
сохранении генерирует массовый DELETE/INSERT, при котором EF ожидал N
rows affected, а реальный DELETE возвращал меньше — и весь батч падал
с 500.

Чиню по-человечески:
- Merge by stable key: barcodes по Code, prices по PriceTypeId.
  Совпавшие — обновляем поля, лишние удаляем, новые добавляем. Минимум
  записей в SaveChanges, минимум поводов для 0-affected.
- Catch DbUpdateConcurrencyException → 409 «Товар изменён в другом
  окне или сессии. Перезагрузите страницу и попробуйте снова.» вместо
  непрозрачного 500.
- Удалена мёртвая ветка `if (input.Vat is null) e.Vat = existingVat`:
  Apply уже не присваивает Vat при null, ничего восстанавливать не
  нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 15:18:23 +05:00
parent ad05f9fe30
commit fd2da58ad4

View file

@ -213,21 +213,50 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
if (e is null) return NotFound(); if (e is null) return NotFound();
var allowFractional = await AllowFractionalAsync(ct); var allowFractional = await AllowFractionalAsync(ct);
var existingVat = e.Vat;
Apply(e, input); Apply(e, input);
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional); e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
// Если UI не передал Vat (скрыт) — сохраняем что было, не обнуляем.
if (input.Vat is null) e.Vat = existingVat;
_db.ProductBarcodes.RemoveRange(e.Barcodes); // Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
e.Barcodes.Clear(); // новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
foreach (var b in input.Barcodes ?? []) // котором EF может выдать DbUpdateConcurrencyException, если какой-то
// child был удалён параллельно из БД.
var inputBarcodes = (input.Barcodes ?? []).ToList();
var byCode = e.Barcodes.ToDictionary(b => b.Code, b => b);
var inputCodes = inputBarcodes.Select(b => b.Code).ToHashSet();
foreach (var existing in e.Barcodes.ToList())
if (!inputCodes.Contains(existing.Code)) _db.ProductBarcodes.Remove(existing);
foreach (var b in inputBarcodes)
{
if (byCode.TryGetValue(b.Code, out var ex))
{
ex.Type = b.Type;
ex.IsPrimary = b.IsPrimary;
}
else
{
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
}
}
_db.ProductPrices.RemoveRange(e.Prices); // Merge prices по PriceTypeId.
e.Prices.Clear(); var inputPrices = (input.Prices ?? []).ToList();
foreach (var pr in input.Prices ?? []) var byPriceType = e.Prices.ToDictionary(p => p.PriceTypeId, p => p);
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId }); var inputPriceTypes = inputPrices.Select(p => p.PriceTypeId).ToHashSet();
foreach (var existing in e.Prices.ToList())
if (!inputPriceTypes.Contains(existing.PriceTypeId)) _db.ProductPrices.Remove(existing);
foreach (var pr in inputPrices)
{
var amount = RoundIfNeeded(pr.Amount, allowFractional);
if (byPriceType.TryGetValue(pr.PriceTypeId, out var ex))
{
ex.Amount = amount;
ex.CurrencyId = pr.CurrencyId;
}
else
{
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = amount, CurrencyId = pr.CurrencyId });
}
}
try try
{ {
@ -237,6 +266,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
{ {
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." }); return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
} }
catch (DbUpdateConcurrencyException)
{
return Conflict(new { error = "Товар изменён в другом окне или сессии. Перезагрузите страницу и попробуйте снова." });
}
return NoContent(); return NoContent();
} }