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
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:
parent
f6cd1063f1
commit
261a720b52
39
docs/data-quality-progress.md
Normal file
39
docs/data-quality-progress.md
Normal 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/себестоимость — отдельная тема.
|
||||||
54
src/food-market.api/Background/DataQualityJob.cs
Normal file
54
src/food-market.api/Background/DataQualityJob.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -161,6 +161,14 @@ public Task StartAsync(CancellationToken ct)
|
||||||
cronExpression: cronShiftWatch,
|
cronExpression: cronShiftWatch,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
194
src/food-market.api/Controllers/DataQualityController.cs
Normal file
194
src/food-market.api/Controllers/DataQualityController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -496,6 +496,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
|
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>();
|
builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>();
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.ShiftWatchJob>();
|
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,
|
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
|
||||||
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.
|
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.
|
||||||
|
|
|
||||||
137
src/food-market.api/Services/DataQualityService.cs
Normal file
137
src/food-market.api/Services/DataQualityService.cs
Normal 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());
|
||||||
|
}
|
||||||
29
src/food-market.domain/Catalog/DataQuality.cs
Normal file
29
src/food-market.domain/Catalog/DataQuality.cs
Normal 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!;
|
||||||
|
}
|
||||||
|
|
@ -140,6 +140,10 @@ public class Organization : Entity
|
||||||
/// уведомление владельцу/админам + блок «Просроченные минусы». Default 24.</summary>
|
/// уведомление владельцу/админам + блок «Просроченные минусы». Default 24.</summary>
|
||||||
public int NegStockEscalateHours { get; set; } = 24;
|
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: ОФД (фискализация чеков) ────────────────────────────
|
// ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
|
||||||
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
|
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
|
||||||
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret
|
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret
|
||||||
|
|
|
||||||
|
|
@ -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.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.Notification> Notifications => Set<foodmarket.Domain.Notifications.Notification>();
|
||||||
public DbSet<foodmarket.Domain.Notifications.WebPushSubscription> WebPushSubscriptions => Set<foodmarket.Domain.Notifications.WebPushSubscription>();
|
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<Employee> Employees => Set<Employee>();
|
||||||
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
||||||
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
||||||
|
|
@ -233,6 +235,20 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.UserId });
|
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.ConfigureCatalog();
|
||||||
builder.ConfigureInventory();
|
builder.ConfigureInventory();
|
||||||
builder.ConfigurePurchases();
|
builder.ConfigurePurchases();
|
||||||
|
|
|
||||||
|
|
@ -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"";");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
tests/food-market.IntegrationTests/DataQualityTests.cs
Normal file
70
tests/food-market.IntegrationTests/DataQualityTests.cs
Normal 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, "единственная пара помечена «разные товары»");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue