From e13ac655e523c238bb35a7f2bcfb6ff9e26f14a9 Mon Sep 17 00:00:00 2001 From: nns Date: Tue, 26 May 2026 11:03:37 +0500 Subject: [PATCH] =?UTF-8?q?fix(catalog):=20FK-guard=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20+=20=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено в 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 --- .../Catalog/CounterpartiesController.cs | 17 +++++++++++++++++ .../Controllers/Catalog/ProductsController.cs | 2 ++ .../Catalog/CatalogDtos.cs | 6 ++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs index 6ca8c87..4ef9ade 100644 --- a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -103,6 +103,23 @@ public async Task 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(); diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index acdf2ee..bfdf088 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -198,6 +198,8 @@ public async Task> Get(Guid id, CancellationToken ct) [HttpPost, Authorize(Roles = "Admin,Storekeeper")] public async Task> 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) diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index 23debef..2e3088f 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -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? Prices = null, IReadOnlyList? Barcodes = null);