From 57168299aca96fcf73052c9d1548275f6302ec3b Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Fri, 8 May 2026 12:05:01 +0500 Subject: [PATCH] =?UTF-8?q?fix(validation):=20=D0=BE=D0=B1=D1=8F=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20FK-Guid=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=20400=20+=20DbUpdateException=20=E2=86=92?= =?UTF-8?q?=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: SupplyInput.SupplierId/StoreId/CurrencyId — non-nullable Guid. Если JSON приходит без поля или с null, оно десериализуется в Guid.Empty, и ошибка проявляется только на SaveChanges как PostgresException 23503 (FK violation) с HTTP 500. UI получает generic 500 и не понимает какое поле виновато. Что изменено: - Добавлен helper RequiredGuid.FirstMissing(...) — возвращает имя первого Guid.Empty поля или null. - SuppliesController.Create/Update, RetailSalesController.Create/Update, ProductsController.Create/Update — теперь начинают с проверки FK-Guid'ов и возвращают 400 {error, field} если какое-то пусто. - В тех же контроллерах SaveChanges обёрнут в SaveOrFkErrorAsync, который ловит PostgresException SqlState=23503 (foreign_key_violation), парсит ConstraintName и возвращает 400 вместо 500. Защита для случая когда Guid не пуст, но указывает на удалённую/чужую запись. TaskUpdate: closes step08-bug «Supply без supplierId → 500». --- .../Controllers/Catalog/ProductsController.cs | 8 ++++ .../Purchases/SuppliesController.cs | 38 ++++++++++++++++++- .../Sales/RetailSalesController.cs | 38 ++++++++++++++++++- .../Common/RequiredGuid.cs | 20 ++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/food-market.application/Common/RequiredGuid.cs diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 10d42dd..acdf2ee 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -198,6 +198,10 @@ public async Task> Get(Guid id, CancellationToken ct) [HttpPost, Authorize(Roles = "Admin,Storekeeper")] public async Task> Create([FromBody] ProductInput input, CancellationToken ct) { + if (RequiredGuid.FirstMissing( + (nameof(input.UnitOfMeasureId), input.UnitOfMeasureId), + (nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk) + return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk }); if (input.Barcodes is null || input.Barcodes.Count == 0) return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) @@ -235,6 +239,10 @@ public async Task> Create([FromBody] ProductInput input [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")] public async Task Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) { + if (RequiredGuid.FirstMissing( + (nameof(input.UnitOfMeasureId), input.UnitOfMeasureId), + (nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk) + return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk }); if (input.Barcodes is null || input.Barcodes.Count == 0) return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index e3fd24d..86f55ae 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -124,6 +124,11 @@ public async Task> Get(Guid id, CancellationToken ct) [HttpPost, Authorize(Roles = "Admin,Storekeeper")] public async Task> Create([FromBody] SupplyInput input, CancellationToken ct) { + if (RequiredGuid.FirstMissing( + (nameof(input.SupplierId), input.SupplierId), + (nameof(input.StoreId), input.StoreId), + (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); if (input.Lines is null || input.Lines.Count == 0) return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." }); var number = await GenerateNumberAsync(input.Date, ct); @@ -159,14 +164,43 @@ public async Task> Create([FromBody] SupplyInput input, supply.Total = supply.Lines.Sum(x => x.LineTotal); _db.Supplies.Add(supply); - await _db.SaveChangesAsync(ct); + if (await SaveOrFkErrorAsync(ct) is { } err) return err; var dto = await GetInternal(supply.Id, ct); return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto); } + /// SaveChanges + перехват PostgresException 23503 (FK violation). + /// Возвращает 400 с указанием поля если FK не сошёлся (например, SupplierId + /// указывает на несуществующего контрагента) — это лучше чем 500. + private async Task SaveOrFkErrorAsync(CancellationToken ct) + { + try + { + await _db.SaveChangesAsync(ct); + return null; + } + catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") + { + // pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId + // — вытащим из него имя FK-поля для UI. + var name = pg.ConstraintName ?? ""; + string field = name.Contains("Supplier") ? "supplierId" + : name.Contains("Store") ? "storeId" + : name.Contains("Currency") ? "currencyId" + : name.Contains("Product") ? "productId" + : "(unknown)"; + return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name }); + } + } + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")] public async Task Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct) { + if (RequiredGuid.FirstMissing( + (nameof(input.SupplierId), input.SupplierId), + (nameof(input.StoreId), input.StoreId), + (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); if (input.Lines is null || input.Lines.Count == 0) return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." }); var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); @@ -204,7 +238,7 @@ public async Task Update(Guid id, [FromBody] SupplyInput input, C } supply.Total = supply.Lines.Sum(x => x.LineTotal); - await _db.SaveChangesAsync(ct); + if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); } diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 8ef4c4d..c69b9af 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -197,6 +197,12 @@ public async Task> Get(Guid id, CancellationToken ct [HttpPost, Authorize(Roles = "Admin,Cashier")] public async Task> Create([FromBody] RetailSaleInput input, CancellationToken ct) { + if (RequiredGuid.FirstMissing( + (nameof(input.StoreId), input.StoreId), + (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); + if (input.Lines is null || input.Lines.Count == 0) + return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." }); var number = await GenerateNumberAsync(input.Date, ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); @@ -216,14 +222,42 @@ public async Task> Create([FromBody] RetailSaleInput }; ApplyLines(sale, input.Lines, allowFractional); _db.RetailSales.Add(sale); - await _db.SaveChangesAsync(ct); + if (await SaveOrFkErrorAsync(ct) is { } err) return err; var dto = await GetInternal(sale.Id, ct); return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto); } + /// SaveChanges + перехват PostgresException 23503 (FK violation). + /// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId + /// или RetailPointId указывают на несуществующую запись) — это лучше + /// чем 500. + private async Task SaveOrFkErrorAsync(CancellationToken ct) + { + try + { + await _db.SaveChangesAsync(ct); + return null; + } + catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") + { + var name = pg.ConstraintName ?? ""; + string field = name.Contains("Store") ? "storeId" + : name.Contains("RetailPoint") ? "retailPointId" + : name.Contains("Customer") ? "customerId" + : name.Contains("Currency") ? "currencyId" + : name.Contains("Product") ? "productId" + : "(unknown)"; + return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name }); + } + } + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")] public async Task Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct) { + if (RequiredGuid.FirstMissing( + (nameof(input.StoreId), input.StoreId), + (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); if (sale is null) return NotFound(); if (sale.Status != RetailSaleStatus.Draft) @@ -245,7 +279,7 @@ public async Task Update(Guid id, [FromBody] RetailSaleInput inpu sale.Lines.Clear(); ApplyLines(sale, input.Lines, allowFractional); - await _db.SaveChangesAsync(ct); + if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); } diff --git a/src/food-market.application/Common/RequiredGuid.cs b/src/food-market.application/Common/RequiredGuid.cs new file mode 100644 index 0000000..fb1bbea --- /dev/null +++ b/src/food-market.application/Common/RequiredGuid.cs @@ -0,0 +1,20 @@ +namespace foodmarket.Application.Common; + +/// Helper для контроллеров: проверка обязательных Guid-полей в DTO. +/// Без него отсутствующее в JSON поле десериализуется в Guid.Empty +/// (000…0), и ошибка проявляется только на SaveChanges как FK violation +/// 500 — что неудобно для UI. Этот helper возвращает payload для 400. +public static class RequiredGuid +{ + /// Возвращает первое пустое поле из набора (fieldName, value), + /// или null если все заполнены. Контроллер сам решает что делать с null — + /// обычно return BadRequest(new { error, field }). + public static string? FirstMissing(params (string Field, Guid Value)[] fields) + { + foreach (var (name, value) in fields) + { + if (value == Guid.Empty) return name; + } + return null; + } +}