feat(data-quality): фоновая «Проверка данных» — бэкенд (п.1,3,4)
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>
This commit is contained in:
nns 2026-10-09 08:11:57 +05:00
parent f6cd1063f1
commit 261a720b52
11 changed files with 606 additions and 0 deletions

View file

@ -0,0 +1,39 @@
# Фоновая «Проверка данных» — прогресс
Идея владельца (08.10): «система в фоне не спеша должна выявлять товары без группы и
выводить это как предупреждение, что необходимо задать им группу». Расширяем на основные
проблемы данных. Правило: минимум как у МойСклада, где можно — лучше.
Легенда: [x] готово · [~] частично · [ ] не начато.
## Сверка с МойСкладом
- У МойСклада **нет отдельного дашборда «проверка данных»**. Есть фильтры в списке
ассортимента: по папке/группе (можно отобрать «без папки»), по архивности; отчёт
«Остатки» с фильтром отрицательных. Поиск дублей штрихкодов/названий как встроенной
фичи у них **нет** (ловится только при вводе дубля штрихкода — ошибка).
- **Делаем лучше:** выделенная панель «Проверка данных» со счётчиками по каждой проблеме,
фоновый расчёт (не грузит БД в часы продаж), массовое исправление из списка, детект
дублей (штрихкод; имя+единица «возможно, дубль») с «это разные товары», отключение
проверок, ненавязчивый бейдж, то же через API для iOS.
## Проверки (поправка: PLU убран; без-штрихкода — для старых/импорта)
- `no-group` — товар в корневой группе / «Без группы» (нужно задать группу).
- `no-sale-price` — нет цены продажи (продать нельзя).
- `no-barcode` — нет штрихкода (кроме услуг; новые получают автоматически — это для старых/импорта).
- `dup-barcode` — один штрихкод у разных товаров.
- `dup-name-unit` — одинаковое название+единица у разных товаров («возможно, дубль»).
- `negative-stock` — товары в минусе: НЕ дублируем уведомления, ссылаемся на центр (задача 01b34).
- `unknown-cost` — ОТЛОЖЕНО до FIFO (себестоимость «неизвестна», а не «0»).
## План / статус
1. [ ] Hangfire-задача: лёгкая, низкий приоритет, по организациям; ночной полный проход +
recompute по запросу. Считает счётчики по каждой проверке (с учётом «это разные товары»).
2. [ ] Админка: панель «Проверка данных» (счётчики + детальные списки), бейдж на главной/каталоге,
время последней проверки; iOS — те же API.
3. [ ] Массовое исправление: «Задать группу», «Задать цену», «Сгенерировать штрихкоды»; дубли —
«Это разные товары» / открыть оба.
4. [ ] Отключение отдельных проверок в настройках; предупреждение исчезает при исправлении.
5. [ ] Тесты (unit/integration/e2e), деплой, скриншоты; реальные числа на витрине/Демо.
## Что НЕ трогаем
Прод, shared-postgres, global.json, gateway nginx, sklad. FIFO/себестоимость — отдельная тема.

View file

