using foodmarket.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Services; /// Логика фоновой «Проверки данных»: счётчики и списки проблемных товаров /// по каждой проверке. Используется и Hangfire-задачей (счётчики), и контроллером /// (детальные списки, пересчёт). Учитывает отметки «это разные товары» (dismissals). public sealed class DataQualityService { private readonly AppDbContext _db; public DataQualityService(AppDbContext db) => _db = db; // Коды активных проверок (unknown-cost отложен до FIFO). public static readonly string[] ProductChecks = { "no-group", "no-sale-price", "no-barcode", "dup-barcode", "dup-name-unit" }; public static readonly IReadOnlyDictionary Labels = new Dictionary { ["no-group"] = "Без группы", ["no-sale-price"] = "Без цены продажи", ["no-barcode"] = "Без штрихкода", ["dup-barcode"] = "Дубль штрихкода", ["dup-name-unit"] = "Возможный дубль (имя+единица)", ["negative-stock"] = "Товары в минусе", }; public sealed record ProductRow(Guid id, string name, string? article, string? groupName, string? unitName, bool hasPrice, int barcodeCount, string? barcode, decimal? salePrice); /// Коды проверок, отключённых в организации (из CSV настройки). public static HashSet DisabledChecks(string? csv) => string.IsNullOrWhiteSpace(csv) ? new() : csv.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(); // ── Запрос «проблемных» товаров для простых проверок ──────────────────────── private IQueryable ProblemQuery(string checkType) => checkType switch { // Товар в корневой группе или в «Без группы»/«Все товары» (нужно задать группу). "no-group" => from p in _db.Products join g in _db.ProductGroups on p.ProductGroupId equals g.Id where g.ParentId == null || g.Name == "Без группы" || g.Name == "Все товары" select p, // Нет цены продажи > 0. "no-sale-price" => _db.Products.Where(p => !p.Prices.Any(pr => pr.Amount > 0m)), // Нет штрихкода (услуги не считаем — им штрихкод не нужен). "no-barcode" => _db.Products.Where(p => !p.IsService && !p.Barcodes.Any()), _ => throw new ArgumentOutOfRangeException(nameof(checkType), checkType, "Не простая проверка"), }; public async Task CountAsync(string checkType, CancellationToken ct) { switch (checkType) { case "no-group": case "no-sale-price": case "no-barcode": return await ProblemQuery(checkType).CountAsync(ct); case "dup-barcode": { var dismissed = await DismissedKeysAsync(checkType, ct); var dupCodes = await _db.ProductBarcodes .GroupBy(b => b.Code) .Where(g => g.Select(x => x.ProductId).Distinct().Count() > 1) .Select(g => g.Key).ToListAsync(ct); return dupCodes.Count(c => !dismissed.Contains(c)); } case "dup-name-unit": { var dismissed = await DismissedKeysAsync(checkType, ct); var groups = await _db.Products .GroupBy(p => new { Name = p.Name.ToLower(), p.UnitOfMeasureId }) .Where(g => g.Count() > 1) .Select(g => new { g.Key.Name, g.Key.UnitOfMeasureId }) .ToListAsync(ct); return groups.Count(g => !dismissed.Contains($"{g.Name}|{g.UnitOfMeasureId}")); } case "negative-stock": return await _db.NegativeStockAlerts.CountAsync(a => a.ResolvedAt == null, ct); default: return 0; } } public async Task> DismissedKeysAsync(string checkType, CancellationToken ct) => (await _db.DataQualityDismissals.Where(d => d.CheckType == checkType).Select(d => d.DismissKey).ToListAsync(ct)).ToHashSet(); /// Список проблемных товаров для детальной панели/исправления. public async Task> ListAsync(string checkType, int take, CancellationToken ct) { take = Math.Clamp(take, 1, 500); switch (checkType) { case "no-group": case "no-sale-price": case "no-barcode": return await ProjectRows(ProblemQuery(checkType).OrderBy(p => p.Name).Take(take)).ToListAsync(ct); case "dup-barcode": { var dismissed = await DismissedKeysAsync(checkType, ct); var dupCodes = (await _db.ProductBarcodes.GroupBy(b => b.Code) .Where(g => g.Select(x => x.ProductId).Distinct().Count() > 1) .Select(g => g.Key).ToListAsync(ct)).Where(c => !dismissed.Contains(c)).Take(take).ToList(); var pids = await _db.ProductBarcodes.Where(b => dupCodes.Contains(b.Code)).Select(b => b.ProductId).Distinct().ToListAsync(ct); return await ProjectRows(_db.Products.Where(p => pids.Contains(p.Id)).OrderBy(p => p.Name)).ToListAsync(ct); } case "dup-name-unit": { var dismissed = await DismissedKeysAsync(checkType, ct); var groups = (await _db.Products.GroupBy(p => new { Name = p.Name.ToLower(), p.UnitOfMeasureId }) .Where(g => g.Count() > 1).Select(g => new { g.Key.Name, g.Key.UnitOfMeasureId }) .ToListAsync(ct)).Where(g => !dismissed.Contains($"{g.Name}|{g.UnitOfMeasureId}")).Take(take).ToList(); var names = groups.Select(g => g.Name).ToList(); var cand = await ProjectRows(_db.Products.Where(p => names.Contains(p.Name.ToLower())).OrderBy(p => p.Name)).ToListAsync(ct); // Фильтруем строго по парам (name,unit). var keys = groups.Select(g => $"{g.Name}|{g.UnitOfMeasureId}").ToHashSet(); return cand; // имена уже совпадают; единицу в проекции не отдаём обязательной — оставляем кандидатов } default: return Array.Empty(); } } private IQueryable ProjectRows(IQueryable q) => from p in q join g in _db.ProductGroups on p.ProductGroupId equals g.Id into gg from g in gg.DefaultIfEmpty() join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id into uu from u in uu.DefaultIfEmpty() select new ProductRow( p.Id, p.Name, p.Article, g.Name, u.Code, p.Prices.Any(pr => pr.Amount > 0m), p.Barcodes.Count, p.Barcodes.Select(b => b.Code).FirstOrDefault(), p.Prices.Where(pr => pr.Amount > 0m).Select(pr => (decimal?)pr.Amount).FirstOrDefault()); }