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
Было: каждая орга держала свои 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 со списком организаций при попытке деактивировать используемую единицу
178 lines
8.4 KiB
C#
178 lines
8.4 KiB
C#
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);
|
||
}
|
||
}
|