food-market/src/food-market.infrastructure/Persistence/AppDbContext.cs
nns 261a720b52
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
feat(data-quality): фоновая «Проверка данных» — бэкенд (п.1,3,4)
Hangfire DataQualityJob (ночью 02:20 UTC) считает по орг. счётчики проблем: без группы
(корень/«Без группы»), без цены продажи, без штрихкода (кроме услуг), дубль штрихкода,
возможный дубль имя+единица; товары в минусе — ссылкой на центр (не дублируем). Результаты
в data_quality_results (Phase43), живые детальные списки — по запросу.
API /api/data-quality: сводка, {check}/products, recompute, dismiss («это разные товары»),
bulk set-group/set-price/generate-barcodes, PUT settings (отключение проверок). unknown-cost
отложен до FIFO; PLU убран (поправка владельца). Integration-тест: счётчики/список/bulk/dismiss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 08:11:57 +05:00

358 lines
17 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.Money;
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<NegativeStockAlert> NegativeStockAlerts => Set<NegativeStockAlert>();
public DbSet<Supply> Supplies => Set<Supply>();
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
public DbSet<InvoiceUpload> InvoiceUploads => Set<InvoiceUpload>();
public DbSet<InvoiceAlias> InvoiceAliases => Set<InvoiceAlias>();
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<foodmarket.Domain.Organizations.DeviceToken> DeviceTokens => Set<foodmarket.Domain.Organizations.DeviceToken>();
public DbSet<foodmarket.Domain.Notifications.Notification> Notifications => Set<foodmarket.Domain.Notifications.Notification>();
public DbSet<foodmarket.Domain.Notifications.WebPushSubscription> WebPushSubscriptions => Set<foodmarket.Domain.Notifications.WebPushSubscription>();
public DbSet<foodmarket.Domain.Catalog.DataQualityResult> DataQualityResults => Set<foodmarket.Domain.Catalog.DataQualityResult>();
public DbSet<foodmarket.Domain.Catalog.DataQualityDismissal> DataQualityDismissals => Set<foodmarket.Domain.Catalog.DataQualityDismissal>();
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>();
// Sprint 33: модуль «Деньги» — простой режим.
public DbSet<MoneyAccount> MoneyAccounts => Set<MoneyAccount>();
public DbSet<ExpenseItem> ExpenseItems => Set<ExpenseItem>();
public DbSet<MoneyTransaction> MoneyTransactions => Set<MoneyTransaction>();
public DbSet<MoneyTransactionLink> MoneyTransactionLinks => Set<MoneyTransactionLink>();
public DbSet<CashShift> CashShifts => Set<CashShift>();
public DbSet<CashDrawerOperation> CashDrawerOperations => Set<CashDrawerOperation>();
public DbSet<CounterpartyAdjustment> CounterpartyAdjustments => Set<CounterpartyAdjustment>();
/// <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.Entity<foodmarket.Domain.Notifications.Notification>(b =>
{
b.ToTable("notifications");
b.Property(x => x.Type).HasMaxLength(50).IsRequired();
b.Property(x => x.Severity).HasMaxLength(20).IsRequired();
b.Property(x => x.Title).HasMaxLength(300).IsRequired();
b.Property(x => x.Body).HasMaxLength(4000).IsRequired();
b.Property(x => x.Link).HasMaxLength(500);
b.Property(x => x.DataJson).HasColumnType("jsonb");
// Центр уведомлений: непрочитанные конкретного пользователя, свежие сверху.
b.HasIndex(x => new { x.OrganizationId, x.RecipientUserId, x.ReadAt });
b.HasIndex(x => new { x.OrganizationId, x.RecipientUserId, x.CreatedAt });
});
builder.Entity<foodmarket.Domain.Notifications.WebPushSubscription>(b =>
{
b.ToTable("web_push_subscriptions");
b.Property(x => x.Endpoint).HasMaxLength(2000).IsRequired();
b.Property(x => x.P256dh).HasMaxLength(500).IsRequired();
b.Property(x => x.Auth).HasMaxLength(500).IsRequired();
b.HasIndex(x => x.Endpoint).IsUnique();
b.HasIndex(x => new { x.OrganizationId, x.UserId });
});
builder.Entity<foodmarket.Domain.Catalog.DataQualityResult>(b =>
{
b.ToTable("data_quality_results");
b.Property(x => x.CheckType).HasMaxLength(40).IsRequired();
b.HasIndex(x => new { x.OrganizationId, x.CheckType }).IsUnique();
});
builder.Entity<foodmarket.Domain.Catalog.DataQualityDismissal>(b =>
{
b.ToTable("data_quality_dismissals");
b.Property(x => x.CheckType).HasMaxLength(40).IsRequired();
b.Property(x => x.DismissKey).HasMaxLength(300).IsRequired();
b.HasIndex(x => new { x.OrganizationId, x.CheckType, x.DismissKey }).IsUnique();
});
builder.ConfigureCatalog();
builder.ConfigureInventory();
builder.ConfigurePurchases();
builder.ConfigureSales();
builder.ConfigureOrganizationsHr();
builder.ConfigureMoney();
// 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;
}
}
}