From bed30f68bd0c1e9c033a172e0d608c2538c73e24 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:04:57 +0500 Subject: [PATCH] =?UTF-8?q?feat(products):=20=D0=B0=D0=B2=D1=82=D0=BE-?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D0=BB=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D1=83=D0=BB=D0=B0=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При POST /api/catalog/products если Article пустой — сервер берёт max(Article::int) среди артикулов текущей организации и ставит +1. Если числовых артикулов нет — «1». Пользователь может указать артикул руками, тогда используется его значение. Конфликт по unique index IX_products_OrganizationId_Article на SaveChanges перехватывается — возвращается 400 с текстом «Артикул «X» уже занят в этой организации.» вместо 500. Та же обработка добавлена в Update. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/Catalog/ProductsController.cs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index b07a341..cf9d836 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -23,6 +23,23 @@ public ProductsController(AppDbContext db, ITenantContext tenant) _tenant = tenant; } + // Следующий числовой артикул для организации. Находит max(Article::int) + // среди артикулов, которые полностью состоят из цифр, и прибавляет 1. + // Если числовых артикулов нет — возвращает "1". + private async Task GenerateNextArticleAsync(CancellationToken ct) + { + var articles = await _db.Products + .Where(p => p.Article != null && p.Article != "") + .Select(p => p.Article!) + .ToListAsync(ct); + var next = 1; + foreach (var a in articles) + { + if (int.TryParse(a, out var n) && n >= next) next = n + 1; + } + return next.ToString(); + } + // Дефолт Vat для нового товара — из страны организации (Country.VatRate). private async Task ResolveDefaultVatAsync(CancellationToken ct) { @@ -124,6 +141,8 @@ public async Task> Create([FromBody] ProductInput input Apply(e, input); // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны. if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct); + // Авто-артикул: если пользователь не указал — генерируем числовой. + if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct); foreach (var b in input.Barcodes ?? []) e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); @@ -131,7 +150,14 @@ public async Task> Create([FromBody] ProductInput input e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); _db.Products.Add(e); - await _db.SaveChangesAsync(ct); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true) + { + return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." }); + } var dto = await GetInternalAsync(e.Id, ct); return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); } @@ -160,7 +186,14 @@ public async Task Update(Guid id, [FromBody] ProductInput input, foreach (var pr in input.Prices ?? []) e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); - await _db.SaveChangesAsync(ct); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true) + { + return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." }); + } return NoContent(); }