diff --git a/docs/seeder-noise-progress.md b/docs/seeder-noise-progress.md new file mode 100644 index 0000000..2f1d331 --- /dev/null +++ b/docs/seeder-noise-progress.md @@ -0,0 +1,50 @@ +# Seeder noise cleanup — прогресс + +Контекст: при каждом старте `food-market-stage-api` в логах появлялись `ERR` +от `QuickLoginDemoSeeder → DemoTenantSeeder.SeedAsync` — повторная попытка +залить фиктивный DEMO-каталог в «Демо-магазин», падающая на unique-индексе +`IX_product_barcodes_OrganizationId_Code` (23505). В БД ничего лишнего не +попадало (транзакция откатывалась), но лог был зашумлён ~9 строк/сутки. + +Корневая причина: idempotency-маркер `DemoTenantSeeder` — наличие Product +с `Article LIKE 'DEMO-%'`. После того как «Демо-магазин» наполнили настоящим +каталогом из МойСклада (у товаров есть `MoySkladExternalId`, а артикулы +не начинаются с `DEMO-`), маркер срабатывал false → сидер шёл заново, +генерировал новые Product'ы с теми же EAN-13 (seed 200_000_000_001…), и +упирался в unique по `(OrganizationId, Code)` на первом же `SaveChangesAsync`. + +## Чеклист + +- [x] 1. `DemoTenantSeeder.SeedAsync` ничего не делает, если в организации уже + есть любые товары (а не только `DEMO-*`). Демо-магазин с реальным + каталогом из МойСклада не трогаем. +- [x] 2. Остальные стартовые сидеры/джобы проверены — нет повторных вставок. +- [x] 3. Integration-тест: повторный запуск сидера на организации с товарами + не пишет и не падает. +- [x] 4. `~/deploy-stage.sh`, логи stage-api после старта — 0 строк `ERR`/`fail`. + +## Что меняем + +1. В `DemoTenantSeeder.SeedAsync` idempotency check расширяется: сейчас + «уже сидировано» = Product с `Article LIKE 'DEMO-%'`. Новая логика — + «уже сидировано» = любой Product в орге (`_db.Products.AnyAsync(p ⇒ + p.OrganizationId == orgId)`). При таком раскладе `CountExistingAsync` + вернёт реальные count'ы, `AlreadySeeded = true`, никаких вставок. +2. Поведение из UI (`POST /api/admin/seed-demo`) не меняется для свежих + org'ов. Для орги с товарами возвращаем summary без вставок — ровно + как и раньше, только маркер стал шире. +3. `YearDemoSeeder` уже и так защищён `hasActivity` гардом (Supply или + RetailSale) — к нему претензий нет. + +## Какие сидеры/джобы проверены + +| Hosted service | Повторные вставки при каждом старте? | +|---|---| +| `OpenIddictClientSeeder` | нет — проверяет `FindByClientIdAsync` + update | +| `SystemReferenceSeeder` | нет — все вставки через `AnyAsync` / `FirstOrDefault` guard | +| `DevDataSeeder` | нет — все вставки идемпотентны (роли/юзеры/единицы/org/stores) | +| `QuickLoginDemoSeeder` | один путь (`TryFillCatalogAsync`) чинится в задаче — он здесь корень шума | +| `HangfireJobsConfigurator` | нет — `AddOrUpdate` по cron-id | +| `HangfireGlobalFilterRegistrar` | нет — pure в-процесс конфиг | +| `InvoiceWorker` | polling-воркер, не сидер | +| `ReferencePriceRefreshJob` | polling-воркер, не сидер | diff --git a/src/food-market.api/Seed/DemoTenantSeeder.cs b/src/food-market.api/Seed/DemoTenantSeeder.cs index 729ddbb..5f94c75 100644 --- a/src/food-market.api/Seed/DemoTenantSeeder.cs +++ b/src/food-market.api/Seed/DemoTenantSeeder.cs @@ -11,9 +11,9 @@ namespace foodmarket.Api.Seed; /// Заполняет КОНКРЕТНОГО tenant'а реалистичными демо-данными для /// показа стенда: 5 групп / 50 товаров / 10 контрагентов / 5 приёмок (Posted) /// / 30 розничных продаж (Posted) / 1 опт. отгрузка / 1 списание / 1 перемещение -/// / 1 инвентаризация. Идемпотентен: маркер — наличие любого Product с -/// Article startsWith "DEMO-" в этой org. Повторный вызов возвращает summary -/// без повторных вставок. +/// / 1 инвентаризация. Идемпотентен: маркер — наличие ЛЮБОГО Product в орге. +/// Повторный вызов возвращает summary без повторных вставок, даже если +/// каталог был наполнен не этим сидером (например, импортом из МойСклада). /// /// Запускается через POST /api/admin/seed-demo (см. DemoSeedController). /// Внутри одной транзакции; на стейдже занимает 2-5 секунд. @@ -37,9 +37,19 @@ public sealed record SeedSummary( public async Task SeedAsync(Guid orgId, CancellationToken ct = default) { - // Idempotency marker: artikul DEMO-* (включает и фолбэк DEMO-{group}-NN). + // Idempotency marker: ЛЮБОЙ Product в организации. Раньше маркером было + // «есть Product с Article LIKE 'DEMO-%'» — но после того как «Демо- + // магазин» на стенде наполнили реальным каталогом из МойСклада + // (артикулы НЕ DEMO-*), маркер стал false, сидер шёл заново и падал + // на unique-индексе IX_product_barcodes_OrganizationId_Code (23505), + // засирая логи stage-api при каждом рестарте. Расширяем: если в орге + // уже хоть что-то есть — считаем её «заселённой» и выходим без вставок. + // + // Поведение из UI (POST /api/admin/seed-demo) не ломается: Admin + // по-прежнему получает summary, просто теперь на непустой орге сидер + // возвращает «уже сидировано» (а не кидается записывать поверх). var alreadySeeded = await _db.Products.IgnoreQueryFilters() - .AnyAsync(p => p.OrganizationId == orgId && p.Article != null && p.Article.StartsWith("DEMO-"), ct); + .AnyAsync(p => p.OrganizationId == orgId, ct); if (alreadySeeded) { // Возвращаем какие данные уже есть, чтобы UI мог показать summary. @@ -412,13 +422,17 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa Stores: stores.Count + (secondStore is { } ? 1 : 0)); } - /// Только считает, ничего не вставляет. Используется в GET-status. + /// Только считает, ничего не вставляет. Используется в GET-status. + /// Маркер «AlreadySeeded» теперь = «в орге есть хотя бы один Product» — + /// совпадает с guard'ом в , чтобы UI не предлагал + /// «Заполнить демо» на орге, где уже есть реальный каталог (например, + /// после импорта из МойСклада). public async Task PeekAsync(Guid orgId, CancellationToken ct) { - var hasDemo = await _db.Products.IgnoreQueryFilters() - .AnyAsync(p => p.OrganizationId == orgId && p.Article != null && p.Article.StartsWith("DEMO-"), ct); + var hasAnyProducts = await _db.Products.IgnoreQueryFilters() + .AnyAsync(p => p.OrganizationId == orgId, ct); var counts = await CountExistingAsync(orgId, ct); - return counts with { AlreadySeeded = hasDemo }; + return counts with { AlreadySeeded = hasAnyProducts }; } private async Task CountExistingAsync(Guid orgId, CancellationToken ct) diff --git a/tests/food-market.IntegrationTests/DemoSeederIdempotencyTests.cs b/tests/food-market.IntegrationTests/DemoSeederIdempotencyTests.cs new file mode 100644 index 0000000..4d00b5f --- /dev/null +++ b/tests/food-market.IntegrationTests/DemoSeederIdempotencyTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Регрессия на шум в логах stage-api: раньше +/// определял «уже сидировано» +/// по наличию Product с Article LIKE 'DEMO-%'. После того как «Демо-магазин» +/// на стенде наполнили реальным каталогом из МойСклада (не с префиксом DEMO-), +/// маркер срабатывал false — сидер шёл заново и падал на unique-индексе +/// IX_product_barcodes_OrganizationId_Code (23505) при каждом рестарте. +/// Фикс расширил маркер до «любой Product в орге». Тесты фиксируют: +/// 1) повторный Seed на орге с товарами не пишет и не падает; +/// 2) org с собственным (не-DEMO) каталогом тоже защищена. +[Collection(ApiCollection.Name)] +public class DemoSeederIdempotencyTests +{ + private readonly ApiFactory _factory; + public DemoSeederIdempotencyTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task SeedDemo_second_call_on_populated_org_returns_already_seeded() + { + // Фоновый сценарий: Admin впервые вызвал seed-demo (50 товаров DEMO-*), + // перезапуск или повторное нажатие не должны дёрнуть вставку второй раз. + var a = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"seed-twice-{Guid.NewGuid():N}"); + + using var first = await a.Http.PostAsync("/api/admin/seed-demo", content: null); + first.StatusCode.Should().Be(HttpStatusCode.OK); + var firstBody = await first.Content.ReadFromJsonAsync(); + firstBody.GetProperty("alreadySeeded").GetBoolean().Should().BeFalse(); + firstBody.GetProperty("products").GetInt32().Should().Be(50); + + using var second = await a.Http.PostAsync("/api/admin/seed-demo", content: null); + second.StatusCode.Should().Be(HttpStatusCode.OK); + var secondBody = await second.Content.ReadFromJsonAsync(); + secondBody.GetProperty("alreadySeeded").GetBoolean().Should().BeTrue(); + // Количество товаров не выросло — повторная вставка не произошла. + secondBody.GetProperty("products").GetInt32().Should().Be(50); + } + + [Fact] + public async Task SeedDemo_on_org_with_imported_catalog_skips_without_duplicate_barcode() + { + // Главная регрессия: воспроизводит stage-сценарий, где у организации уже + // есть товары БЕЗ префикса DEMO-. Старый маркер (Article LIKE 'DEMO-%') + // пропускал гард и сидер падал на unique-индексе по баркодам (23505). + var a = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"seed-imported-{Guid.NewGuid():N}"); + var refs = await a.LoadRefsAsync(); + + // Один товар с «импортным» (не DEMO-*) артикулом — имитирует каталог + // из МойСклада. Этого хватает чтобы новый маркер «any Product» сработал. + await a.CreateProductAsync(refs, $"ImportedProduct-{Guid.NewGuid():N}", + retailPrice: 500m, barcode: $"IMP-{Guid.NewGuid():N}"); + + using var resp = await a.Http.PostAsync("/api/admin/seed-demo", content: null); + resp.StatusCode.Should().Be(HttpStatusCode.OK, "сидер должен no-op'нуть, а не упасть с 500"); + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("alreadySeeded").GetBoolean().Should().BeTrue(); + // Должен быть ровно 1 товар — тот что мы создали; никаких DEMO- вставок. + body.GetProperty("products").GetInt32().Should().Be(1); + + // Status (Peek) тоже отражает «заселено» — чтобы UI не предлагал кнопку. + var status = await a.GetJsonAsync("/api/admin/seed-demo/status"); + status.GetProperty("alreadySeeded").GetBoolean().Should().BeTrue(); + } +}