fix(validation): обязательные FK-Guid проверяются на 400 + DbUpdateException → 400

Было: 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».
This commit is contained in:
nns 2026-05-08 12:05:01 +05:00
parent 9eb1a6c69a
commit 57168299ac
4 changed files with 100 additions and 4 deletions

View file

@ -198,6 +198,10 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<ProductDto>> 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<ActionResult<ProductDto>> Create([FromBody] ProductInput input
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> 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)

View file

@ -124,6 +124,11 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<SupplyDto>> 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<ActionResult<SupplyDto>> 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);
}
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
/// Возвращает 400 с указанием поля если FK не сошёлся (например, SupplierId
/// указывает на несуществующего контрагента) — это лучше чем 500.</summary>
private async Task<ActionResult?> 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<IActionResult> 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<IActionResult> 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();
}

View file

@ -197,6 +197,12 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
[HttpPost, Authorize(Roles = "Admin,Cashier")]
public async Task<ActionResult<RetailSaleDto>> 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<ActionResult<RetailSaleDto>> 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);
}
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
/// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId
/// или RetailPointId указывают на несуществующую запись) — это лучше
/// чем 500.</summary>
private async Task<ActionResult?> 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<IActionResult> 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<IActionResult> 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();
}

View file

@ -0,0 +1,20 @@
namespace foodmarket.Application.Common;
/// <summary>Helper для контроллеров: проверка обязательных Guid-полей в DTO.
/// Без него отсутствующее в JSON поле десериализуется в Guid.Empty
/// (000…0), и ошибка проявляется только на SaveChanges как FK violation
/// 500 — что неудобно для UI. Этот helper возвращает payload для 400.</summary>
public static class RequiredGuid
{
/// <summary>Возвращает первое пустое поле из набора (fieldName, value),
/// или null если все заполнены. Контроллер сам решает что делать с null —
/// обычно return BadRequest(new { error, field }).</summary>
public static string? FirstMissing(params (string Field, Guid Value)[] fields)
{
foreach (var (name, value) in fields)
{
if (value == Guid.Empty) return name;
}
return null;
}
}