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:
parent
d1a7e1e647
commit
53fa4d2deb
|
|
@ -23,6 +23,20 @@ public ProductsController(AppDbContext db, ITenantContext 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.
|
||||
// Возвращает true если орг разрешает дробные цены.
|
||||
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)
|
||||
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 e = new Product();
|
||||
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)
|
||||
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
|
||||
.Include(p => p.Barcodes)
|
||||
.Include(p => p.Prices)
|
||||
|
|
@ -230,6 +250,28 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
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
|
||||
.Include(p => p.UnitOfMeasure)
|
||||
.Include(p => p.ProductGroup)
|
||||
|
|
|
|||
|
|
@ -300,7 +300,12 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
|
||||
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);
|
||||
existingBarcodeSet.Add(b.Code);
|
||||
}
|
||||
|
|
@ -324,6 +329,18 @@ await foreach (var p in _client.StreamProductsAsync(token, 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue