food-market/src/food-market.api/Seed/DevDataSeeder.cs
nns 026de9f262 rebrand: безопасность входа + тост 404 на странице входа (по ревью Mac-Claude)
БЕЗОПАСНОСТЬ:
- Страница входа больше НЕ префилит e-mail/пароль dev-админа и не показывает
  подсказку «Dev admin: …» — только в import.meta.env.DEV на localhost
  (на стенде/проде поля пустые).
- DevDataSeeder: пароль SuperAdmin/демо-админа ТОЛЬКО из секрета
  (Seed:SuperAdminPassword / env SEED_SUPERADMIN_PASSWORD). Дефолт «Admin12345!»
  допустим лишь в Development. На stage/prod (ASPNETCORE_ENVIRONMENT=Production)
  без секрета аккаунт с известным паролем НЕ создаётся (warning в лог).
- stage-compose: проброшен Seed__SuperAdminPassword (без дефолта).
- (Пароль существующего SuperAdmin на стенде сменён вручную на сложный и сохранён
  в ~/.fm-watchdog/stage-superadmin-password; quick-login по sentinel/master цел.)

ТОСТ 404:
- StageBanner и QuickLoginBlock пробуют необязательный /api/dev/quick-login/accounts
  через raw fetch (минуя axios-интерсептор) — на проде/без API 404 больше не
  показывается глобальным тостом. На странице входа тостов ошибок нет.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:07:39 +05:00

465 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<AppDbContext>();
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
var roleMgr = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var log = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().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);
}
/// <summary>Идемпотентный back-fill модуля «Деньги» по всем организациям:
/// системной роли «Кассир» гарантирует CashShiftOperate=true (своя смена) и
/// MoneyView=FALSE (модуль «Деньги» кассиру недоступен — требование владельца),
/// и гарантирует 5 системных статей расходов в каждой орге. Нужно для орг,
/// созданных ДО этого изменения (у них кассиру раньше ставился MoneyView=true).</summary>
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);
}
/// <summary>Привязывает существующего admin@food-market.local к
/// Employee-записи с системной ролью «Администратор» — чтобы UI «Сотрудники»
/// сразу показывал учётку с правильной ролью, а не пустой список.</summary>
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);
}
/// <summary>Bootstrap минимально-достаточного набора tenant-сущностей для
/// новой организации: единицы измерения (ОКЕИ), типы цен (Розничная+Оптовая),
/// «Основной склад» MAIN, «Касса 1» POS-1, и системные роли через
/// SeedEmployeeRolesAsync. Идемпотентно: каждый блок проверяет существующие
/// записи. Используется и при первом старте Demo, и при создании org через
/// SuperAdmin UI.</summary>
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);
}
/// <summary>Системные роли (IsSystem=true): только Администратор / Кладовщик /
/// Кассир. Менеджеры/Закупщики/Бухгалтеры и пр. — это кастомные роли,
/// которые создаёт администратор орги сам через UI «Настроить права ролей».
/// Системные нельзя редактировать или удалить из UI; только просмотр.
/// Сидируется один раз per организацию.</summary>
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);
}
/// <summary>Системные статьи расходов для новой организации — пять по
/// умолчанию (из ТЗ модуля «Деньги»). Их можно переименовать, но
/// нельзя удалить (<see cref="ExpenseItem.IsSystem"/>=true), чтобы
/// старые документы не остались с «висящим» FK.</summary>
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);
}
/// <summary>Канонический набор 5 глобальных единиц измерения (Phase5c).
/// Идемпотентно: вставляет только отсутствующие по Code среди globals.
/// На свежем prod-environment где миграция данных не нашла исходных
/// tenant-rows — этот сидер обеспечит наличие минимума.</summary>
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);
}
/// <summary>Включить все active globals для новой организации через
/// junction org_units_of_measure. Идемпотентно: на повторный вызов не
/// добавляет дубликатов.</summary>
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;
}