БЕЗОПАСНОСТЬ: - Страница входа больше НЕ префилит 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>
465 lines
23 KiB
C#
465 lines
23 KiB
C#
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;
|
||
}
|