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:
parent
9eb1a6c69a
commit
57168299ac
|
|
@ -198,6 +198,10 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
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)
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
||||||
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
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")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
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)
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
||||||
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,11 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
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)
|
if (input.Lines is null || input.Lines.Count == 0)
|
||||||
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
|
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
|
||||||
var number = await GenerateNumberAsync(input.Date, ct);
|
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);
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
_db.Supplies.Add(supply);
|
_db.Supplies.Add(supply);
|
||||||
await _db.SaveChangesAsync(ct);
|
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||||||
var dto = await GetInternal(supply.Id, ct);
|
var dto = await GetInternal(supply.Id, ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
|
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")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
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)
|
if (input.Lines is null || input.Lines.Count == 0)
|
||||||
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
|
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
|
||||||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
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);
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,12 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
|
||||||
[HttpPost, Authorize(Roles = "Admin,Cashier")]
|
[HttpPost, Authorize(Roles = "Admin,Cashier")]
|
||||||
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
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 number = await GenerateNumberAsync(input.Date, ct);
|
||||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
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);
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
_db.RetailSales.Add(sale);
|
_db.RetailSales.Add(sale);
|
||||||
await _db.SaveChangesAsync(ct);
|
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||||||
var dto = await GetInternal(sale.Id, ct);
|
var dto = await GetInternal(sale.Id, ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
|
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")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
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);
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
if (sale is null) return NotFound();
|
if (sale is null) return NotFound();
|
||||||
if (sale.Status != RetailSaleStatus.Draft)
|
if (sale.Status != RetailSaleStatus.Draft)
|
||||||
|
|
@ -245,7 +279,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
|
||||||
sale.Lines.Clear();
|
sale.Lines.Clear();
|
||||||
ApplyLines(sale, input.Lines, allowFractional);
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
20
src/food-market.application/Common/RequiredGuid.cs
Normal file
20
src/food-market.application/Common/RequiredGuid.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue