diff --git a/docs/data-quality-progress.md b/docs/data-quality-progress.md new file mode 100644 index 0000000..97c9c9c --- /dev/null +++ b/docs/data-quality-progress.md @@ -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/себестоимость — отдельная тема. diff --git a/src/food-market.api/Background/DataQualityJob.cs b/src/food-market.api/Background/DataQualityJob.cs new file mode 100644 index 0000000..ed1a586 --- /dev/null +++ b/src/food-market.api/Background/DataQualityJob.cs @@ -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; + +/// Фоновая «Проверка данных»: раз в сутки (низкий приоритет) по каждой +/// организации считает счётчики проблем (без группы, без цены, без штрихкода, +/// дубли) и сохраняет в data_quality_results. Панель/бейдж читают готовые числа — +/// без тяжёлых запросов в часы продаж. Можно пересчитать по запросу (RecomputeOrgAsync). +public sealed class DataQualityJob +{ + private readonly AppDbContext _db; + private readonly DataQualityService _svc; + private readonly ILogger _log; + + public DataQualityJob(AppDbContext db, DataQualityService svc, ILogger 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); } + } + } + + /// Пересчитать все проверки одной организации (вызов из job и из recompute-эндпоинта, + /// где tenant-scope уже установлен). + 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); + } +} diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index d25589e..bd68867 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -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( + recurringJobId: "data-quality-scan", + methodCall: j => j.RunAsync(CancellationToken.None), + cronExpression: cronDataQuality, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + return Task.CompletedTask; } diff --git a/src/food-market.api/Controllers/DataQualityController.cs b/src/food-market.api/Controllers/DataQualityController.cs new file mode 100644 index 0000000..b4eb781 --- /dev/null +++ b/src/food-market.api/Controllers/DataQualityController.cs @@ -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; + +/// Фоновая «Проверка данных»: сводка по проблемам (товары без группы/цены/ +/// штрихкода, дубли), детальные списки, массовое исправление, «это разные товары», +/// пересчёт, настройки. Тот же API использует мобильное приложение. +[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 checks, int total, DateTime? lastCheckedAt); + + /// Сводка по проверкам со счётчиками (из сохранённых результатов job-а) + + /// товары в минусе (ссылкой на центр). total — сумма активных проблем. + [HttpGet, RequiresPermission("ProductsView")] + public async Task> 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(); + 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); + } + + /// Детальный список проблемных товаров (живой запрос, всегда актуальный). + [HttpGet("{checkType}/products"), RequiresPermission("ProductsView")] + public async Task>> 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)); + } + + /// Пересчитать все проверки организации сейчас (user-triggered). + [HttpPost("recompute"), RequiresPermission("ProductsEdit")] + public async Task> 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); + + /// «Это разные товары»: больше не считать это дублем. + [HttpPost("dismiss"), RequiresPermission("ProductsEdit")] + public async Task 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 productIds, Guid groupId); + public record BulkPriceInput(IReadOnlyList productIds, Guid? priceTypeId, decimal amount); + public record BulkIdsInput(IReadOnlyList productIds); + public record BulkResult(int affected); + + [HttpPost("bulk/set-group"), RequiresPermission("ProductsEdit")] + public async Task> 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> 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> 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 disabledChecks); + + [HttpPut("settings"), Authorize(Roles = "Admin")] + public async Task 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 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 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); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 77e7cac..378dc7c 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -496,6 +496,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio, // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs. diff --git a/src/food-market.api/Services/DataQualityService.cs b/src/food-market.api/Services/DataQualityService.cs new file mode 100644 index 0000000..1c907e0 --- /dev/null +++ b/src/food-market.api/Services/DataQualityService.cs @@ -0,0 +1,137 @@ +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()); +} diff --git a/src/food-market.domain/Catalog/DataQuality.cs b/src/food-market.domain/Catalog/DataQuality.cs new file mode 100644 index 0000000..5ece954 --- /dev/null +++ b/src/food-market.domain/Catalog/DataQuality.cs @@ -0,0 +1,29 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Catalog; + +/// Сохранённый результат фоновой «Проверки данных»: счётчик по одной +/// проверке в одной организации + когда посчитан. Считается Hangfire-задачей +/// (ночью полностью, плюс recompute по запросу), читается панелью/бейджем быстро +/// без тяжёлых запросов в часы продаж. +public class DataQualityResult : TenantEntity +{ + /// Код проверки: no-group | no-sale-price | no-barcode | dup-barcode | + /// dup-name-unit | negative-stock. + public string CheckType { get; set; } = null!; + + /// Сколько объектов (товаров/групп дублей) с этой проблемой сейчас. + public int Count { get; set; } + + public DateTime LastCheckedAt { get; set; } = DateTime.UtcNow; +} + +/// Отметка «это разные товары» — пользователь подтвердил, что найденные +/// как возможный дубль записи дублями не являются; больше не предупреждаем. +/// Ключ зависит от проверки: для dup-barcode — сам штрихкод; для dup-name-unit — +/// «nameLower|unitId». +public class DataQualityDismissal : TenantEntity +{ + public string CheckType { get; set; } = null!; + public string DismissKey { get; set; } = null!; +} diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 3c9a7e7..5e7f603 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -140,6 +140,10 @@ public class Organization : Entity /// уведомление владельцу/админам + блок «Просроченные минусы». Default 24. public int NegStockEscalateHours { get; set; } = 24; + /// Отключённые проверки «Проверки данных» (CSV кодов: no-group, + /// no-sale-price, no-barcode, dup-barcode, dup-name-unit). null/пусто — все включены. + public string? DataQualityDisabledChecks { get; set; } + // ─ Sprint 11: ОФД (фискализация чеков) ──────────────────────────── // Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant: // у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 369ab90..494b58e 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -79,6 +79,8 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet DeviceTokens => Set(); public DbSet Notifications => Set(); public DbSet WebPushSubscriptions => Set(); + public DbSet DataQualityResults => Set(); + public DbSet DataQualityDismissals => Set(); public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); public DbSet SuperAdminAuditLogs => Set(); @@ -233,6 +235,20 @@ protected override void OnModelCreating(ModelBuilder builder) b.HasIndex(x => new { x.OrganizationId, x.UserId }); }); + builder.Entity(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(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(); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009050000_Phase43_DataQuality.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009050000_Phase43_DataQuality.cs new file mode 100644 index 0000000..13662ba --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009050000_Phase43_DataQuality.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase43 — фоновая «Проверка данных»: таблицы результатов проверок и + /// отметок «это разные товары» + настройка отключённых проверок в организации. + /// Идемпотентно. + [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"";"); + } + } +} diff --git a/tests/food-market.IntegrationTests/DataQualityTests.cs b/tests/food-market.IntegrationTests/DataQualityTests.cs new file mode 100644 index 0000000..d5f26f1 --- /dev/null +++ b/tests/food-market.IntegrationTests/DataQualityTests.cs @@ -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; + +/// Фоновая «Проверка данных»: счётчики (без группы/цены), детальный список, +/// массовое исправление, «это разные товары». +[Collection(ApiCollection.Name)] +public class DataQualityTests +{ + private readonly ApiFactory _factory; + public DataQualityTests(ApiFactory factory) => _factory = factory; + + private async Task 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()).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(); + 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(); + var dupAfter = afterDismiss.GetProperty("checks").EnumerateArray() + .First(x => x.GetProperty("checkType").GetString() == "dup-name-unit").GetProperty("count").GetInt32(); + dupAfter.Should().Be(0, "единственная пара помечена «разные товары»"); + } +}