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();
+ }
+}