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, "единственная пара помечена «разные товары»");
+ }
+}