food-market/src/food-market.infrastructure/Persistence/AppDbContext.cs
nns 6940aa40df
Some checks failed
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
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
feat(s19): bulk-операции + presets + power-user UX (7 пунктов)
1. Bulk-обновление товаров — Product.IsArchived + IsAvailableForSale
   (Phase19a миграция), POST /api/catalog/products/bulk-update {ids, op, params}
   с операциями price-adjust (% / абсолют), change-group, archive/unarchive,
   toggle-sale. Одной транзакцией, multi-tenant через query-filter.
   Frontend: checkbox-колонка, sticky bulk-bar, модалка.

2. SavedPresets — domain UserPreset (Phase19b: jsonb ConfigJson,
   unique по OrgId+UserId+PageKey+Name). /api/user/presets CRUD per-user.
   <SavedPresets> компонент с chip-bar и сохранением. Применено к /reports/
   sales/stock/profit + /catalog/products.

3. QuickActionsPalette — Cmd+J открывает отдельную палитру с 14 действиями
   + история topa-10 в localStorage.fm.quickActions.recent. ↑↓/Enter/Esc
   keyboard nav. Cmd+K (поиск) и Cmd+J (действия) — разные палитры.

4. Inline-edit цены — PATCH /api/catalog/products/{id}/price новый endpoint
   с RoundIfNeeded. <InlinePriceCell> с dblclick → input, optimistic update
   + revert при ошибке.

5. CSV import товаров — POST /api/catalog/products/import-csv (rows[]).
   Клиент парсит CSV (auto-detect разделитель ,/;), сервер commit'ит
   транзакцией. autoCreateGroup для новых групп. <ProductsCsvImport>
   модалка с preview и подсветкой ошибочных строк.

6. CSV/XLSX export — endpoint'ы /export на 5 контроллерах (products,
   counterparties, stock, retail-sales, supplies). Reuse существующего
   ReportExport.Csv/Xlsx. <ExportButton> dropdown с двумя форматами.

7. Keyboard-first nav в DataTable — ↑↓/Home/End/Enter/Space/Delete props
   keyboardNav/onSelect/onDelete. Подсветка focused-строки. Документация
   в src/help/keyboard-shortcuts.md + 2 новые HelpTopic'a.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:08:48 +05:00

285 lines
13 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>();
/// <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);
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<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;
}
}
}