fix(catalog): FK-guard удаления контрагента + валидация полей товара

Найдено в catalog-edge:

- DELETE контрагента, на которого ссылаются supplies/retail-sales/products
  (DefaultSupplier), отдавал 500 (DbUpdateException 23503) вместо понятного
  409. Добавлен явный чек использования → Conflict со списком где занят.
- POST товара с пустым Name проходил до FK-проверки и падал неинформативно;
  теперь явный 400 с указанием поля. На ProductInput навешены
  [Required]/[MinLength]/[StringLength] на Name/Article/ImageUrl — отсекаем
  пустые и сверхдлинные значения на уровне модели.

catalog-edge: 12/12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-26 11:03:37 +05:00
parent 32729e72a3
commit e13ac655e5
3 changed files with 23 additions and 2 deletions

View file

@ -103,6 +103,23 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
// FK-guard: counterparty используется как Supplier в supplies / customer
// в retail-sales / default supplier у products. Без явного чека EF
// отдаёт 500 «DbUpdateException 23503 violates foreign key constraint»
// вместо понятного 409 — пользователь не понимает что чинить.
var usedAsSupplier = await _db.Supplies.AnyAsync(s => s.SupplierId == id, ct);
var usedAsCustomer = await _db.RetailSales.AnyAsync(s => s.CustomerId == id, ct);
var usedAsDefault = await _db.Products.AnyAsync(p => p.DefaultSupplierId == id, ct);
if (usedAsSupplier || usedAsCustomer || usedAsDefault)
{
return Conflict(new
{
error = "Нельзя удалить контрагента: он используется в документах или товарах.",
usedAsSupplier, usedAsCustomer, usedAsDefault,
});
}
_db.Counterparties.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();

View file

@ -198,6 +198,8 @@ 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 (string.IsNullOrWhiteSpace(input.Name))
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
if (RequiredGuid.FirstMissing(
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)

View file

@ -85,12 +85,14 @@ public record CounterpartyInput(
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
public record ProductInput(
string Name, string? Article, string? Description,
[Required, MinLength(1), StringLength(500)] string Name,
[StringLength(500)] string? Article,
string? Description,
Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled,
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null,
[StringLength(1000)] string? ImageUrl = null,
IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);