@ -0,0 +1,54 @@
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Services;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Фоновая «Проверка данных»: раз в сутки (низкий приоритет) по каждой
/// организации считает счётчики проблем (без группы, без цены, без штрихкода,
/// дубли) и сохраняет в data_quality_results. Панель/бейдж читают готовые числа —
/// без тяжёлых запросов в часы продаж. Можно пересчитать по запросу (RecomputeOrgAsync).</summary>
public sealed class DataQualityJob
{
private readonly AppDbContext _db;
private readonly DataQualityService _svc;
private readonly ILogger<DataQualityJob> _log;
public DataQualityJob(AppDbContext db, DataQualityService svc, ILogger<DataQualityJob> log)
{ _db = db; _svc = svc; _log = log; }
public async Task RunAsync(CancellationToken ct = default)
{
var orgs = await _db.Organizations.IgnoreQueryFilters()
.Where(o => !o.IsArchived && o.IsActive)
.Select(o => new { o.Id, o.DataQualityDisabledChecks }).ToListAsync(ct);
foreach (var org in orgs)
{
try
{
using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false);
await RecomputeAsync(org.Id, org.DataQualityDisabledChecks, ct);
}
catch (Exception ex) { _log.LogWarning(ex, "DataQuality failed for org={OrgId}", org.Id); }
}
}
/// <summary>Пересчитать все проверки одной организации (вызов из job и из recompute-эндпоинта,
/// где tenant-scope уже установлен).</summary>
public async Task RecomputeAsync(Guid orgId, string? disabledCsv, CancellationToken ct)
{
var disabled = DataQualityService.DisabledChecks(disabledCsv);
var now = DateTime.UtcNow;
foreach (var check in DataQualityService.ProductChecks)
{
var count = disabled.Contains(check) ? 0 : await _svc.CountAsync(check, ct);
var existing = await _db.DataQualityResults.FirstOrDefaultAsync(r => r.CheckType == check, ct);
if (existing is null)
_db.DataQualityResults.Add(new DataQualityResult { OrganizationId = orgId, CheckType = check, Count = count, LastCheckedAt = now });
else { existing.Count = count; existing.LastCheckedAt = now; }
}
await _db.SaveChangesAsync(ct);
}
}

View file

@ -161,6 +161,14 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronShiftWatch,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// «Проверка данных» — ночной полный проход (низкий приоритет, не в часы продаж).
var cronDataQuality = _cfg["Hangfire:Cron:DataQuality"] ?? "20 2 * * *"; // 02:20 UTC = 07:20 МСК/Алматы
_jobs.AddOrUpdate<DataQualityJob>(
recurringJobId: "data-quality-scan",
methodCall: j => j.RunAsync(CancellationToken.None),
cronExpression: cronDataQuality,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
return Task.CompletedTask;
}

View file

