food-market/src/food-market.api/Seed/DevDataSeeder.cs
nns 1d9fd7297c fix(roles): keep only Admin + Cashier as system, demote others to custom + migration
После ревью UX оказалось что 6 системных ролей — перебор. Перешли на
схему «два системных + остальные шаблоны»:

- Администратор (IsSystem=true) — RolePermissions.All().
- Кассир (IsSystem=true) — POS-only набор:
  ProductsView + StocksView + RetailSalesOperate. Без RetailSalesRefund
  (админ включит при необходимости). Это маркер для будущего POS-app —
  не имеет доступа к веб-админке.
- Менеджер / Кладовщик / Закупщик / Бухгалтер — IsSystem=false
  (кастомные). Можно удалить если не нужны или подкрутить под себя.

Сидер на чистой БД сразу создаёт роли в правильных статусах. Для
существующих установок миграция Phase4b_RolesSimplify идемпотентно
делает UPDATE: демоутит лишние и приводит permissions Кассира к
правильному набору. Down() — no-op (юзер мог переименовать).

На стенде sql применил вручную + записал в __EFMigrationsHistory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:41:13 +05:00

267 lines
12 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.Organizations;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
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>>();
foreach (var role in new[] { SystemRoles.SuperAdmin, SystemRoles.Admin, SystemRoles.Manager, SystemRoles.Cashier, SystemRoles.Storekeeper })
{
if (!await roleMgr.RoleExistsAsync(role))
{
await roleMgr.CreateAsync(new Role { Name = role });
}
}
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);
const string adminEmail = "admin@food-market.local";
var admin = await userMgr.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new User
{
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true,
FullName = "System Admin",
OrganizationId = demoOrg.Id,
};
var result = await userMgr.CreateAsync(admin, "Admin12345!");
if (result.Succeeded)
{
await userMgr.AddToRoleAsync(admin, SystemRoles.Admin);
}
}
await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, 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);
}
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
if (!anyUnit)
{
db.UnitsOfMeasure.AddRange(
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
);
}
// Сидируем 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): Администратор / Менеджер /
/// Кладовщик / Кассир / Закупщик / Бухгалтер. Сидируется один раз
/// 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(),
};
// Менеджер/Кладовщик/Закупщик/Бухгалтер — кастомные шаблоны (IsSystem=false),
// юзер может удалить или подкрутить под себя. Системные только Администратор + Кассир.
var manager = new EmployeeRole
{
OrganizationId = orgId,
Name = "Менеджер",
Description = "Управление каталогом, документами и контрагентами",
IsSystem = false, SortOrder = 10,
Permissions = new RolePermissions
{
ProductsView = true, ProductsEdit = true, ProductGroupsManage = true, PriceTypesManage = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
CounterpartiesView = true, CounterpartiesEdit = true,
ReportsView = true, StocksView = true,
},
};
var keeper = new EmployeeRole
{
OrganizationId = orgId,
Name = "Кладовщик",
Description = "Приёмки, инвентаризация, остатки",
IsSystem = false, SortOrder = 20,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
StocksView = true,
},
};
var cashier = new EmployeeRole
{
OrganizationId = orgId,
Name = "Кассир",
Description = "Только работа на кассе. Без доступа к веб-админке.",
IsSystem = true, SortOrder = 30,
Permissions = new RolePermissions
{
ProductsView = true,
StocksView = true,
RetailSalesOperate = true,
// RetailSalesRefund по умолчанию false — админ включит при необходимости
},
};
var buyer = new EmployeeRole
{
OrganizationId = orgId,
Name = "Закупщик",
Description = "Заказы поставщикам и приёмка товара",
IsSystem = false, SortOrder = 40,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true, SuppliesEdit = true,
CounterpartiesView = true, CounterpartiesEdit = true,
},
};
var accountant = new EmployeeRole
{
OrganizationId = orgId,
Name = "Бухгалтер",
Description = "Просмотр всех данных и отчётов, без редактирования",
IsSystem = false, SortOrder = 50,
Permissions = new RolePermissions
{
ProductsView = true,
SuppliesView = true,
CounterpartiesView = true,
ReportsView = true, StocksView = true,
},
};
db.EmployeeRoles.AddRange(admin, manager, keeper, cashier, buyer, accountant);
await db.SaveChangesAsync(ct);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}