food-market/src/food-market.infrastructure/Persistence/AppDbContext.cs
nns 4342318c2c feat(s29): Organization.IsAdvancedMode + миграция Phase29a (шаг 1/8)
Simple/Advanced mode: по умолчанию у всех организаций «простой» режим
(IsAdvancedMode=false) — виден только базовый розничный учёт. Включить
«расширенный» режим может только SuperAdmin через отдельный API; в
настройках магазина переключателя нет.

Данные скрытых модулей при переключении НЕ удаляются; при повторном
включении всё снова видно.

- Organization.IsAdvancedMode (bool, default false) + XML-док
- EF-миграция Phase29a — идемпотентный ADD COLUMN IF NOT EXISTS DEFAULT false
- AppDbContextModelSnapshot обновлён
- Применено на dev-БД: 5/5 организаций → IsAdvancedMode=false

docs/simple-mode-progress.md — чек-лист + журнал.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:28:28 +05:00

301 lines
14 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<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<Enter> Enters => Set<Enter>();
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
public DbSet<SupplierReturn> SupplierReturns => Set<SupplierReturn>();
public DbSet<SupplierReturnLine> SupplierReturnLines => Set<SupplierReturnLine>();
public DbSet<Loss> Losses => Set<Loss>();
public DbSet<LossLine> LossLines => Set<LossLine>();
public DbSet<Transfer> Transfers => Set<Transfer>();
public DbSet<TransferLine> TransferLines => Set<TransferLine>();
public DbSet<InventoryDoc> InventoryDocs => Set<InventoryDoc>();
public DbSet<InventoryLine> InventoryLines => Set<InventoryLine>();
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
public DbSet<PosBatchAck> PosBatchAcks => Set<PosBatchAck>();
// Sprint 9: лояльность и акции.
public DbSet<LoyaltyProgram> LoyaltyPrograms => Set<LoyaltyProgram>();
public DbSet<LoyaltyCard> LoyaltyCards => Set<LoyaltyCard>();
public DbSet<Promotion> Promotions => Set<Promotion>();
public DbSet<Demand> Demands => Set<Demand>();
public DbSet<DemandLine> DemandLines => Set<DemandLine>();
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<OrgAuditLog> OrgAuditLogs => Set<OrgAuditLog>();
public DbSet<foodmarket.Domain.Integrations.ImportJob> ImportJobs => Set<foodmarket.Domain.Integrations.ImportJob>();
public DbSet<UserPreset> UserPresets => Set<UserPreset>();
public DbSet<OrgExport> OrgExports => Set<OrgExport>();
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
/// импортами (где tenant-context отсутствует) и при ручной чистке логов.</summary>
public bool SkipAudit { get; set; }
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);
// Simple/Advanced mode: default false (простой режим). Миграция
// Phase29a_IsAdvancedMode добавляет колонку, backfilling всех
// существующих организаций в false.
b.Property(o => o.IsAdvancedMode).HasDefaultValue(false);
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.Entity<OrgAuditLog>(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<OrgExport>(b =>
{
b.ToTable("org_exports");
b.Property(x => x.StorageKey).HasMaxLength(500);
b.Property(x => x.DownloadToken).HasMaxLength(128);
b.Property(x => x.NotifyEmail).HasMaxLength(200);
b.Property(x => x.Error).HasMaxLength(2000);
b.HasIndex(x => new { x.OrganizationId, x.CreatedAt });
b.HasIndex(x => x.DownloadToken).IsUnique(); // быстрый lookup по download token
});
builder.Entity<UserPreset>(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<foodmarket.Domain.Integrations.ImportJob>(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<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;
}
}
}