food-market/src/food-market.infrastructure/Persistence/AppDbContext.cs
nns 58038c9cf7
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
feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Концепция: 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>
2026-04-26 16:20:47 +05:00

202 lines
8.7 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.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;
}
}
}