feat(products): авто-генерация числового артикула при создании

При 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) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-24 19:04:57 +05:00
parent 9c70de9b3d
commit 6468186ed5

View file

@ -23,6 +23,23 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
_tenant = tenant; _tenant = tenant;
} }
// Следующий числовой артикул для организации. Находит max(Article::int)
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
// Если числовых артикулов нет — возвращает "1".
private async Task<string> 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). // Дефолт Vat для нового товара — из страны организации (Country.VatRate).
private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct) private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
{ {
@ -124,6 +141,8 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
Apply(e, input); Apply(e, input);
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны. // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct); 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 ?? []) foreach (var b in input.Barcodes ?? [])
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
@ -131,7 +150,14 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
_db.Products.Add(e); _db.Products.Add(e);
try
{
await _db.SaveChangesAsync(ct); 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); var dto = await GetInternalAsync(e.Id, ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
} }
@ -160,7 +186,14 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
foreach (var pr in input.Prices ?? []) foreach (var pr in input.Prices ?? [])
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
try
{
await _db.SaveChangesAsync(ct); 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(); return NoContent();
} }