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,43 +70,65 @@ public async Task StartAsync(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();
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()
.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);
if (demoOrg is null)
demoOrg = new Organization
{
demoOrg = new Organization
{
Name = OrgName,
CountryCode = "KZ",
Bin = "000000000001",
Address = "Алматы, ул. Демо-стенд 1",
Phone = "+7 (727) 000-00-01",
Email = "demo@food-market.local",
DefaultCurrencyId = kzt?.Id,
};
db.Organizations.Add(demoOrg);
await db.SaveChangesAsync(ct);
_log.LogInformation("QuickLoginDemoSeeder: создана организация {OrgName}", OrgName);
}
Name = OrgName,
CountryCode = "KZ",
Bin = "000000000001",
Address = "Алматы, ул. Демо-стенд 1",
Phone = "+7 (727) 000-00-01",
Email = "demo@food-market.local",
DefaultCurrencyId = kzt?.Id,
};
db.Organizations.Add(demoOrg);
await db.SaveChangesAsync(ct);
_log.LogInformation("QuickLoginDemoSeeder: создана организация {OrgName}", OrgName);
return demoOrg.Id;
}
// 2. Tenant references (единицы, магазин, PriceType, роли).
// SeedTenantReferencesAsync идемпотентен сам по себе.
await DevDataSeeder.SeedTenantReferencesAsync(db, demoOrg.Id, ct);
private async Task EnsureTenantReferencesAsync(Guid orgId, CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DevDataSeeder.SeedTenantReferencesAsync(db, orgId, ct);
}
// 3. Каталог демо-товаров (50 товаров, 10 контрагентов, ...).
// Отдельный DemoTenantSeeder — scoped-сервис, уже зарегистрирован в DI.
var catalogSeeder = scope.ServiceProvider.GetRequiredService<DemoTenantSeeder>();
private async Task TryFillCatalogAsync(Guid orgId, CancellationToken ct)
{
// DemoTenantSeeder требует ITenantContext с orgId — при старте без
// HTTP-запроса этот контекст пуст, StockService.ApplyMovementAsync
// падает с «Tenant not set». Поэтому ловим и игнорируем: катало́г —
// nice-to-have, демо-trio юзеров всё равно создастся отдельно.
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)
{
_log.LogInformation("QuickLoginDemoSeeder: каталог демо наполнен — товаров {Products}, продаж {Sales}",
@ -115,25 +137,29 @@ private async Task SeedAsync(CancellationToken ct)
}
catch (Exception ex)
{
// Не фатально: демо-данные для стенда — ценные, но не критичные.
// Если упал seed — всё равно продолжаем создавать юзеров.
_log.LogWarning(ex, "QuickLoginDemoSeeder: демо-каталог не наполнился");
_log.LogWarning(ex, "QuickLoginDemoSeeder: демо-каталог не наполнился " +
"(это ОК — создать его можно из UI через POST /api/admin/seed-demo после логина Admin'ом).");
}
}
private async Task EnsureDemoUsersAsync(Guid orgId, CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
// 4. Трио сотрудников. Пароль у них — обычный Admin12345! для совместимости,
// но мастер-пароль 123456 всё равно пустит (см. AuthorizationController).
var adminRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Администратор", ct);
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Администратор", ct);
var cashierRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Кассир", ct);
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Кассир", ct);
var keeperRole = await db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Кладовщик", ct);
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Кладовщик", ct);
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoAdminEmail, "Демо Админ Магазина",
await EnsureUserAsync(db, userMgr, orgId, DemoAdminEmail, "Демо Админ Магазина",
SystemRoles.Admin, adminRole.Id, "Администратор", ct);
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoCashierEmail, "Демо Кассир",
await EnsureUserAsync(db, userMgr, orgId, DemoCashierEmail, "Демо Кассир",
SystemRoles.Cashier, cashierRole.Id, "Кассир", ct);
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoKeeperEmail, "Демо Кладовщик",
await EnsureUserAsync(db, userMgr, orgId, DemoKeeperEmail, "Демо Кладовщик",
SystemRoles.Storekeeper, keeperRole.Id, "Кладовщик", ct);
}