@ -0,0 +1,194 @@
using foodmarket.Api.Background;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Services;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Shared.Barcodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>Фоновая «Проверка данных»: сводка по проблемам (товары без группы/цены/
/// штрихкода, дубли), детальные списки, массовое исправление, «это разные товары»,
/// пересчёт, настройки. Тот же API использует мобильное приложение.</summary>
[ApiController]
[Authorize]
[Route("api/data-quality")]
public class DataQualityController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly DataQualityService _svc;
private readonly DataQualityJob _job;
public DataQualityController(AppDbContext db, ITenantContext tenant, DataQualityService svc, DataQualityJob job)
{ _db = db; _tenant = tenant; _svc = svc; _job = job; }
public record CheckSummary(string checkType, string label, int count, bool enabled, string? link);
public record SummaryResult(IReadOnlyList<CheckSummary> checks, int total, DateTime? lastCheckedAt);
/// <summary>Сводка по проверкам со счётчиками (из сохранённых результатов job-а) +
/// товары в минусе (ссылкой на центр). total — сумма активных проблем.</summary>
[HttpGet, RequiresPermission("ProductsView")]
public async Task<ActionResult<SummaryResult>> Summary(CancellationToken ct)
{
var disabledCsv = await _db.Organizations.Where(o => o.Id == _tenant.OrganizationId)
.Select(o => o.DataQualityDisabledChecks).FirstOrDefaultAsync(ct);
var disabled = DataQualityService.DisabledChecks(disabledCsv);
var results = await _db.DataQualityResults.ToDictionaryAsync(r => r.CheckType, r => r, ct);
var checks = new List<CheckSummary>();
foreach (var c in DataQualityService.ProductChecks)
{
results.TryGetValue(c, out var r);
checks.Add(new CheckSummary(c, DataQualityService.Labels[c], disabled.Contains(c) ? 0 : (r?.Count ?? 0),
!disabled.Contains(c), null));
}
// Товары в минусе — не дублируем, ссылаемся на отчёт/центр.
var neg = await _db.NegativeStockAlerts.CountAsync(a => a.ResolvedAt == null, ct);
checks.Add(new CheckSummary("negative-stock", DataQualityService.Labels["negative-stock"], neg, true,
"/inventory/stock?negativeOnly=true"));
var total = checks.Where(x => x.enabled && x.checkType != "negative-stock").Sum(x => x.count);
var last = results.Values.Count > 0 ? results.Values.Max(r => (DateTime?)r.LastCheckedAt) : null;
return new SummaryResult(checks, total, last);
}
/// <summary>Детальный список проблемных товаров (живой запрос, всегда актуальный).</summary>
[HttpGet("{checkType}/products"), RequiresPermission("ProductsView")]
public async Task<ActionResult<IReadOnlyList<DataQualityService.ProductRow>>> Products(
string checkType, [FromQuery] int take = 100, CancellationToken ct = default)
{
if (!DataQualityService.ProductChecks.Contains(checkType))
return BadRequest(new { error = "Неизвестная проверка." });
return Ok(await _svc.ListAsync(checkType, take, ct));
}
/// <summary>Пересчитать все проверки организации сейчас (user-triggered).</summary>
[HttpPost("recompute"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<SummaryResult>> Recompute(CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var disabledCsv = await _db.Organizations.Where(o => o.Id == orgId)
.Select(o => o.DataQualityDisabledChecks).FirstOrDefaultAsync(ct);
await _job.RecomputeAsync(orgId, disabledCsv, ct);
return await Summary(ct);
}
public record DismissInput(string checkType, Guid? productId, string? barcode);
/// <summary>«Это разные товары»: больше не считать это дублем.</summary>
[HttpPost("dismiss"), RequiresPermission("ProductsEdit")]
public async Task<IActionResult> Dismiss([FromBody] DismissInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
string? key = null;
if (input.checkType == "dup-barcode") key = input.barcode?.Trim();
else if (input.checkType == "dup-name-unit" && input.productId is { } pid)
{
var p = await _db.Products.Where(x => x.Id == pid).Select(x => new { x.Name, x.UnitOfMeasureId }).FirstOrDefaultAsync(ct);
if (p is not null) key = $"{p.Name.ToLower()}|{p.UnitOfMeasureId}";
}
if (string.IsNullOrWhiteSpace(key)) return BadRequest(new { error = "Не удалось определить ключ дубля." });
if (!await _db.DataQualityDismissals.AnyAsync(d => d.CheckType == input.checkType && d.DismissKey == key, ct))
{
_db.DataQualityDismissals.Add(new DataQualityDismissal { OrganizationId = orgId, CheckType = input.checkType, DismissKey = key });
await _db.SaveChangesAsync(ct);
}
return NoContent();
}
// ── Массовое исправление ────────────────────────────────────────────────────
public record BulkGroupInput(IReadOnlyList<Guid> productIds, Guid groupId);
public record BulkPriceInput(IReadOnlyList<Guid> productIds, Guid? priceTypeId, decimal amount);
public record BulkIdsInput(IReadOnlyList<Guid> productIds);
public record BulkResult(int affected);
[HttpPost("bulk/set-group"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<BulkResult>> BulkSetGroup([FromBody] BulkGroupInput input, CancellationToken ct)
{
if (!await _db.ProductGroups.AnyAsync(g => g.Id == input.groupId, ct))
return BadRequest(new { error = "Группа не найдена." });
var n = await _db.Products.Where(p => input.productIds.Contains(p.Id))
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ProductGroupId, input.groupId), ct);
return new BulkResult(n);
}
[HttpPost("bulk/set-price"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<BulkResult>> BulkSetPrice([FromBody] BulkPriceInput input, CancellationToken ct)
{
if (input.amount <= 0m) return BadRequest(new { error = "Цена должна быть больше 0." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ptId = input.priceTypeId ?? await _db.PriceTypes.Where(p => p.IsRetail)
.OrderByDescending(p => p.IsSystem).Select(p => (Guid?)p.Id).FirstOrDefaultAsync(ct);
if (ptId is not { } priceType) return BadRequest(new { error = "Не найден тип цены." });
var curId = await _db.Currencies.Where(c => c.Code == "KZT").Select(c => c.Id).FirstOrDefaultAsync(ct);
var ids = await _db.Products.Where(p => input.productIds.Contains(p.Id)).Select(p => p.Id).ToListAsync(ct);
int affected = 0;
foreach (var pid in ids)
{
var existing = await _db.ProductPrices.FirstOrDefaultAsync(pp => pp.ProductId == pid && pp.PriceTypeId == priceType, ct);
if (existing is null)
_db.ProductPrices.Add(new ProductPrice { OrganizationId = orgId, ProductId = pid, PriceTypeId = priceType, Amount = input.amount, CurrencyId = curId });
else existing.Amount = input.amount;
affected++;
}
await _db.SaveChangesAsync(ct);
return new BulkResult(affected);
}
[HttpPost("bulk/generate-barcodes"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<BulkResult>> BulkGenerateBarcodes([FromBody] BulkIdsInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var settings = await GetWeightedSettingsAsync(ct);
// Только товары без штрихкодов (не трогаем те, у кого уже есть).
var ids = await _db.Products.Where(p => input.productIds.Contains(p.Id) && !p.Barcodes.Any())
.Select(p => p.Id).ToListAsync(ct);
int affected = 0;
foreach (var pid in ids)
{
var code = await GenerateUniqueInternalBarcodeAsync(settings, ct);
_db.ProductBarcodes.Add(new ProductBarcode { OrganizationId = orgId, ProductId = pid, Code = code, Type = BarcodeType.Ean13, IsPrimary = true });
affected++;
}
await _db.SaveChangesAsync(ct);
return new BulkResult(affected);
}
// ── Настройки проверок ──────────────────────────────────────────────────────
public record SettingsInput(IReadOnlyList<string> disabledChecks);
[HttpPut("settings"), Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateSettings([FromBody] SettingsInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var valid = input.disabledChecks.Where(c => DataQualityService.ProductChecks.Contains(c)).Distinct();
var csv = string.Join(",", valid);
await _db.Organizations.Where(o => o.Id == orgId)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.DataQualityDisabledChecks, csv.Length == 0 ? null : csv), ct);
return NoContent();
}
private async Task<WeightedBarcodeSettings> GetWeightedSettingsAsync(CancellationToken ct)
{
var o = await _db.Organizations.Where(x => x.Id == _tenant.OrganizationId)
.Select(x => new { x.WeightBarcodeEnabled, x.WeightBarcodeWeightPrefixes, x.WeightBarcodePricePrefixes, x.WeightBarcodeCodeDigits })
.FirstOrDefaultAsync(ct);
return o is null ? WeightedBarcodeSettings.Default
: WeightedBarcodeSettings.Create(o.WeightBarcodeEnabled, o.WeightBarcodeWeightPrefixes, o.WeightBarcodePricePrefixes, o.WeightBarcodeCodeDigits);
}
private async Task<string> GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct)
{
for (int i = 0; i < 20; i++)
{
var code = InternalBarcode.Generate(settings);
if (!await _db.ProductBarcodes.AnyAsync(b => b.Code == code, ct)) return code;
}
return InternalBarcode.Generate(settings);
}
}

View file

@ -496,6 +496,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>();
builder.Services.AddScoped<foodmarket.Api.Background.ShiftWatchJob>();
builder.Services.AddScoped<foodmarket.Api.Services.DataQualityService>();
builder.Services.AddScoped<foodmarket.Api.Background.DataQualityJob>();
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.

View file

@ -0,0 +1,137 @@
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());
}

View file

@ -0,0 +1,29 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Catalog;
/// <summary>Сохранённый результат фоновой «Проверки данных»: счётчик по одной
/// проверке в одной организации + когда посчитан. Считается Hangfire-задачей
/// (ночью полностью, плюс recompute по запросу), читается панелью/бейджем быстро
/// без тяжёлых запросов в часы продаж.</summary>
public class DataQualityResult : TenantEntity
{
/// <summary>Код проверки: no-group | no-sale-price | no-barcode | dup-barcode |
/// dup-name-unit | negative-stock.</summary>
public string CheckType { get; set; } = null!;
/// <summary>Сколько объектов (товаров/групп дублей) с этой проблемой сейчас.</summary>
public int Count { get; set; }
public DateTime LastCheckedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Отметка «это разные товары» — пользователь подтвердил, что найденные
/// как возможный дубль записи дублями не являются; больше не предупреждаем.
/// Ключ зависит от проверки: для dup-barcode — сам штрихкод; для dup-name-unit —
/// «nameLower|unitId».</summary>
public class DataQualityDismissal : TenantEntity
{
public string CheckType { get; set; } = null!;
public string DismissKey { get; set; } = null!;
}

View file

@ -140,6 +140,10 @@ public class Organization : Entity
/// уведомление владельцу/админам + блок «Просроченные минусы». Default 24.</summary>
public int NegStockEscalateHours { get; set; } = 24;
/// <summary>Отключённые проверки «Проверки данных» (CSV кодов: no-group,
/// no-sale-price, no-barcode, dup-barcode, dup-name-unit). null/пусто — все включены.</summary>
public string? DataQualityDisabledChecks { get; set; }
// ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret

View file

@ -79,6 +79,8 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<foodmarket.Domain.Organizations.DeviceToken> DeviceTokens => Set<foodmarket.Domain.Organizations.DeviceToken>();
public DbSet<foodmarket.Domain.Notifications.Notification> Notifications => Set<foodmarket.Domain.Notifications.Notification>();
public DbSet<foodmarket.Domain.Notifications.WebPushSubscription> WebPushSubscriptions => Set<foodmarket.Domain.Notifications.WebPushSubscription>();
public DbSet<foodmarket.Domain.Catalog.DataQualityResult> DataQualityResults => Set<foodmarket.Domain.Catalog.DataQualityResult>();
public DbSet<foodmarket.Domain.Catalog.DataQualityDismissal> DataQualityDismissals => Set<foodmarket.Domain.Catalog.DataQualityDismissal>();
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
@ -233,6 +235,20 @@ protected override void OnModelCreating(ModelBuilder builder)
b.HasIndex(x => new { x.OrganizationId, x.UserId });
});
builder.Entity<foodmarket.Domain.Catalog.DataQualityResult>(b =>
{
b.ToTable("data_quality_results");
b.Property(x => x.CheckType).HasMaxLength(40).IsRequired();
b.HasIndex(x => new { x.OrganizationId, x.CheckType }).IsUnique();
});
builder.Entity<foodmarket.Domain.Catalog.DataQualityDismissal>(b =>
{
b.ToTable("data_quality_dismissals");
b.Property(x => x.CheckType).HasMaxLength(40).IsRequired();
b.Property(x => x.DismissKey).HasMaxLength(300).IsRequired();
b.HasIndex(x => new { x.OrganizationId, x.CheckType, x.DismissKey }).IsUnique();
});
builder.ConfigureCatalog();
builder.ConfigureInventory();
builder.ConfigurePurchases();

View file

@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase43 — фоновая «Проверка данных»: таблицы результатов проверок и
/// отметок «это разные товары» + настройка отключённых проверок в организации.
/// Идемпотентно.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261009050000_Phase43_DataQuality")]
public partial class Phase43_DataQuality : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.data_quality_results (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""CheckType"" varchar(40) NOT NULL,
""Count"" integer NOT NULL DEFAULT 0,
""LastCheckedAt"" timestamp with time zone NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_data_quality_results_Org_Check""
ON public.data_quality_results (""OrganizationId"", ""CheckType"");
CREATE TABLE IF NOT EXISTS public.data_quality_dismissals (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""CheckType"" varchar(40) NOT NULL,
""DismissKey"" varchar(300) NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_data_quality_dismissals_Org_Check_Key""
ON public.data_quality_dismissals (""OrganizationId"", ""CheckType"", ""DismissKey"");
");
b.Sql(@"ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""DataQualityDisabledChecks"" text;");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"DROP TABLE IF EXISTS public.data_quality_results;");
b.Sql(@"DROP TABLE IF EXISTS public.data_quality_dismissals;");
b.Sql(@"ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""DataQualityDisabledChecks"";");
}
}
}

View file

@ -0,0 +1,70 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Фоновая «Проверка данных»: счётчики (без группы/цены), детальный список,
/// массовое исправление, «это разные товары».</summary>
[Collection(ApiCollection.Name)]
public class DataQualityTests
{
private readonly ApiFactory _factory;
public DataQualityTests(ApiFactory factory) => _factory = factory;
private async Task<string> QuickProductAsync(ApiActor api, string name, decimal? price = null)
{
var r = await api.Http.PostAsJsonAsync("/api/catalog/products/quick", new { name, salePrice = price });
r.StatusCode.Should().Be(HttpStatusCode.Created, await r.Content.ReadAsStringAsync());
return (await r.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
[Fact]
public async Task Counts_detail_bulkfix_and_dismiss_work()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"dq-{Guid.NewGuid():N}");
// Товары с проблемами: без цены (и в корне → без группы), + дубль по имени.
var p1 = await QuickProductAsync(api, "Молоко разливное"); // нет цены, корень
await QuickProductAsync(api, "Хлеб белый", 180m); // есть цена, но корень
await QuickProductAsync(api, "Дубль-товар");
await QuickProductAsync(api, "Дубль-товар"); // пара → dup-name-unit
// Пересчёт → сводка со счётчиками.
var sumResp = await api.Http.PostAsJsonAsync("/api/data-quality/recompute", new { });
sumResp.EnsureSuccessStatusCode();
var sum = await sumResp.Content.ReadFromJsonAsync<JsonElement>();
var checks = sum.GetProperty("checks").EnumerateArray()
.ToDictionary(x => x.GetProperty("checkType").GetString()!, x => x.GetProperty("count").GetInt32());
checks["no-group"].Should().BeGreaterThanOrEqualTo(4, "все 4 товара в корневой группе");
checks["no-sale-price"].Should().BeGreaterThanOrEqualTo(3, "три без цены");
checks["dup-name-unit"].Should().BeGreaterThanOrEqualTo(1, "пара «Дубль-товар»");
// Детальный список без цены содержит p1.
var noPrice = await api.ListAsync("/api/data-quality/no-sale-price/products");
noPrice.Should().Contain(x => x.GetProperty("id").GetString() == p1);
// Массовое: задать цену p1 → в списке «без цены» его больше нет.
var setPrice = await api.Http.PostAsJsonAsync("/api/data-quality/bulk/set-price",
new { productIds = new[] { p1 }, priceTypeId = (string?)null, amount = 300m });
setPrice.EnsureSuccessStatusCode();
var noPrice2 = await api.ListAsync("/api/data-quality/no-sale-price/products");
noPrice2.Should().NotContain(x => x.GetProperty("id").GetString() == p1);
// «Это разные товары» для дубля → счётчик дублей падает.
var dupList = await api.ListAsync("/api/data-quality/dup-name-unit/products");
var dupId = dupList.First().GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync("/api/data-quality/dismiss",
new { checkType = "dup-name-unit", productId = dupId, barcode = (string?)null })).EnsureSuccessStatusCode();
var afterResp = await api.Http.PostAsJsonAsync("/api/data-quality/recompute", new { });
afterResp.EnsureSuccessStatusCode();
var afterDismiss = await afterResp.Content.ReadFromJsonAsync<JsonElement>();
var dupAfter = afterDismiss.GetProperty("checks").EnumerateArray()
.First(x => x.GetProperty("checkType").GetString() == "dup-name-unit").GetProperty("count").GetInt32();
dupAfter.Should().Be(0, "единственная пара помечена «разные товары»");
}
}