Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 59s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m26s
Docker Web / Build + push Web (push) Successful in 35s
Docker API / Deploy API on stage (push) Failing after 38s
Docker Web / Deploy Web on stage (push) Successful in 12s
Было: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк в БД на 19 орг — duplication, любой Admin мог их редактировать. Стало: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. Орга включает нужные единицы у себя через junction org_units_of_measure. Backend: - UnitOfMeasure: добавлен IsActive (для soft-delete с filtered unique index) - Новый OrgUnitOfMeasure (junction PK Organization+Unit, FK Restrict) - Migration Phase5c_UnitsOfMeasureGlobal: безопасная для prod — поднимает по одной строке на (Code, Name) до global, remap'ит products.UnitOfMeasureId, наполняет junction по факту существующих привязок, удаляет дубликаты. - /api/catalog/units-of-measure для org Admin: read-only список enabled-globals + POST/DELETE /enable для toggle - /api/super-admin/units-of-measure: full CRUD; DELETE soft (IsActive=false) с 409 если есть products или active org-junction (со списком орг) - DevDataSeeder.SeedTenantReferencesAsync вместо создания per-tenant юнитов — auto-enable всех active globals через junction Frontend: - /catalog/units — checkbox-список (включить/выключить); CTA на платформу для SuperAdmin - /super-admin/units — full CRUD над глобалами, 409 со списком организаций при попытке деактивировать используемую единицу
219 lines
9.5 KiB
C#
219 lines
9.5 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<OrgUnitOfMeasure> OrgUnitsOfMeasure => Set<OrgUnitOfMeasure>();
|
||
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>();
|
||
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
|
||
|
||
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<SystemSettings>(b =>
|
||
{
|
||
b.ToTable("system_settings");
|
||
});
|
||
|
||
builder.Entity<foodmarket.Domain.Platform.PlatformSettings>(b =>
|
||
{
|
||
b.ToTable("platform_settings");
|
||
b.Property(x => x.SmtpHost).HasMaxLength(200);
|
||
b.Property(x => x.SmtpUsername).HasMaxLength(200);
|
||
b.Property(x => x.FromEmail).HasMaxLength(200);
|
||
b.Property(x => x.FromName).HasMaxLength(200);
|
||
});
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|