using foodmarket.Domain.Catalog; using foodmarket.Domain.Money; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace foodmarket.Api.Seed; public class DevDataSeeder : IHostedService { private readonly IServiceProvider _services; private readonly IHostEnvironment _env; public DevDataSeeder(IServiceProvider services, IHostEnvironment env) { _services = services; _env = env; } public async Task StartAsync(CancellationToken ct) { // Idempotent — runs in all envs to bootstrap a usable admin + demo org. // Once first real user/org is set up via UI, rename/disable demo. // (Wired regardless of env so stage/prod first-deploy lands a working // admin, otherwise nobody can log in.) using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var userMgr = scope.ServiceProvider.GetRequiredService>(); var roleMgr = scope.ServiceProvider.GetRequiredService>(); var cfg = scope.ServiceProvider.GetRequiredService(); var log = scope.ServiceProvider.GetRequiredService().CreateLogger("DevDataSeeder"); // Пароль сидируемых аккаунтов — ТОЛЬКО из секрета/переменной окружения. // Дефолт «Admin12345!» допустим исключительно в Development (локальная разработка). // На stage/prod без секрета аккаунт с известным паролем НЕ создаётся. string? seedPassword = cfg["Seed:SuperAdminPassword"] ?? Environment.GetEnvironmentVariable("SEED_SUPERADMIN_PASSWORD"); if (string.IsNullOrWhiteSpace(seedPassword) && _env.IsDevelopment()) seedPassword = "Admin12345!"; foreach (var role in new[] { SystemRoles.SuperAdmin, SystemRoles.Admin, SystemRoles.Cashier, SystemRoles.Storekeeper }) { if (!await roleMgr.RoleExistsAsync(role)) { await roleMgr.CreateAsync(new Role { Name = role }); } } // Глобальные единицы измерения (Phase5c). Идемпотентно: если миграция // уже их создала, ничего не происходит. Нужно для случая, когда // развернули с нуля без миграции данных. await SeedGlobalUnitsAsync(db, ct); var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct); if (demoOrg is null) { demoOrg = new Organization { Name = "Demo Market", CountryCode = "KZ", Bin = "000000000000", Address = "Алматы, ул. Пример 1", Phone = "+7 (777) 000-00-00", Email = "demo@food-market.local", DefaultCurrencyId = kzt?.Id, }; db.Organizations.Add(demoOrg); await db.SaveChangesAsync(ct); } else if (demoOrg.DefaultCurrencyId is null && kzt is not null) { demoOrg.DefaultCurrencyId = kzt.Id; await db.SaveChangesAsync(ct); } await SeedTenantReferencesAsync(db, demoOrg.Id, ct); // SuperAdmin платформы — без OrganizationId, видит все организации const string superAdminEmail = "admin@food-market.local"; var superAdmin = await userMgr.FindByEmailAsync(superAdminEmail); if (superAdmin is null) { if (string.IsNullOrWhiteSpace(seedPassword)) { log.LogWarning("SuperAdmin ({Email}) не сидируется: задайте секрет Seed:SuperAdminPassword " + "(или переменную окружения SEED_SUPERADMIN_PASSWORD). Дефолтный пароль разрешён только в Development.", superAdminEmail); superAdmin = null; } else { superAdmin = new User { UserName = superAdminEmail, Email = superAdminEmail, EmailConfirmed = true, FullName = "Platform SuperAdmin", OrganizationId = null, }; var result = await userMgr.CreateAsync(superAdmin, seedPassword); if (result.Succeeded) await userMgr.AddToRoleAsync(superAdmin, SystemRoles.SuperAdmin); else superAdmin = null; } } else { // Исправляем старую БД: убираем OrganizationId и пересаживаем на SuperAdmin if (superAdmin.OrganizationId is not null) { superAdmin.OrganizationId = null; await userMgr.UpdateAsync(superAdmin); } var roles = await userMgr.GetRolesAsync(superAdmin); if (!roles.Contains(SystemRoles.SuperAdmin)) { await userMgr.RemoveFromRolesAsync(superAdmin, roles); await userMgr.AddToRoleAsync(superAdmin, SystemRoles.SuperAdmin); } } // Admin демо-организации — владелец/директор Demo Market const string demoAdminEmail = "admin@demo-market.local"; var demoAdmin = await userMgr.FindByEmailAsync(demoAdminEmail); if (demoAdmin is null && !string.IsNullOrWhiteSpace(seedPassword)) { demoAdmin = new User { UserName = demoAdminEmail, Email = demoAdminEmail, EmailConfirmed = true, FullName = "Demo Market Admin", OrganizationId = demoOrg.Id, }; var result = await userMgr.CreateAsync(demoAdmin, seedPassword); if (result.Succeeded) await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin); } else if (demoAdmin is not null) { if (!await userMgr.IsInRoleAsync(demoAdmin, SystemRoles.Admin)) await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin); } await SeedAdminEmployeeAsync(db, demoOrg.Id, superAdmin?.Id, ct); // Глобальные SystemSettings — single-row. Сидируем дефолт 30 дней // retention если ещё нет записи. var anySettings = await db.SystemSettings.AnyAsync(ct); if (!anySettings) { db.SystemSettings.Add(new SystemSettings { ArchiveRetentionDays = 30 }); await db.SaveChangesAsync(ct); } // Sprint 33: бэкофилл дефолтов модуля «Деньги» для УЖЕ существующих // организаций (их роли/статьи были засижены до появления модуля — // SeedEmployeeRolesAsync их пропускает по guard'у `anyRole`). Без этого // на stage/prod кассир демо-орги получил бы 403 на /api/money/shifts. await EnsureMoneyDefaultsAsync(db, ct); } /// Идемпотентный back-fill модуля «Деньги» по всем организациям: /// системной роли «Кассир» гарантирует CashShiftOperate=true (своя смена) и /// MoneyView=FALSE (модуль «Деньги» кассиру недоступен — требование владельца), /// и гарантирует 5 системных статей расходов в каждой орге. Нужно для орг, /// созданных ДО этого изменения (у них кассиру раньше ставился MoneyView=true). private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, CancellationToken ct) { var cashierRoles = await db.EmployeeRoles.IgnoreQueryFilters() .Where(r => r.IsSystem && r.Name == "Кассир") .ToListAsync(ct); var changed = false; foreach (var role in cashierRoles) { if (role.Permissions.MoneyView || !role.Permissions.CashShiftOperate) { // Permissions — owned-тип; мутация скалярных флагов на // отслеживаемой сущности ловится DetectChanges при SaveChanges. // Остальные права кассира (ProductsView/RetailSalesOperate/…) // сохраняются — мы их не трогаем. role.Permissions.MoneyView = false; role.Permissions.CashShiftOperate = true; changed = true; } } if (changed) await db.SaveChangesAsync(ct); // Back-fill прав кладовщика: системной роли «Кладовщик» нужны права на // инвентаризацию/оприходование/списание (веб их показывает, API должен // совпадать). Старые орги их не имели → ловили 403. var keeperRoles = await db.EmployeeRoles.IgnoreQueryFilters() .Where(r => r.IsSystem && r.Name == "Кладовщик") .ToListAsync(ct); var keeperChanged = false; foreach (var role in keeperRoles) { if (!role.Permissions.InventoryEdit || !role.Permissions.EnterEdit || !role.Permissions.LossEdit) { role.Permissions.InventoryEdit = true; role.Permissions.EnterEdit = true; role.Permissions.LossEdit = true; keeperChanged = true; } } if (keeperChanged) await db.SaveChangesAsync(ct); var orgIds = await db.Organizations.IgnoreQueryFilters().Select(o => o.Id).ToListAsync(ct); foreach (var orgId in orgIds) await SeedExpenseItemsAsync(db, orgId, ct); } /// Привязывает существующего admin@food-market.local к /// Employee-записи с системной ролью «Администратор» — чтобы UI «Сотрудники» /// сразу показывал учётку с правильной ролью, а не пустой список. private static async Task SeedAdminEmployeeAsync(AppDbContext db, Guid orgId, Guid? adminUserId, CancellationToken ct) { if (adminUserId is null) return; var existing = await db.Employees.IgnoreQueryFilters() .FirstOrDefaultAsync(e => e.OrganizationId == orgId && e.UserId == adminUserId, ct); if (existing is not null) return; var adminRole = await db.EmployeeRoles.IgnoreQueryFilters() .FirstOrDefaultAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Администратор", ct); if (adminRole is null) return; db.Employees.Add(new Employee { OrganizationId = orgId, UserId = adminUserId, LastName = "Admin", FirstName = "System", Position = "Администратор", Email = "admin@food-market.local", RoleId = adminRole.Id, IsActive = true, }); await db.SaveChangesAsync(ct); } /// Bootstrap минимально-достаточного набора tenant-сущностей для /// новой организации: единицы измерения (ОКЕИ), типы цен (Розничная+Оптовая), /// «Основной склад» MAIN, «Касса 1» POS-1, и системные роли через /// SeedEmployeeRolesAsync. Идемпотентно: каждый блок проверяет существующие /// записи. Используется и при первом старте Demo, и при создании org через /// SuperAdmin UI. public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct) { // Phase5c: единицы измерения теперь global. Подключаем к новой // организации все active globals через junction org_units_of_measure. await EnableAllActiveUnitsForOrgAsync(db, orgId, ct); // Phase5e: системная группа товаров «Все товары». Любой новый продукт, // если юзер не указал группу явно, должен иметь хоть какую-то — без // этой группы ProductsController.Create падал с 400 на новой орге. var hasSystemGroup = await db.ProductGroups.IgnoreQueryFilters() .AnyAsync(g => g.OrganizationId == orgId && g.IsSystem, ct); if (!hasSystemGroup) { db.ProductGroups.Add(new ProductGroup { OrganizationId = orgId, Name = "Все товары", Path = "Все товары", SortOrder = 0, IsSystem = true, }); } // Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи. // Если есть — никогда не создаём «системную копию», корректность IsSystem // обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту: // запись с максимумом ProductPrice). var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct); if (!anyPriceType) { db.PriceTypes.AddRange( new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsRetail = true, SortOrder = 0 }, new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 } ); } var mainStore = await db.Stores.IgnoreQueryFilters().FirstOrDefaultAsync(s => s.OrganizationId == orgId && s.IsMain, ct); if (mainStore is null) { mainStore = new Store { OrganizationId = orgId, Name = "Основной склад", Code = "MAIN", IsMain = true, Address = "Алматы, ул. Пример 1", }; db.Stores.Add(mainStore); await db.SaveChangesAsync(ct); } var anyRetail = await db.RetailPoints.IgnoreQueryFilters().AnyAsync(r => r.OrganizationId == orgId, ct); if (!anyRetail) { db.RetailPoints.Add(new RetailPoint { OrganizationId = orgId, Name = "Касса 1", Code = "POS-1", StoreId = mainStore.Id, }); } await db.SaveChangesAsync(ct); await SeedEmployeeRolesAsync(db, orgId, ct); } /// Системные роли (IsSystem=true): только Администратор / Кладовщик / /// Кассир. Менеджеры/Закупщики/Бухгалтеры и пр. — это кастомные роли, /// которые создаёт администратор орги сам через UI «Настроить права ролей». /// Системные нельзя редактировать или удалить из UI; только просмотр. /// Сидируется один раз per организацию. private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, CancellationToken ct) { var anyRole = await db.EmployeeRoles.IgnoreQueryFilters().AnyAsync(r => r.OrganizationId == orgId, ct); if (anyRole) return; var admin = new EmployeeRole { OrganizationId = orgId, Name = "Администратор", Description = "Полный доступ ко всем разделам организации", IsSystem = true, SortOrder = 0, Permissions = RolePermissions.All(), }; var keeper = new EmployeeRole { OrganizationId = orgId, Name = "Кладовщик", Description = "Приёмки, инвентаризация, остатки", IsSystem = true, SortOrder = 10, Permissions = new RolePermissions { ProductsView = true, SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, StocksView = true, // Складская работа кладовщика: инвентаризация, оприходование, // списание (веб уже показывает эти пункты — API должен совпадать). InventoryEdit = true, EnterEdit = true, LossEdit = true, // Кладовщику деньги не положены — только видимость оплаченности // приёмки через SuppliesView (payedSum в DTO). MoneyView=false. }, }; var cashier = new EmployeeRole { OrganizationId = orgId, Name = "Кассир", Description = "Только работа на кассе. Без доступа к веб-админке.", IsSystem = true, SortOrder = 20, Permissions = new RolePermissions { ProductsView = true, StocksView = true, RetailSalesOperate = true, // RetailSalesRefund по умолчанию false — админ включит при необходимости // Кассиру нужна возможность открыть/закрыть свою смену и // вносить/выплачивать деньги в ней. Чужие смены и движения // по банковским счетам ему недоступны (контроллер фильтрует). CashShiftOperate = true, // Кассир НЕ видит модуль «Деньги» (счета/платежи/долги/чужие // смены) — требование владельца «кассир не видит денег». Свою // смену и Z-отчёт он видит через CashShiftOperate (эндпоинты // shifts List/Get/Report принимают его право по ИЛИ-семантике). MoneyView = false, }, }; db.EmployeeRoles.AddRange(admin, keeper, cashier); await db.SaveChangesAsync(ct); await SeedExpenseItemsAsync(db, orgId, ct); } /// Системные статьи расходов для новой организации — пять по /// умолчанию (из ТЗ модуля «Деньги»). Их можно переименовать, но /// нельзя удалить (=true), чтобы /// старые документы не остались с «висящим» FK. private static async Task SeedExpenseItemsAsync(AppDbContext db, Guid orgId, CancellationToken ct) { var anyItem = await db.ExpenseItems.IgnoreQueryFilters() .AnyAsync(e => e.OrganizationId == orgId, ct); if (anyItem) return; var items = new[] { new ExpenseItem { OrganizationId = orgId, Name = "Закупка товара", Code = "PURCHASE", IsSystem = true, IsActive = true }, new ExpenseItem { OrganizationId = orgId, Name = "Аренда", Code = "RENT", IsSystem = true, IsActive = true }, new ExpenseItem { OrganizationId = orgId, Name = "Зарплата", Code = "SALARY", IsSystem = true, IsActive = true }, new ExpenseItem { OrganizationId = orgId, Name = "Коммунальные", Code = "UTILITIES", IsSystem = true, IsActive = true }, new ExpenseItem { OrganizationId = orgId, Name = "Прочее", Code = "OTHER", IsSystem = true, IsActive = true }, }; db.ExpenseItems.AddRange(items); await db.SaveChangesAsync(ct); } /// Канонический набор 5 глобальных единиц измерения (Phase5c). /// Идемпотентно: вставляет только отсутствующие по Code среди globals. /// На свежем prod-environment где миграция данных не нашла исходных /// tenant-rows — этот сидер обеспечит наличие минимума. private static async Task SeedGlobalUnitsAsync(AppDbContext db, CancellationToken ct) { var canonical = new (string Code, string Name)[] { ("796", "штука"), ("166", "килограмм"), ("112", "литр"), ("006", "метр"), ("625", "упаковка"), }; var existingCodes = await db.UnitsOfMeasure .IgnoreQueryFilters() .Where(u => u.OrganizationId == null) .Select(u => u.Code) .ToListAsync(ct); foreach (var (code, name) in canonical) { if (!existingCodes.Contains(code)) { db.UnitsOfMeasure.Add(new UnitOfMeasure { OrganizationId = null, Code = code, Name = name, IsActive = true }); } } await db.SaveChangesAsync(ct); } /// Включить все active globals для новой организации через /// junction org_units_of_measure. Идемпотентно: на повторный вызов не /// добавляет дубликатов. private static async Task EnableAllActiveUnitsForOrgAsync(AppDbContext db, Guid orgId, CancellationToken ct) { var globals = await db.UnitsOfMeasure .IgnoreQueryFilters() .Where(u => u.OrganizationId == null && u.IsActive) .Select(u => u.Id) .ToListAsync(ct); var alreadyEnabled = await db.OrgUnitsOfMeasure .IgnoreQueryFilters() .Where(j => j.OrganizationId == orgId) .Select(j => j.UnitOfMeasureId) .ToListAsync(ct); foreach (var unitId in globals.Except(alreadyEnabled)) { db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId, UnitOfMeasureId = unitId }); } await db.SaveChangesAsync(ct); } public Task StopAsync(CancellationToken ct) => Task.CompletedTask; }