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
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>
285 lines
13 KiB
C#
285 lines
13 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<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;
|
||
}
|
||
}
|
||
}
|