fix(seed): DemoTenantSeeder гардит по наличию ЛЮБЫХ товаров в орге
Some checks failed
Auto-tag / Create date-tag (push) Has been cancelled
CI / Backend (.NET 8) (push) Has been cancelled
CI / Web (React + Vite) (push) Has been cancelled
CI / POS (WPF, Windows) (push) Has been cancelled
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled

Старый idempotency-маркер — Product с Article LIKE 'DEMO-%'. После того как
«Демо-магазин» на стенде наполнили реальным каталогом из МойСклада
(артикулы не DEMO-*), маркер срабатывал false и QuickLoginDemoSeeder при
каждом рестарте стенда пытался залить фиктивный каталог заново — падая
на unique-индексе IX_product_barcodes_OrganizationId_Code (23505). В БД
ничего лишнего не попадало (транзакция откатывалась), но лог stage-api
получал 9 ERR в сутки.

Фикс: SeedAsync и PeekAsync считают «уже сидировано» при любом Product в
орге. Поведение из UI (POST /api/admin/seed-demo) не меняется для свежих
org'ов. Прочие стартовые сидеры (OpenIddictClientSeeder,
SystemReferenceSeeder, DevDataSeeder) и HangfireJobsConfigurator — уже
идемпотентны, аудит в docs/seeder-noise-progress.md.

Integration-тест:
- SeedDemo_second_call_on_populated_org_returns_already_seeded
- SeedDemo_on_org_with_imported_catalog_skips_without_duplicate_barcode

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-06 00:50:50 +05:00
parent c4a59d7bf2
commit 99bf6c9f3e
3 changed files with 146 additions and 9 deletions

View file

@ -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-воркер, не сидер |

View file

@ -11,9 +11,9 @@ namespace foodmarket.Api.Seed;
/// <summary>Заполняет КОНКРЕТНОГО tenant'а реалистичными демо-данными для /// <summary>Заполняет КОНКРЕТНОГО tenant'а реалистичными демо-данными для
/// показа стенда: 5 групп / 50 товаров / 10 контрагентов / 5 приёмок (Posted) /// показа стенда: 5 групп / 50 товаров / 10 контрагентов / 5 приёмок (Posted)
/// / 30 розничных продаж (Posted) / 1 опт. отгрузка / 1 списание / 1 перемещение /// / 30 розничных продаж (Posted) / 1 опт. отгрузка / 1 списание / 1 перемещение
/// / 1 инвентаризация. Идемпотентен: маркер — наличие любого Product с /// / 1 инвентаризация. Идемпотентен: маркер — наличие ЛЮБОГО Product в орге.
/// Article startsWith "DEMO-" в этой org. Повторный вызов возвращает summary /// Повторный вызов возвращает summary без повторных вставок, даже если
/// без повторных вставок. /// каталог был наполнен не этим сидером (например, импортом из МойСклада).
/// ///
/// Запускается через POST /api/admin/seed-demo (см. DemoSeedController). /// Запускается через POST /api/admin/seed-demo (см. DemoSeedController).
/// Внутри одной транзакции; на стейдже занимает 2-5 секунд.</summary> /// Внутри одной транзакции; на стейдже занимает 2-5 секунд.</summary>
@ -37,9 +37,19 @@ public sealed record SeedSummary(
public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = default) public async Task<SeedSummary> 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() 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) if (alreadySeeded)
{ {
// Возвращаем какие данные уже есть, чтобы UI мог показать summary. // Возвращаем какие данные уже есть, чтобы UI мог показать summary.
@ -412,13 +422,17 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
Stores: stores.Count + (secondStore is { } ? 1 : 0)); Stores: stores.Count + (secondStore is { } ? 1 : 0));
} }
/// <summary>Только считает, ничего не вставляет. Используется в GET-status.</summary> /// <summary>Только считает, ничего не вставляет. Используется в GET-status.
/// Маркер «AlreadySeeded» теперь = «в орге есть хотя бы один Product» —
/// совпадает с guard'ом в <see cref="SeedAsync"/>, чтобы UI не предлагал
/// «Заполнить демо» на орге, где уже есть реальный каталог (например,
/// после импорта из МойСклада).</summary>
public async Task<SeedSummary> PeekAsync(Guid orgId, CancellationToken ct) public async Task<SeedSummary> PeekAsync(Guid orgId, CancellationToken ct)
{ {
var hasDemo = await _db.Products.IgnoreQueryFilters() var hasAnyProducts = await _db.Products.IgnoreQueryFilters()
.AnyAsync(p => p.OrganizationId == orgId && p.Article != null && p.Article.StartsWith("DEMO-"), ct); .AnyAsync(p => p.OrganizationId == orgId, ct);
var counts = await CountExistingAsync(orgId, ct); var counts = await CountExistingAsync(orgId, ct);
return counts with { AlreadySeeded = hasDemo }; return counts with { AlreadySeeded = hasAnyProducts };
} }
private async Task<SeedSummary> CountExistingAsync(Guid orgId, CancellationToken ct) private async Task<SeedSummary> CountExistingAsync(Guid orgId, CancellationToken ct)

View file

@ -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;
/// <summary>Регрессия на шум в логах stage-api: раньше
/// <see cref="foodmarket.Api.Seed.DemoTenantSeeder"/> определял «уже сидировано»
/// по наличию Product с Article LIKE 'DEMO-%'. После того как «Демо-магазин»
/// на стенде наполнили реальным каталогом из МойСклада (не с префиксом DEMO-),
/// маркер срабатывал false — сидер шёл заново и падал на unique-индексе
/// IX_product_barcodes_OrganizationId_Code (23505) при каждом рестарте.
/// Фикс расширил маркер до «любой Product в орге». Тесты фиксируют:
/// 1) повторный Seed на орге с товарами не пишет и не падает;
/// 2) org с собственным (не-DEMO) каталогом тоже защищена.</summary>
[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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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();
}
}