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
Hangfire DataQualityJob (ночью 02:20 UTC) считает по орг. счётчики проблем: без группы
(корень/«Без группы»), без цены продажи, без штрихкода (кроме услуг), дубль штрихкода,
возможный дубль имя+единица; товары в минусе — ссылкой на центр (не дублируем). Результаты
в data_quality_results (Phase43), живые детальные списки — по запросу.
API /api/data-quality: сводка, {check}/products, recompute, dismiss («это разные товары»),
bulk set-group/set-price/generate-barcodes, PUT settings (отключение проверок). unknown-cost
отложен до FIFO; PLU убран (поправка владельца). Integration-тест: счётчики/список/bulk/dismiss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
7.7 KiB
C#
138 lines
7.7 KiB
C#
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.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<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());
|
||
}
|