food-market/src/food-market.api/Seed/DevDataSeeder.cs
nurdotnet f61d8bc178
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m8s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m8s
Docker API / Deploy API on stage (push) Successful in 18s
fix(auth): SuperAdmin платформы без OrganizationId + отдельный Admin для Demo Market
admin@food-market.local → SuperAdmin (OrganizationId=null, видит все орги)
admin@demo-market.local → Admin Demo Market (новый, для тестов орг-уровня)
Idempotent-фикс для существующих БД: исправляет роль и чистит OrganizationId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:18:21 +05:00

319 lines
14 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);
// SuperAdmin платформы — без OrganizationId, видит все организации
const string superAdminEmail = "admin@food-market.local";
var superAdmin = await userMgr.FindByEmailAsync(superAdminEmail);
if (superAdmin is null)
{
superAdmin = new User
{
UserName = superAdminEmail,
Email = superAdminEmail,
EmailConfirmed = true,
FullName = "Platform SuperAdmin",
OrganizationId = null,
};
var result = await userMgr.CreateAsync(superAdmin, "Admin12345!");
if (result.Succeeded)
await userMgr.AddToRoleAsync(superAdmin, SystemRoles.SuperAdmin);
}
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)
{
demoAdmin = new User
{
UserName = demoAdminEmail,
Email = demoAdminEmail,
EmailConfirmed = true,
FullName = "Demo Market Admin",
OrganizationId = demoOrg.Id,
};
var result = await userMgr.CreateAsync(demoAdmin, "Admin12345!");
if (result.Succeeded)
await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin);
}
else
{
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);
}
}
/// <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)
{
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;
}