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:
parent
e3a8db3206
commit
f817abc8c7
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue