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
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:
parent
c4a59d7bf2
commit
99bf6c9f3e
50
docs/seeder-noise-progress.md
Normal file
50
docs/seeder-noise-progress.md
Normal 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-воркер, не сидер |
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue