feat(s30): QuickLoginDemoSeeder — организация + Admin/Cashier/Keeper (шаг 4/9)

Hosted-сервис, который на старте, когда EffectivelyEnabled=true, создаёт:
- организацию «Демо-магазин» (отдельная от «Demo Market» DevDataSeeder'а,
  чтобы не ломать e2e, завязанные на DemoMarket);
- tenant references через существующий SeedTenantReferencesAsync (единицы,
  магазин, PriceType, системные роли);
- каталог демо-товаров через DemoTenantSeeder (50 товаров + 30 продаж +
  приёмки/контрагенты) — идемпотентно, маркер DEMO-*;
- три учётки с ролями Admin/Cashier/Storekeeper:
    demo-admin@food-market.local
    demo-cashier@food-market.local
    demo-keeper@food-market.local
  (пароль Admin12345!, но мастер-пароль 123456 тоже подойдёт — через
  QuickLoginOptions в AuthorizationController).

На проде (EffectivelyEnabled=false) сервис тихо выходит в StartAsync —
ничего не трогает, ничего не логирует.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 15:40:28 +05:00
parent ed5089bac3
commit 35f633e206
3 changed files with 183 additions and 1 deletions

View file

@ -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.

View file

@ -451,6 +451,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddHostedService<DevDataSeeder>();
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
// Sprint 30: демо-trio «Демо-магазина» для quick-login — только когда флаг
// эффективно включён. Внутри сам чекает EffectivelyEnabled().
builder.Services.AddHostedService<foodmarket.Api.Seed.QuickLoginDemoSeeder>();
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();

View file

@ -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;
/// <summary>Поднимает «Демо-магазин» и трёх демо-сотрудников (Admin / Cashier /
/// Storekeeper) ПРИ СТАРТЕ, только когда quick-login эффективно включён
/// (<see cref="QuickLoginOptions.EffectivelyEnabled"/>). Это тот фикстурный
/// набор аккаунтов, который попадает первым в <c>/api/dev/quick-login/accounts</c>
/// под IsDemo=true.
///
/// Идемпотентно: каждая сущность создаётся через проверку по имени/email,
/// плюс полный demo-каталог поднимается через существующий
/// <see cref="DemoTenantSeeder"/> (маркер DEMO-* защищает от повторных вставок).
///
/// Если флаг выключен — Hosted-сервис не делает ничего (включая запись
/// в лог), чтобы на проде просто не запускаться.</summary>
public sealed class QuickLoginDemoSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IConfiguration _cfg;
private readonly QuickLoginOptions _opts;
private readonly ILogger<QuickLoginDemoSeeder> _log;
public QuickLoginDemoSeeder(
IServiceProvider services,
IConfiguration cfg,
IOptions<QuickLoginOptions> opts,
ILogger<QuickLoginDemoSeeder> 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<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);
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<DemoTenantSeeder>();
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<User> 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;
}