From 1ee4b84e53c69b0613f12c9c51e2416b088c44bd Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:26:20 +0500 Subject: [PATCH] =?UTF-8?q?feat(barcode-uniqueness):=20pre-check=20=D0=BD?= =?UTF-8?q?=D0=B0=20Create/Update=20+=20warnings=20=D0=B8=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B0=20+=20admin=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Controllers/Catalog/ProductsController.cs | 42 +++++++++++++++++++ .../MoySklad/MoySkladImportService.cs | 19 ++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 1e0acca..ce32ae5 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -23,6 +23,20 @@ public ProductsController(AppDbContext db, ITenantContext tenant) _tenant = tenant; } + // Проверка пересечения штрихкодов с другими товарами организации. + // Возвращает первый конфликт «код → товар» либо null если всё чисто. + private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync( + IEnumerable 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 AllowFractionalAsync(CancellationToken ct) @@ -154,6 +168,9 @@ public async Task> 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 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 Delete(Guid id, CancellationToken ct) return NoContent(); } + public record BarcodeDuplicate(string Code, IReadOnlyList Products); + public record DuplicateProductRef(Guid ProductId, string ProductName, string? Article); + + /// Находит штрихкоды, привязанные к более чем одному товару в текущей + /// организации. Уникальный индекс это запрещает в новых записях, но реальная + /// БД может содержать исторические дубли (например, после ручной правки). + /// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта. + [HttpGet("barcode-duplicates"), Authorize(Roles = "Admin,Manager")] + public async Task>> 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 QueryIncludes() => _db.Products .Include(p => p.UnitOfMeasure) .Include(p => p.ProductGroup) diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 51fd6a7..894251b 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -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); }