feat(barcode-uniqueness): pre-check на Create/Update + warnings импорта + admin endpoint

Pre-check:
- ProductsController.FindBarcodeConflictAsync ищет штрихкоды,
  принадлежащие другим товарам организации; на Create/Update при
  конфликте возвращается 400 «Штрихкод 1234 уже используется
  товаром «Кока-кола 0.5л».» вместо 500 от unique index.

OtherSystem-импорт:
- При попытке привязать уже занятый штрихкод — пишется warning
  «{товар}: штрихкод {код} уже занят, пропущен.» в errors[],
  товар остаётся, дубль не сохраняется.
- В конце импорта проходит финальный SELECT по дубликатам в БД
  (если есть исторические) — warnings типа «Внимание: штрихкод X
  привязан к нескольким товарам — почисти вручную.».

Admin-endpoint:
- GET /api/catalog/products/barcode-duplicates (Admin/Manager)
  возвращает массив { code, products: [{productId, productName,
  article}, ...] } для будущей UI-чистки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 12:26:20 +05:00
parent d1a7e1e647
commit 53fa4d2deb
2 changed files with 60 additions and 1 deletions

View file

@ -23,6 +23,20 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
_tenant = tenant; _tenant = tenant;
} }
// Проверка пересечения штрихкодов с другими товарами организации.
// Возвращает первый конфликт «код → товар» либо null если всё чисто.
private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync(
IEnumerable<string> codes, Guid? excludeProductId, CancellationToken ct)
{
var codeSet = codes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList();
if (codeSet.Count == 0) return null;
var hit = await _db.ProductBarcodes
.Where(b => codeSet.Contains(b.Code) && (excludeProductId == null || b.ProductId != excludeProductId))
.Select(b => new { b.Code, ProductName = b.Product!.Name })
.FirstOrDefaultAsync(ct);
return hit is null ? null : (hit.Code, hit.ProductName);
}
// Округление цен под настройку AllowFractionalPrices. // Округление цен под настройку AllowFractionalPrices.
// Возвращает true если орг разрешает дробные цены. // Возвращает true если орг разрешает дробные цены.
private async Task<bool> AllowFractionalAsync(CancellationToken ct) private async Task<bool> AllowFractionalAsync(CancellationToken ct)
@ -154,6 +168,9 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
{ {
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 = "У товара должен быть хотя бы один штрихкод." });
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
var allowFractional = await AllowFractionalAsync(ct); var allowFractional = await AllowFractionalAsync(ct);
var e = new Product(); var e = new Product();
Apply(e, input); Apply(e, input);
@ -186,6 +203,9 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
{ {
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 = "У товара должен быть хотя бы один штрихкод." });
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
var e = await _db.Products var e = await _db.Products
.Include(p => p.Barcodes) .Include(p => p.Barcodes)
.Include(p => p.Prices) .Include(p => p.Prices)
@ -230,6 +250,28 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
public record BarcodeDuplicate(string Code, IReadOnlyList<DuplicateProductRef> Products);
public record DuplicateProductRef(Guid ProductId, string ProductName, string? Article);
/// <summary>Находит штрихкоды, привязанные к более чем одному товару в текущей
/// организации. Уникальный индекс это запрещает в новых записях, но реальная
/// БД может содержать исторические дубли (например, после ручной правки).
/// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
{
var rows = await _db.ProductBarcodes
.GroupBy(b => b.Code)
.Where(g => g.Count() > 1)
.Select(g => new { Code = g.Key, Items = g.Select(x => new { x.ProductId, ProductName = x.Product!.Name, x.Product.Article }).ToList() })
.ToListAsync(ct);
var result = rows
.Select(r => new BarcodeDuplicate(r.Code,
r.Items.Select(i => new DuplicateProductRef(i.ProductId, i.ProductName, i.Article)).ToList()))
.ToList();
return result;
}
private IQueryable<Product> QueryIncludes() => _db.Products private IQueryable<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure) .Include(p => p.UnitOfMeasure)
.Include(p => p.ProductGroup) .Include(p => p.ProductGroup)

View file

@ -300,7 +300,12 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
foreach (var b in ExtractBarcodes(p)) foreach (var b in ExtractBarcodes(p))
{ {
if (existingBarcodeSet.Contains(b.Code)) continue; if (existingBarcodeSet.Contains(b.Code))
{
errors.Add($"{p.Name}: штрихкод {b.Code} уже занят, пропущен.");
if (progress is not null) progress.Errors = errors;
continue;
}
product.Barcodes.Add(b); product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code); existingBarcodeSet.Add(b.Code);
} }
@ -324,6 +329,18 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
} }
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
// Финальная проверка дубликатов штрихкодов (исторические записи или
// расхождения c уникальным индексом). Только warning в errors[].
var duplicates = await _db.ProductBarcodes
.GroupBy(b => b.Code)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToListAsync(ct);
foreach (var dup in duplicates)
errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам — почисти вручную.");
if (progress is not null && duplicates.Count > 0) progress.Errors = errors;
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors); return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
} }