food-market/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs
nns 493ed33fd0
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 59s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m26s
Docker Web / Build + push Web (push) Successful in 35s
Docker API / Deploy API on stage (push) Failing after 38s
Docker Web / Deploy Web on stage (push) Successful in 12s
phase5c: единицы измерения — глобальный справочник + junction для орг
Было: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк
в БД на 19 орг — duplication, любой Admin мог их редактировать.
Стало: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. Орга
включает нужные единицы у себя через junction org_units_of_measure.

Backend:
- UnitOfMeasure: добавлен IsActive (для soft-delete с filtered unique index)
- Новый OrgUnitOfMeasure (junction PK Organization+Unit, FK Restrict)
- Migration Phase5c_UnitsOfMeasureGlobal: безопасная для prod —
  поднимает по одной строке на (Code, Name) до global, remap'ит
  products.UnitOfMeasureId, наполняет junction по факту существующих
  привязок, удаляет дубликаты.
- /api/catalog/units-of-measure для org Admin: read-only список
  enabled-globals + POST/DELETE /enable для toggle
- /api/super-admin/units-of-measure: full CRUD; DELETE soft (IsActive=false)
  с 409 если есть products или active org-junction (со списком орг)
- DevDataSeeder.SeedTenantReferencesAsync вместо создания per-tenant
  юнитов — auto-enable всех active globals через junction

Frontend:
- /catalog/units — checkbox-список (включить/выключить); CTA на платформу
  для SuperAdmin
- /super-admin/units — full CRUD над глобалами, 409 со списком
  организаций при попытке деактивировать используемую единицу
2026-05-08 01:21:20 +05:00

