fix(s30): QuickLoginDemoSeeder — свежий scope на каждый sub-step

Разбил монолитный SeedAsync на 4 метода, каждый со своим DI-scope и
DbContext'ом:
1) EnsureDemoOrgAsync — org Демо-магазин;
2) EnsureTenantReferencesAsync — единицы/магазин/роли через
   DevDataSeeder.SeedTenantReferencesAsync;
3) TryFillCatalogAsync — DemoTenantSeeder (товары/приёмки/продажи);
4) EnsureDemoUsersAsync — трио демо-юзеров.

Корень проблемы на стенде: DemoTenantSeeder падает с «Tenant not set»
(при старте нет HTTP-контекста → ITenantContext.OrganizationId пуст →
StockService.ApplyMovementAsync бросает). Если переиспользовать один
scope, следующий UserManager.CreateAsync возвращает
DbUpdateConcurrencyException — DbContext транзакционно сломан.

Теперь: catalog-step изолирован и его падение не отравляет создание
юзеров. Catalog нужен только для показательного UI — он
опциональный, с явным логом «ОК — создать можно через POST
/api/admin/seed-demo после логина».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 16:46:45 +05:00
parent e3a8db3206
commit f817abc8c7

View file

@ -70,18 +70,31 @@ public async Task StartAsync(CancellationToken ct)
} }
private async Task SeedAsync(CancellationToken ct) private async Task SeedAsync(CancellationToken ct)
{
// Каждый sub-step использует СВОЙ scope (и, значит, свой DbContext).
// Это важно: DemoTenantSeeder может упасть с «Tenant not set»
// (нет HTTP-контекста при старте приложения), оставив DbContext в
// транзакционно-битом состоянии. Если бы мы переиспользовали один
// scope, следующие UserManager.CreateAsync возвращали бы
// DbUpdateConcurrencyException. Свежий scope = свежая транзакция.
var demoOrgId = await EnsureDemoOrgAsync(ct);
await EnsureTenantReferencesAsync(demoOrgId, ct);
await TryFillCatalogAsync(demoOrgId, ct);
await EnsureDemoUsersAsync(demoOrgId, ct);
}
private async Task<Guid> EnsureDemoOrgAsync(CancellationToken ct)
{ {
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
// 1. «Демо-магазин» (orgEntity). Отдельная org, в отличие от «Demo Market»
// из DevDataSeeder — чтобы не трогать e2e, которые завязаны на Demo Market.
var demoOrg = await db.Organizations.IgnoreQueryFilters() var demoOrg = await db.Organizations.IgnoreQueryFilters()
.FirstOrDefaultAsync(o => o.Name == OrgName, ct); .FirstOrDefaultAsync(o => o.Name == OrgName, ct);
if (demoOrg is not null) return demoOrg.Id;
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
if (demoOrg is null)
{
demoOrg = new Organization demoOrg = new Organization
{ {
Name = OrgName, Name = OrgName,
@ -95,18 +108,27 @@ private async Task SeedAsync(CancellationToken ct)
db.Organizations.Add(demoOrg); db.Organizations.Add(demoOrg);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
_log.LogInformation("QuickLoginDemoSeeder: создана организация {OrgName}", OrgName); _log.LogInformation("QuickLoginDemoSeeder: создана организация {OrgName}", OrgName);
return demoOrg.Id;
} }
// 2. Tenant references (единицы, магазин, PriceType, роли). private async Task EnsureTenantReferencesAsync(Guid orgId, CancellationToken ct)
// SeedTenantReferencesAsync идемпотентен сам по себе. {
await DevDataSeeder.SeedTenantReferencesAsync(db, demoOrg.Id, ct); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DevDataSeeder.SeedTenantReferencesAsync(db, orgId, ct);
}
// 3. Каталог демо-товаров (50 товаров, 10 контрагентов, ...). private async Task TryFillCatalogAsync(Guid orgId, CancellationToken ct)
// Отдельный DemoTenantSeeder — scoped-сервис, уже зарегистрирован в DI. {
var catalogSeeder = scope.ServiceProvider.GetRequiredService<DemoTenantSeeder>(); // DemoTenantSeeder требует ITenantContext с orgId — при старте без
// HTTP-запроса этот контекст пуст, StockService.ApplyMovementAsync
// падает с «Tenant not set». Поэтому ловим и игнорируем: катало́г —
// nice-to-have, демо-trio юзеров всё равно создастся отдельно.
try try
{ {
var summary = await catalogSeeder.SeedAsync(demoOrg.Id, ct); using var scope = _services.CreateScope();
var catalogSeeder = scope.ServiceProvider.GetRequiredService<DemoTenantSeeder>();
var summary = await catalogSeeder.SeedAsync(orgId, ct);
if (!summary.AlreadySeeded) if (!summary.AlreadySeeded)
{ {
_log.LogInformation("QuickLoginDemoSeeder: каталог демо наполнен — товаров {Products}, продаж {Sales}", _log.LogInformation("QuickLoginDemoSeeder: каталог демо наполнен — товаров {Products}, продаж {Sales}",
@ -115,25 +137,29 @@ private async Task SeedAsync(CancellationToken ct)
} }
catch (Exception ex) catch (Exception ex)
{ {
// Не фатально: демо-данные для стенда — ценные, но не критичные. _log.LogWarning(ex, "QuickLoginDemoSeeder: демо-каталог не наполнился " +
// Если упал seed — всё равно продолжаем создавать юзеров. "(это ОК — создать его можно из UI через POST /api/admin/seed-demo после логина Admin'ом).");
_log.LogWarning(ex, "QuickLoginDemoSeeder: демо-каталог не наполнился"); }
} }
// 4. Трио сотрудников. Пароль у них — обычный Admin12345! для совместимости, private async Task EnsureDemoUsersAsync(Guid orgId, CancellationToken ct)
// но мастер-пароль 123456 всё равно пустит (см. AuthorizationController). {
var adminRole = await db.EmployeeRoles.IgnoreQueryFilters() using var scope = _services.CreateScope();
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Администратор", ct); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cashierRole = await db.EmployeeRoles.IgnoreQueryFilters() var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Кассир", ct);
var keeperRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Кладовщик", ct);
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoAdminEmail, "Демо Админ Магазина", var adminRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Администратор", ct);
var cashierRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Кассир", ct);
var keeperRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Кладовщик", ct);
await EnsureUserAsync(db, userMgr, orgId, DemoAdminEmail, "Демо Админ Магазина",
SystemRoles.Admin, adminRole.Id, "Администратор", ct); SystemRoles.Admin, adminRole.Id, "Администратор", ct);
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoCashierEmail, "Демо Кассир", await EnsureUserAsync(db, userMgr, orgId, DemoCashierEmail, "Демо Кассир",
SystemRoles.Cashier, cashierRole.Id, "Кассир", ct); SystemRoles.Cashier, cashierRole.Id, "Кассир", ct);
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoKeeperEmail, "Демо Кладовщик", await EnsureUserAsync(db, userMgr, orgId, DemoKeeperEmail, "Демо Кладовщик",
SystemRoles.Storekeeper, keeperRole.Id, "Кладовщик", ct); SystemRoles.Storekeeper, keeperRole.Id, "Кладовщик", ct);
} }