food-market/src/food-market.api/Services/DataQualityService.cs
nns 592399f90e
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
fix(data-quality): «без группы» — только служебный корень, не настоящие верхние категории
Раньше флагировались все товары в группах с ParentId==null (включая реальные
верхнеуровневые категории вроде «Молочные»). Теперь — только товары в служебной
группе «Все товары»/«Без группы». На seed-demo: было 50 (ложно), стало 3 (верно).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 08:27:15 +05:00

140 lines
8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Services;
/// <summary>Логика фоновой «Проверки данных»: счётчики и списки проблемных товаров
/// по каждой проверке. Используется и Hangfire-задачей (счётчики), и контроллером
/// (детальные списки, пересчёт). Учитывает отметки «это разные товары» (dismissals).</summary>
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<string, string> Labels = new Dictionary<string, string>
{
["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);
/// <summary>Коды проверок, отключённых в организации (из CSV настройки).</summary>
public static HashSet<string> DisabledChecks(string? csv)
=> string.IsNullOrWhiteSpace(csv)
? new()
: csv.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
// ── Запрос «проблемных» товаров для простых проверок ────────────────────────
private IQueryable<Domain.Catalog.Product> 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.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<int> 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<HashSet<string>> DismissedKeysAsync(string checkType, CancellationToken ct)
=> (await _db.DataQualityDismissals.Where(d => d.CheckType == checkType).Select(d => d.DismissKey).ToListAsync(ct)).ToHashSet();
/// <summary>Список проблемных товаров для детальной панели/исправления.</summary>
public async Task<IReadOnlyList<ProductRow>> 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<ProductRow>();
}
}
private IQueryable<ProductRow> ProjectRows(IQueryable<Domain.Catalog.Product> 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());
}