178 lines
8.4 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.Domain.Catalog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace foodmarket.Infrastructure.Persistence.Configurations;
internal static class CatalogConfigurations
{
public static void ConfigureCatalog(this ModelBuilder b)
{
b.Entity<Country>(ConfigureCountry);
b.Entity<Currency>(ConfigureCurrency);
b.Entity<UnitOfMeasure>(ConfigureUnit);
b.Entity<OrgUnitOfMeasure>(ConfigureOrgUnit);
b.Entity<Counterparty>(ConfigureCounterparty);
b.Entity<Store>(ConfigureStore);
b.Entity<RetailPoint>(ConfigureRetailPoint);
b.Entity<ProductGroup>(ConfigureProductGroup);
b.Entity<PriceType>(ConfigurePriceType);
b.Entity<Product>(ConfigureProduct);
b.Entity<ProductPrice>(ConfigureProductPrice);
b.Entity<ProductBarcode>(ConfigureBarcode);
b.Entity<ProductImage>(ConfigureImage);
}
private static void ConfigureCountry(EntityTypeBuilder<Country> b)
{
b.ToTable("countries");
b.Property(x => x.Code).HasMaxLength(2).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.VatRate).HasPrecision(5, 2);
b.HasOne(x => x.DefaultCurrency).WithMany().HasForeignKey(x => x.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => x.Code).IsUnique();
}
private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
{
b.ToTable("currencies");
b.Property(x => x.Code).HasMaxLength(3).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Symbol).HasMaxLength(5).IsRequired();
b.HasIndex(x => x.Code).IsUnique();
}
private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
{
b.ToTable("units_of_measure");
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Description).HasMaxLength(500);
b.Property(x => x.IsActive).HasDefaultValue(true);
// Phase5c: после миграции OrganizationId всегда NULL — глобальный справочник.
// Уникальность по Code только среди активных, чтобы можно было
// soft-delete старую запись и создать новую с тем же кодом.
b.HasIndex(x => x.Code).IsUnique().HasFilter("\"IsActive\" = true");
}
private static void ConfigureOrgUnit(EntityTypeBuilder<OrgUnitOfMeasure> b)
{
b.ToTable("org_units_of_measure");
b.HasKey(x => new { x.OrganizationId, x.UnitOfMeasureId });
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => x.UnitOfMeasureId);
}
private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
{
b.ToTable("counterparties");
b.Property(x => x.Name).HasMaxLength(255).IsRequired();
b.Property(x => x.LegalName).HasMaxLength(500);
b.Property(x => x.Bin).HasMaxLength(20);
b.Property(x => x.Iin).HasMaxLength(20);
b.Property(x => x.TaxNumber).HasMaxLength(20);
b.Property(x => x.Phone).HasMaxLength(50);
b.Property(x => x.Email).HasMaxLength(255);
b.Property(x => x.BankName).HasMaxLength(255);
b.Property(x => x.BankAccount).HasMaxLength(50);
b.Property(x => x.Bik).HasMaxLength(20);
b.Property(x => x.ContactPerson).HasMaxLength(255);
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.Name });
b.HasIndex(x => new { x.OrganizationId, x.Bin });
}
private static void ConfigureStore(EntityTypeBuilder<Store> b)
{
b.ToTable("stores");
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
b.Property(x => x.Code).HasMaxLength(50);
b.Property(x => x.Phone).HasMaxLength(50);
b.Property(x => x.ManagerName).HasMaxLength(200);
b.HasIndex(x => new { x.OrganizationId, x.Name });
}
private static void ConfigureRetailPoint(EntityTypeBuilder<RetailPoint> b)
{
b.ToTable("retail_points");
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
b.Property(x => x.Code).HasMaxLength(50);
b.Property(x => x.Phone).HasMaxLength(50);
b.Property(x => x.FiscalSerial).HasMaxLength(50);
b.Property(x => x.FiscalRegNumber).HasMaxLength(50);
b.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.Name });
}
private static void ConfigureProductGroup(EntityTypeBuilder<ProductGroup> b)
{
b.ToTable("product_groups");
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
b.Property(x => x.Path).HasMaxLength(1000);
b.HasOne(x => x.Parent)
.WithMany(x => x.Children)
.HasForeignKey(x => x.ParentId)
.OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.ParentId });
b.HasIndex(x => new { x.OrganizationId, x.Path });
}
private static void ConfigurePriceType(EntityTypeBuilder<PriceType> b)
{
b.ToTable("price_types");
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
}
private static void ConfigureProduct(EntityTypeBuilder<Product> b)
{
b.ToTable("products");
b.Property(x => x.Name).HasMaxLength(500).IsRequired();
b.Property(x => x.Article).HasMaxLength(500);
b.Property(x => x.MinStock).HasPrecision(18, 4);
b.Property(x => x.MaxStock).HasPrecision(18, 4);
b.Property(x => x.ReferencePrice).HasPrecision(18, 4);
b.Property(x => x.Cost).HasPrecision(18, 4);
b.Property(x => x.ImageUrl).HasMaxLength(1000);
// VatEnabled defaults to true в БД — при миграции existing rows сохраняют true.
b.Property(x => x.VatEnabled).HasDefaultValue(true);
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.PurchaseCurrency).WithMany().HasForeignKey(x => x.PurchaseCurrencyId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.Name });
b.HasIndex(x => new { x.OrganizationId, x.Article });
b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId });
}
private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)
{
b.ToTable("product_prices");
b.Property(x => x.Amount).HasPrecision(18, 4);
b.HasOne(x => x.Product).WithMany(p => p.Prices).HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.PriceType).WithMany().HasForeignKey(x => x.PriceTypeId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.ProductId, x.PriceTypeId }).IsUnique();
}
private static void ConfigureBarcode(EntityTypeBuilder<ProductBarcode> b)
{
b.ToTable("product_barcodes");
// Up to 500 to accommodate GS1 DataMatrix / crypto-tail tracking codes (Честный ЗНАК etc.)
b.Property(x => x.Code).HasMaxLength(500).IsRequired();
b.HasOne(x => x.Product).WithMany(p => p.Barcodes).HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
}
private static void ConfigureImage(EntityTypeBuilder<ProductImage> b)
{
b.ToTable("product_images");
b.Property(x => x.Url).HasMaxLength(1000).IsRequired();
b.HasOne(x => x.Product).WithMany(p => p.Images).HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => x.ProductId);
}
}