feat(domain): Employee, EmployeeRole, RolePermissions entities + migration

Базовый каркас модуля «Сотрудники и Роли» (по образу сторонняя система):

Domain:
- Employee — сотрудник организации (UserId nullable: запись может
  существовать без логина), ФИО + Position + Email/Phone + Role + IsActive
  + FiredAt + RetailPointAssignments.
- EmployeeRole — роль с IsSystem флагом и owned RolePermissions.
- RolePermissions — 21 булев флаг по группам (Каталог/Закупки/Продажи/
  Контрагенты/Отчёты/Настройки) + helper All() для админа.
- EmployeeRetailPointAssignment — ассоциация сотрудника с RetailPoint
  (для роли Кассир — к каким кассам привязан).

Infrastructure:
- OrganizationsHrConfigurations с OwnsOne(...).ToJson("permissions")
  для permissions — JSONB-колонка вместо отдельной таблицы.
- DbSet<EmployeeRole/Employee/EmployeeRetailPointAssignment>.
- Уникальные индексы: (OrgId, RoleName), (OrgId, UserId) с filter
  WHERE UserId IS NOT NULL, (EmployeeId, RetailPointId).

Migration Phase4_EmployeesAndRoles создаёт три таблицы. Сидер
системных ролей и привязка существующего admin'а к Employee —
следующим коммитом, контроллеры и UI — далее.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 12:00:30 +05:00
parent cec76ecaaf
commit f38d34f42d
8 changed files with 2391 additions and 0 deletions

View file

@ -0,0 +1,41 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Сотрудник организации. Может быть привязан к учётной записи
/// (UserId), а может существовать без логина (например, кассир, которого
/// добавили в HR, но логин ещё не выдали).</summary>
public class Employee : TenantEntity
{
/// <summary>FK на Identity-юзера. Заполняется когда сотруднику выдан логин.
/// Null — запись без учётки.</summary>
public Guid? UserId { get; set; }
public string LastName { get; set; } = "";
public string FirstName { get; set; } = "";
public string? MiddleName { get; set; }
public string? Position { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public Guid RoleId { get; set; }
public EmployeeRole Role { get; set; } = null!;
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
/// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary>
public bool IsActive { get; set; } = true;
public DateTime? FiredAt { get; set; }
public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; }
= new List<EmployeeRetailPointAssignment>();
}
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам
/// он может вставать. Если назначений нет — может ко всем (поведение по умолчанию).</summary>
public class EmployeeRetailPointAssignment : TenantEntity
{
public Guid EmployeeId { get; set; }
public Employee Employee { get; set; } = null!;
public Guid RetailPointId { get; set; }
}

View file

@ -0,0 +1,17 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Роль сотрудника в организации. Системные (IsSystem=true) сидируются
/// при создании Organization (Администратор/Менеджер/Кладовщик/Кассир/Закупщик/
/// Бухгалтер) — нельзя удалить, имя менять можно. Кастомные — полный CRUD.</summary>
public class EmployeeRole : TenantEntity
{
public string Name { get; set; } = "";
public string? Description { get; set; }
public bool IsSystem { get; set; }
public int SortOrder { get; set; }
/// <summary>Permissions — owned JSON-колонка (см. EF config).</summary>
public RolePermissions Permissions { get; set; } = new();
}

View file

@ -0,0 +1,51 @@
namespace foodmarket.Domain.Organizations;
/// <summary>Набор флагов разрешений роли. Хранится JSON-колонкой
/// (owned by EmployeeRole). Семантика: false = доступ запрещён.</summary>
public class RolePermissions
{
// Каталог
public bool ProductsView { get; set; }
public bool ProductsEdit { get; set; }
public bool ProductsDelete { get; set; }
public bool ProductGroupsManage { get; set; }
public bool PriceTypesManage { get; set; }
// Закупки
public bool SuppliesView { get; set; }
public bool SuppliesEdit { get; set; }
public bool SuppliesPost { get; set; }
public bool SuppliesDelete { get; set; }
// Продажи (POS)
public bool RetailSalesOperate { get; set; }
public bool RetailSalesRefund { get; set; }
// Контрагенты
public bool CounterpartiesView { get; set; }
public bool CounterpartiesEdit { get; set; }
// Отчёты и остатки
public bool ReportsView { get; set; }
public bool StocksView { get; set; }
// Настройки организации
public bool OrgSettingsManage { get; set; }
public bool EmployeesManage { get; set; }
public bool RolesManage { get; set; }
public bool StoresManage { get; set; }
public bool RetailPointsManage { get; set; }
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
public static RolePermissions All() => new()
{
ProductsView = true, ProductsEdit = true, ProductsDelete = true,
ProductGroupsManage = true, PriceTypesManage = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, SuppliesDelete = true,
RetailSalesOperate = true, RetailSalesRefund = true,
CounterpartiesView = true, CounterpartiesEdit = true,
ReportsView = true, StocksView = true,
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
StoresManage = true, RetailPointsManage = true,
};
}

View file

@ -46,6 +46,10 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<RetailSale> RetailSales => Set<RetailSale>(); public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>(); public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
@ -78,6 +82,7 @@ protected override void OnModelCreating(ModelBuilder builder)
builder.ConfigureInventory(); builder.ConfigureInventory();
builder.ConfigurePurchases(); builder.ConfigurePurchases();
builder.ConfigureSales(); builder.ConfigureSales();
builder.ConfigureOrganizationsHr();
// Apply multi-tenant query filter to every entity that implements ITenantEntity // Apply multi-tenant query filter to every entity that implements ITenantEntity
foreach (var entityType in builder.Model.GetEntityTypes()) foreach (var entityType in builder.Model.GetEntityTypes())

View file

@ -0,0 +1,44 @@
using foodmarket.Domain.Organizations;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
public static class OrganizationsHrConfigurations
{
public static void ConfigureOrganizationsHr(this ModelBuilder b)
{
b.Entity<EmployeeRole>(e =>
{
e.ToTable("employee_roles");
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
// Permissions — owned JSON-колонка. Один объект = один сериализованный
// блоб на роль. Все поля булевы, по умолчанию false.
e.OwnsOne(x => x.Permissions, p => p.ToJson("permissions"));
e.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
});
b.Entity<Employee>(e =>
{
e.ToTable("employees");
e.Property(x => x.LastName).HasMaxLength(100).IsRequired();
e.Property(x => x.FirstName).HasMaxLength(100).IsRequired();
e.Property(x => x.MiddleName).HasMaxLength(100);
e.Property(x => x.Position).HasMaxLength(150);
e.Property(x => x.Email).HasMaxLength(200);
e.Property(x => x.Phone).HasMaxLength(50);
e.HasOne(x => x.Role).WithMany().HasForeignKey(x => x.RoleId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.RetailPointAssignments).WithOne(a => a.Employee)
.HasForeignKey(a => a.EmployeeId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.UserId }).IsUnique()
.HasFilter("\"UserId\" IS NOT NULL");
e.HasIndex(x => new { x.OrganizationId, x.LastName });
});
b.Entity<EmployeeRetailPointAssignment>(e =>
{
e.ToTable("employee_retail_point_assignments");
e.HasIndex(x => new { x.EmployeeId, x.RetailPointId }).IsUnique();
});
}
}

View file

