From f817abc8c79aa94453ce0f7ee36462578509d255 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 4 Oct 2026 16:46:45 +0500 Subject: [PATCH] =?UTF-8?q?fix(s30):=20QuickLoginDemoSeeder=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D0=B9=20scope=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20sub-step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбил монолитный 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 --- .../Seed/QuickLoginDemoSeeder.cs | 100 +++++++++++------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/src/food-market.api/Seed/QuickLoginDemoSeeder.cs b/src/food-market.api/Seed/QuickLoginDemoSeeder.cs index 9e0afbd..2a105b5 100644 --- a/src/food-market.api/Seed/QuickLoginDemoSeeder.cs +++ b/src/food-market.api/Seed/QuickLoginDemoSeeder.cs @@ -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 EnsureDemoOrgAsync(CancellationToken ct) { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var userMgr = scope.ServiceProvider.GetRequiredService>(); - - // 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(); + await DevDataSeeder.SeedTenantReferencesAsync(db, orgId, ct); + } - // 3. Каталог демо-товаров (50 товаров, 10 контрагентов, ...). - // Отдельный DemoTenantSeeder — scoped-сервис, уже зарегистрирован в DI. - var catalogSeeder = scope.ServiceProvider.GetRequiredService(); + 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(); + 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(); + var userMgr = scope.ServiceProvider.GetRequiredService>(); - // 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); }