diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md index 13c8959..c1a88dc 100644 --- a/docs/quick-login-progress.md +++ b/docs/quick-login-progress.md @@ -24,7 +24,7 @@ quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*, a11y-*, mob1*-*, isoB*-*, axdet-*, smoke-*, ratetest-*, perf-*, probe-*, load-*, s19-test-* и всё с суффиксом из ≥10 цифр) — скрыты. Лимит 50 + `?q=`. -- [ ] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» (через +- [x] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» (через существующий `DemoTenantSeeder`) + сотрудники с ролями Admin / Cashier / Storekeeper (`demo-admin@food-market.local`, `demo-cashier@...`, `demo-keeper@...`). Запускается только при Enabled=true. diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index afe63b7..fc68b96 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -451,6 +451,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddHostedService(); // Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo). builder.Services.AddScoped(); + // Sprint 30: демо-trio «Демо-магазина» для quick-login — только когда флаг + // эффективно включён. Внутри сам чекает EffectivelyEnabled(). + builder.Services.AddHostedService(); // Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1). builder.Services.AddScoped(); builder.Services.AddHostedService(); diff --git a/src/food-market.api/Seed/QuickLoginDemoSeeder.cs b/src/food-market.api/Seed/QuickLoginDemoSeeder.cs new file mode 100644 index 0000000..de4e769 --- /dev/null +++ b/src/food-market.api/Seed/QuickLoginDemoSeeder.cs @@ -0,0 +1,179 @@ +using foodmarket.Api.Infrastructure; +using foodmarket.Domain.Organizations; +using foodmarket.Infrastructure.Identity; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace foodmarket.Api.Seed; + +/// Поднимает «Демо-магазин» и трёх демо-сотрудников (Admin / Cashier / +/// Storekeeper) ПРИ СТАРТЕ, только когда quick-login эффективно включён +/// (). Это тот фикстурный +/// набор аккаунтов, который попадает первым в /api/dev/quick-login/accounts +/// под IsDemo=true. +/// +/// Идемпотентно: каждая сущность создаётся через проверку по имени/email, +/// плюс полный demo-каталог поднимается через существующий +/// (маркер DEMO-* защищает от повторных вставок). +/// +/// Если флаг выключен — Hosted-сервис не делает ничего (включая запись +/// в лог), чтобы на проде просто не запускаться. +public sealed class QuickLoginDemoSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IConfiguration _cfg; + private readonly QuickLoginOptions _opts; + private readonly ILogger _log; + + public QuickLoginDemoSeeder( + IServiceProvider services, + IConfiguration cfg, + IOptions opts, + ILogger log) + { + _services = services; + _cfg = cfg; + _opts = opts.Value; + _log = log; + } + + private const string OrgName = "Демо-магазин"; + private const string DemoAdminEmail = "demo-admin@food-market.local"; + private const string DemoCashierEmail = "demo-cashier@food-market.local"; + private const string DemoKeeperEmail = "demo-keeper@food-market.local"; + + public async Task StartAsync(CancellationToken ct) + { + var issuer = _cfg["OpenIddict:Issuer"]; + if (!_opts.EffectivelyEnabled(issuer, out _)) + { + // На проде вообще не запускаемся. В dev без Enabled=true — тоже + // не создаём демо-набор, чтобы не отравлять дефолтный localhost. + return; + } + + 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); + var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); + if (demoOrg is null) + { + 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); + } + + // 2. Tenant references (единицы, магазин, PriceType, роли). + // SeedTenantReferencesAsync идемпотентен сам по себе. + await DevDataSeeder.SeedTenantReferencesAsync(db, demoOrg.Id, ct); + + // 3. Каталог демо-товаров (50 товаров, 10 контрагентов, ...). + // Отдельный DemoTenantSeeder — scoped-сервис, уже зарегистрирован в DI. + var catalogSeeder = scope.ServiceProvider.GetRequiredService(); + try + { + var summary = await catalogSeeder.SeedAsync(demoOrg.Id, ct); + if (!summary.AlreadySeeded) + { + _log.LogInformation("QuickLoginDemoSeeder: каталог демо наполнен — товаров {Products}, продаж {Sales}", + summary.Products, summary.Sales); + } + } + catch (Exception ex) + { + // Не фатально: демо-данные для стенда — ценные, но не критичные. + // Если упал seed — всё равно продолжаем создавать юзеров. + _log.LogWarning(ex, "QuickLoginDemoSeeder: демо-каталог не наполнился"); + } + + // 4. Трио сотрудников. Пароль у них — обычный Admin12345! для совместимости, + // но мастер-пароль 123456 всё равно пустит (см. AuthorizationController). + var adminRole = await db.EmployeeRoles.IgnoreQueryFilters() + .FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Администратор", ct); + var cashierRole = await db.EmployeeRoles.IgnoreQueryFilters() + .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, "Демо Админ Магазина", + SystemRoles.Admin, adminRole.Id, "Администратор", ct); + await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoCashierEmail, "Демо Кассир", + SystemRoles.Cashier, cashierRole.Id, "Кассир", ct); + await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoKeeperEmail, "Демо Кладовщик", + SystemRoles.Storekeeper, keeperRole.Id, "Кладовщик", ct); + } + + private static async Task EnsureUserAsync( + AppDbContext db, UserManager userMgr, + Guid orgId, string email, string fullName, + string identityRole, Guid employeeRoleId, string position, + CancellationToken ct) + { + var user = await userMgr.FindByEmailAsync(email); + if (user is null) + { + user = new User + { + UserName = email, + Email = email, + EmailConfirmed = true, + FullName = fullName, + OrganizationId = orgId, + IsActive = true, + }; + var r = await userMgr.CreateAsync(user, "Admin12345!"); + if (!r.Succeeded) return; + await userMgr.AddToRoleAsync(user, identityRole); + } + else + { + // Поддерживаем IsActive=true, OrganizationId, роль — чтобы если + // кто-то их вручную потрогал, при рестарте всё вернулось в норму. + var dirty = false; + if (!user.IsActive) { user.IsActive = true; dirty = true; } + if (user.OrganizationId != orgId) { user.OrganizationId = orgId; dirty = true; } + if (dirty) await userMgr.UpdateAsync(user); + + var roles = await userMgr.GetRolesAsync(user); + if (!roles.Contains(identityRole)) + await userMgr.AddToRoleAsync(user, identityRole); + } + + var hasEmployee = await db.Employees.IgnoreQueryFilters() + .AnyAsync(e => e.OrganizationId == orgId && e.UserId == user.Id, ct); + if (!hasEmployee) + { + db.Employees.Add(new Employee + { + OrganizationId = orgId, + UserId = user.Id, + LastName = fullName.Split(' ').Last(), + FirstName = fullName.Split(' ').First(), + Position = position, + Email = email, + RoleId = employeeRoleId, + IsActive = true, + }); + await db.SaveChangesAsync(ct); + } + } + + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; +}