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 { private readonly ITenantContext _tenant; public AppDbContext(DbContextOptions options, ITenantContext tenant) : base(options) { _tenant = tenant; } public DbSet Organizations => Set(); public DbSet Countries => Set(); public DbSet Currencies => Set(); public DbSet UnitsOfMeasure => Set(); public DbSet OrgUnitsOfMeasure => Set(); public DbSet Counterparties => Set(); public DbSet Stores => Set(); public DbSet RetailPoints => Set(); public DbSet ProductGroups => Set(); public DbSet PriceTypes => Set(); public DbSet Products => Set(); public DbSet ProductPrices => Set(); public DbSet ProductBarcodes => Set(); public DbSet ProductImages => Set(); public DbSet Stocks => Set(); public DbSet StockMovements => Set(); public DbSet Supplies => Set(); public DbSet SupplyLines => Set(); public DbSet Enters => Set(); public DbSet EnterLines => Set(); public DbSet SupplierReturns => Set(); public DbSet SupplierReturnLines => Set(); public DbSet Losses => Set(); public DbSet LossLines => Set(); public DbSet Transfers => Set(); public DbSet TransferLines => Set(); public DbSet InventoryDocs => Set(); public DbSet InventoryLines => Set(); public DbSet RetailSales => Set(); public DbSet RetailSaleLines => Set(); public DbSet PosBatchAcks => Set(); // Sprint 9: лояльность и акции. public DbSet LoyaltyPrograms => Set(); public DbSet LoyaltyCards => Set(); public DbSet Promotions => Set(); public DbSet Demands => Set(); public DbSet DemandLines => Set(); public DbSet EmployeeRoles => Set(); public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); public DbSet SuperAdminAuditLogs => Set(); public DbSet OrgAuditLogs => Set(); public DbSet ImportJobs => Set(); public DbSet UserPresets => Set(); /// Если true — не пишет audit-строки /// для этого SaveChanges. Используется сидерами/миграциями, фоновыми /// импортами (где tenant-context отсутствует) и при ручной чистке логов. public bool SkipAudit { get; set; } public DbSet SystemSettings => Set(); public DbSet PlatformSettings => Set(); 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(b => { b.ToTable("users"); b.Property(u => u.FullName).HasMaxLength(200); }); builder.Entity(b => b.ToTable("roles")); builder.Entity(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(b => { b.ToTable("system_settings"); }); builder.Entity(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(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.Entity(b => { b.ToTable("org_audit_log"); b.Property(x => x.Action).HasMaxLength(20).IsRequired(); b.Property(x => x.EntityType).HasMaxLength(100).IsRequired(); b.Property(x => x.ChangesJson).HasColumnType("jsonb").IsRequired(); b.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); b.HasIndex(x => new { x.OrganizationId, x.EntityType, x.EntityId }); b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt }); }); builder.Entity(b => { b.ToTable("user_presets"); b.Property(x => x.PageKey).HasMaxLength(100).IsRequired(); b.Property(x => x.Name).HasMaxLength(200).IsRequired(); b.Property(x => x.ConfigJson).HasColumnType("jsonb").IsRequired(); // Один пресет с таким именем на (UserId, OrgId, PageKey). b.HasIndex(x => new { x.OrganizationId, x.UserId, x.PageKey, x.Name }).IsUnique(); b.HasIndex(x => new { x.OrganizationId, x.UserId, x.PageKey }); }); builder.Entity(b => { b.ToTable("import_jobs"); b.Property(x => x.Kind).HasMaxLength(50).IsRequired(); b.Property(x => x.Stage).HasMaxLength(500); b.Property(x => x.Message).HasMaxLength(2000); b.Property(x => x.ErrorsJson).HasColumnType("jsonb").IsRequired(); b.HasIndex(x => new { x.OrganizationId, x.StartedAt }); b.HasIndex(x => new { x.OrganizationId, x.Status }); b.HasIndex(x => x.FinishedAt); }); 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(ModelBuilder builder) where T : class, ITenantEntity { // SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…». // В override-режиме (X-Org-Override header активен) он работает в // контексте конкретной орги — фильтр обязан применяться, иначе // возвращаются записи всех орг и нарушается tenant isolation. builder.Entity().HasQueryFilter(e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) || e.OrganizationId == _tenant.OrganizationId); } private void ApplyOptionalTenantFilter(ModelBuilder builder) where T : class, IOptionalTenantEntity { // Системные записи (OrganizationId == null) видны ВСЕМ tenant'ам как // эталонные. Tenant'овские (свои OrganizationId) — обычная изоляция. // SuperAdmin без override видит всё. builder.Entity().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 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()) { if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTime.UtcNow; else if (entry.State == EntityState.Modified) entry.Entity.UpdatedAt = DateTime.UtcNow; } } }