feat(barcode-uniqueness): pre-check на Create/Update + warnings импорта + admin endpoint
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 28s
CI / Web (React + Vite) (push) Successful in 25s
Docker Images / API image (push) Successful in 39s
Docker Images / Web image (push) Successful in 5s
Docker Images / Deploy stage (push) Successful in 18s
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 28s
CI / Web (React + Vite) (push) Successful in 25s
Docker Images / API image (push) Successful in 39s
Docker Images / Web image (push) Successful in 5s
Docker Images / Deploy stage (push) Successful in 18s
Pre-check:
- ProductsController.FindBarcodeConflictAsync ищет штрихкоды,
принадлежащие другим товарам организации; на Create/Update при
конфликте возвращается 400 «Штрихкод 1234 уже используется
товаром «Кока-кола 0.5л».» вместо 500 от unique index.
MoySklad-импорт:
- При попытке привязать уже занятый штрихкод — пишется 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
6b3491056b
commit
1ee4b84e53
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue