Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 46s
Docker Web / Build + push Web (push) Successful in 30s
Docker API / Deploy API on stage (push) Failing after 36s
Docker Web / Deploy Web on stage (push) Successful in 11s
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.
Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
если SuperAdmin без override (системная), иначе подставляется
tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
OrganizationId DROP NOT NULL на product_groups и units_of_measure.
Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
если запись OrganizationId=null и юзер не SuperAdmin (только
суперадмин может править/удалять системные).
Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
реиспользуют существующие компоненты — для SuperAdmin без override
фильтр возвращает все записи, что в Phase 4+ можно ужесточить
отдельным эндпоинтом «только системные» (?orgId=null).
Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
8.7 KiB
C#
202 lines
8.7 KiB
C#
using foodmarket.Application.Common.Tenancy;
|
||
using foodmarket.Domain.Catalog;
|
||
using foodmarket.Domain.Common;
|
||
using foodmarket.Domain.Inventory;
|
||
using foodmarket.Domain.Purchases;
|
||
using foodmarket.Domain.Sales;
|
||
using foodmarket.Infrastructure.Identity;
|
||
using foodmarket.Domain.Organizations;
|
||
using foodmarket.Infrastructure.Persistence.Configurations;
|
||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace foodmarket.Infrastructure.Persistence;
|
||
|
||
public class AppDbContext : IdentityDbContext<User, Role, Guid>
|
||
{
|
||
private readonly ITenantContext _tenant;
|
||
|
||
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenant)
|
||
: base(options)
|
||
{
|
||
_tenant = tenant;
|
||
}
|
||
|
||
public DbSet<Organization> Organizations => Set<Organization>();
|
||
|
||
public DbSet<Country> Countries => Set<Country>();
|
||
public DbSet<Currency> Currencies => Set<Currency>();
|
||
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
||
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
||
public DbSet<Store> Stores => Set<Store>();
|
||
public DbSet<RetailPoint> RetailPoints => Set<RetailPoint>();
|
||
public DbSet<ProductGroup> ProductGroups => Set<ProductGroup>();
|
||
public DbSet<PriceType> PriceTypes => Set<PriceType>();
|
||
public DbSet<Product> Products => Set<Product>();
|
||
public DbSet<ProductPrice> ProductPrices => Set<ProductPrice>();
|
||
public DbSet<ProductBarcode> ProductBarcodes => Set<ProductBarcode>();
|
||
public DbSet<ProductImage> ProductImages => Set<ProductImage>();
|
||
|
||
public DbSet<Stock> Stocks => Set<Stock>();
|
||
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
||
|
||
public DbSet<Supply> Supplies => Set<Supply>();
|
||
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
||
|
||
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
|
||
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
|
||
|
||
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
|
||
public DbSet<Employee> Employees => Set<Employee>();
|
||
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
||
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
||
|
||
protected override void OnModelCreating(ModelBuilder builder)
|
||
{
|
||
base.OnModelCreating(builder);
|
||
|
||
builder.HasDefaultSchema("public");
|
||
|
||
// OpenIddict EF Core tables
|
||
builder.UseOpenIddict();
|
||
|
||
// Identity table renaming for clarity
|
||
builder.Entity<User>(b =>
|
||
{
|
||
b.ToTable("users");
|
||
b.Property(u => u.FullName).HasMaxLength(200);
|
||
});
|
||
builder.Entity<Role>(b => b.ToTable("roles"));
|
||
|
||
builder.Entity<Organization>(b =>
|
||
{
|
||
b.ToTable("organizations");
|
||
b.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
||
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
|
||
b.Property(o => o.Bin).HasMaxLength(20);
|
||
b.Property(o => o.MoySkladToken).HasMaxLength(200);
|
||
b.HasOne(o => o.DefaultCurrency).WithMany().HasForeignKey(o => o.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict);
|
||
b.HasIndex(o => o.Name);
|
||
b.HasIndex(o => o.IsArchived);
|
||
});
|
||
|
||
builder.Entity<SuperAdminAuditLog>(b =>
|
||
{
|
||
b.ToTable("super_admin_audit_log");
|
||
b.Property(x => x.ActionType).HasMaxLength(50).IsRequired();
|
||
b.Property(x => x.EntityType).HasMaxLength(100);
|
||
b.Property(x => x.Description).HasMaxLength(500);
|
||
b.Property(x => x.Reason).HasMaxLength(1000);
|
||
b.Property(x => x.ChangesJson).HasColumnType("jsonb");
|
||
b.Property(x => x.IpAddress).HasMaxLength(45);
|
||
b.HasIndex(x => x.CreatedAt);
|
||
b.HasIndex(x => new { x.SuperAdminUserId, x.CreatedAt });
|
||
b.HasIndex(x => new { x.OrganizationId, x.CreatedAt });
|
||
});
|
||
|
||
builder.ConfigureCatalog();
|
||
builder.ConfigureInventory();
|
||
builder.ConfigurePurchases();
|
||
builder.ConfigureSales();
|
||
builder.ConfigureOrganizationsHr();
|
||
|
||
// Apply multi-tenant query filter to every entity that implements ITenantEntity.
|
||
// IOptionalTenantEntity (системные справочники с nullable OrganizationId) —
|
||
// через отдельный фильтр, который пропускает запись с NULL для всех.
|
||
foreach (var entityType in builder.Model.GetEntityTypes())
|
||
{
|
||
if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
|
||
{
|
||
var method = typeof(AppDbContext)
|
||
.GetMethod(nameof(ApplyTenantFilter),
|
||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||
.MakeGenericMethod(entityType.ClrType);
|
||
method.Invoke(this, new object[] { builder });
|
||
}
|
||
else if (typeof(IOptionalTenantEntity).IsAssignableFrom(entityType.ClrType))
|
||
{
|
||
var method = typeof(AppDbContext)
|
||
.GetMethod(nameof(ApplyOptionalTenantFilter),
|
||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||
.MakeGenericMethod(entityType.ClrType);
|
||
method.Invoke(this, new object[] { builder });
|
||
}
|
||
}
|
||
}
|
||
|
||
private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity
|
||
{
|
||
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
|
||
// В override-режиме (X-Org-Override header активен) он работает в
|
||
// контексте конкретной орги — фильтр обязан применяться, иначе
|
||
// возвращаются записи всех орг и нарушается tenant isolation.
|
||
builder.Entity<T>().HasQueryFilter(e =>
|
||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||
|| e.OrganizationId == _tenant.OrganizationId);
|
||
}
|
||
|
||
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
|
||
{
|
||
// Системные записи (OrganizationId == null) видны ВСЕМ tenant'ам как
|
||
// эталонные. Tenant'овские (свои OrganizationId) — обычная изоляция.
|
||
// SuperAdmin без override видит всё.
|
||
builder.Entity<T>().HasQueryFilter(e =>
|
||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||
|| e.OrganizationId == null
|
||
|| e.OrganizationId == _tenant.OrganizationId);
|
||
}
|
||
|
||
public override int SaveChanges()
|
||
{
|
||
StampTenant();
|
||
StampTimestamps();
|
||
return base.SaveChanges();
|
||
}
|
||
|
||
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
|
||
{
|
||
StampTenant();
|
||
StampTimestamps();
|
||
return base.SaveChangesAsync(ct);
|
||
}
|
||
|
||
private void StampTenant()
|
||
{
|
||
foreach (var entry in ChangeTracker.Entries())
|
||
{
|
||
if (entry.State != EntityState.Added) continue;
|
||
if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
|
||
{
|
||
if (_tenant.OrganizationId.HasValue)
|
||
tenant.OrganizationId = _tenant.OrganizationId.Value;
|
||
}
|
||
else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null)
|
||
{
|
||
// Если SuperAdmin создаёт запись «как пользователь» (override
|
||
// активен), стампим выбранную орг. Если SuperAdmin без override
|
||
// (системная консоль) — оставляем null (системная запись).
|
||
// Tenant-юзер всегда стампит свой orgId.
|
||
if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||
{
|
||
// null — системная запись; оставляем
|
||
}
|
||
else if (_tenant.OrganizationId.HasValue)
|
||
{
|
||
opt.OrganizationId = _tenant.OrganizationId.Value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void StampTimestamps()
|
||
{
|
||
foreach (var entry in ChangeTracker.Entries<Entity>())
|
||
{
|
||
if (entry.State == EntityState.Added)
|
||
entry.Entity.CreatedAt = DateTime.UtcNow;
|
||
else if (entry.State == EntityState.Modified)
|
||
entry.Entity.UpdatedAt = DateTime.UtcNow;
|
||
}
|
||
}
|
||
}
|