Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff). EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories: Create без строк автоматически подгружает все товары склада с текущим Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая diff. Post создаёт корректирующие движения InventoryAdjustment на diff (положительный — приход излишка, отрицательный — списание недостачи). Unpost атомарно откатывает; проверка «излишек уже расходован» → 409. Web: /inventory/inventories (list с разделением излишек/недостача) + edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации». Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff; post 400 если diff=0; tenant-изоляция). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
231 lines
9.9 KiB
C#
231 lines
9.9 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<Enter> Enters => Set<Enter>();
|
||
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
|
||
|
||
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<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;
|
||
}
|
||
}
|
||
}
|