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