@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Сотрудники (employees) + роли (employee_roles с JSON-permissions)
/// + назначения сотрудников на кассы (employee_retail_point_assignments).
/// Сидер заведёт системные роли и подвяжет существующего admin'а как
/// сотрудника при первом старте.</summary>
public partial class Phase4_EmployeesAndRoles : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "employee_roles",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<System.Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
permissions = table.Column<string>(type: "jsonb", nullable: true),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_employee_roles", x => x.Id);
});
b.CreateIndex(
name: "IX_employee_roles_OrganizationId_Name",
schema: "public",
table: "employee_roles",
columns: new[] { "OrganizationId", "Name" },
unique: true);
b.CreateTable(
name: "employees",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<System.Guid>(type: "uuid", nullable: false),
UserId = table.Column<System.Guid>(type: "uuid", nullable: true),
LastName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
FirstName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
MiddleName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Position = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true),
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Phone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
RoleId = table.Column<System.Guid>(type: "uuid", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
FiredAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_employees", x => x.Id);
table.ForeignKey(
name: "FK_employees_employee_roles_RoleId",
column: x => x.RoleId,
principalSchema: "public",
principalTable: "employee_roles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
b.CreateIndex(
name: "IX_employees_RoleId",
schema: "public",
table: "employees",
column: "RoleId");
b.CreateIndex(
name: "IX_employees_OrganizationId_LastName",
schema: "public",
table: "employees",
columns: new[] { "OrganizationId", "LastName" });
b.CreateIndex(
name: "IX_employees_OrganizationId_UserId",
schema: "public",
table: "employees",
columns: new[] { "OrganizationId", "UserId" },
unique: true,
filter: "\"UserId\" IS NOT NULL");
b.CreateTable(
name: "employee_retail_point_assignments",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<System.Guid>(type: "uuid", nullable: false),
EmployeeId = table.Column<System.Guid>(type: "uuid", nullable: false),
RetailPointId = table.Column<System.Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_employee_retail_point_assignments", x => x.Id);
table.ForeignKey(
name: "FK_employee_retail_point_assignments_employees_EmployeeId",
column: x => x.EmployeeId,
principalSchema: "public",
principalTable: "employees",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
b.CreateIndex(
name: "IX_employee_retail_point_assignments_EmployeeId_RetailPointId",
schema: "public",
table: "employee_retail_point_assignments",
columns: new[] { "EmployeeId", "RetailPointId" },
unique: true);
}
protected override void Down(MigrationBuilder b)
{
b.DropTable(name: "employee_retail_point_assignments", schema: "public");
b.DropTable(name: "employees", schema: "public");
b.DropTable(name: "employee_roles", schema: "public");
}
}
}

View file

@ -1888,6 +1888,110 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{ {
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRole", b =>
{
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
b.Property<Guid>("OrganizationId").HasColumnType("uuid");
b.Property<string>("Name").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)");
b.Property<string>("Description").HasMaxLength(500).HasColumnType("character varying(500)");
b.Property<bool>("IsSystem").HasColumnType("boolean");
b.Property<int>("SortOrder").HasColumnType("integer");
b.Property<DateTime>("CreatedAt").HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt").HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrganizationId", "Name").IsUnique();
b.ToTable("employee_roles", "public");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRole", b =>
{
b.OwnsOne("foodmarket.Domain.Organizations.RolePermissions", "Permissions", o =>
{
o.Property<Guid>("EmployeeRoleId").HasColumnType("uuid");
o.Property<bool>("ProductsView").HasColumnType("boolean");
o.Property<bool>("ProductsEdit").HasColumnType("boolean");
o.Property<bool>("ProductsDelete").HasColumnType("boolean");
o.Property<bool>("ProductGroupsManage").HasColumnType("boolean");
o.Property<bool>("PriceTypesManage").HasColumnType("boolean");
o.Property<bool>("SuppliesView").HasColumnType("boolean");
o.Property<bool>("SuppliesEdit").HasColumnType("boolean");
o.Property<bool>("SuppliesPost").HasColumnType("boolean");
o.Property<bool>("SuppliesDelete").HasColumnType("boolean");
o.Property<bool>("RetailSalesOperate").HasColumnType("boolean");
o.Property<bool>("RetailSalesRefund").HasColumnType("boolean");
o.Property<bool>("CounterpartiesView").HasColumnType("boolean");
o.Property<bool>("CounterpartiesEdit").HasColumnType("boolean");
o.Property<bool>("ReportsView").HasColumnType("boolean");
o.Property<bool>("StocksView").HasColumnType("boolean");
o.Property<bool>("OrgSettingsManage").HasColumnType("boolean");
o.Property<bool>("EmployeesManage").HasColumnType("boolean");
o.Property<bool>("RolesManage").HasColumnType("boolean");
o.Property<bool>("StoresManage").HasColumnType("boolean");
o.Property<bool>("RetailPointsManage").HasColumnType("boolean");
o.HasKey("EmployeeRoleId");
o.ToJson("permissions");
o.WithOwner().HasForeignKey("EmployeeRoleId");
});
b.Navigation("Permissions");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.Employee", b =>
{
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
b.Property<Guid>("OrganizationId").HasColumnType("uuid");
b.Property<Guid?>("UserId").HasColumnType("uuid");
b.Property<string>("LastName").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)");
b.Property<string>("FirstName").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)");
b.Property<string>("MiddleName").HasMaxLength(100).HasColumnType("character varying(100)");
b.Property<string>("Position").HasMaxLength(150).HasColumnType("character varying(150)");
b.Property<string>("Email").HasMaxLength(200).HasColumnType("character varying(200)");
b.Property<string>("Phone").HasMaxLength(50).HasColumnType("character varying(50)");
b.Property<Guid>("RoleId").HasColumnType("uuid");
b.Property<bool>("IsActive").HasColumnType("boolean");
b.Property<DateTime?>("FiredAt").HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt").HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt").HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("RoleId");
b.HasIndex("OrganizationId", "LastName");
b.HasIndex("OrganizationId", "UserId").IsUnique().HasFilter("\"UserId\" IS NOT NULL");
b.ToTable("employees", "public");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.Employee", b =>
{
b.HasOne("foodmarket.Domain.Organizations.EmployeeRole", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Role");
b.Navigation("RetailPointAssignments");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRetailPointAssignment", b =>
{
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
b.Property<Guid>("OrganizationId").HasColumnType("uuid");
b.Property<Guid>("EmployeeId").HasColumnType("uuid");
b.Property<Guid>("RetailPointId").HasColumnType("uuid");
b.Property<DateTime>("CreatedAt").HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt").HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("EmployeeId", "RetailPointId").IsUnique();
b.ToTable("employee_retail_point_assignments", "public");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRetailPointAssignment", b =>
{
b.HasOne("foodmarket.Domain.Organizations.Employee", "Employee")
.WithMany("RetailPointAssignments")
.HasForeignKey("EmployeeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Employee");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }