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); }