From db3be5bbcab44f60fabe3d53a89aef710de47b7a Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:31:31 +0500 Subject: [PATCH] fix(price-types): correct is-system seeder + require value > 0 + system-price filter/sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» с IsSystem=true в каждой организации, не проверяя что фактически системной была другая запись (с реальными ценами у товаров). В итоге IsSystem-замок оказывался не у той записи. Миграция Phase3b_FixPriceTypeIsSystem (идемпотентная): - Снимает IsSystem со всех записей. - Помечает IsSystem=true + IsRequired=true тому PriceType, у которого максимум связанных ProductPrice (приоритет — фактически использующейся цене); при равенстве — самая старая (CreatedAt ASC). - Если у организации вообще нет PriceType — создаёт «Розничная цена» (IsSystem=true, IsRequired=true). DevDataSeeder: «Розничная» переименована в «Розничная цена», добавлены IsSystem=true / IsRequired=true; работает только если у организации ноль PriceType — больше не шлёпает дубль. API валидация (ProductsController.Create/Update): - FindMissingRequiredPriceAsync: для каждого PriceType с IsRequired=true проверяет, что в input.Prices есть запись с Amount > 0. Иначе возвращает 400 «Цена «<имя>» обязательна и должна быть больше 0.». API фильтр+сортировка по системной цене: - ProductsController.List: query parameters systemPriceFrom / systemPriceTo применяют ≥ / ≤ к Prices.Where(IsSystem).Amount. - Sort key 'systemPrice' — OrderBy / OrderByDescending по той же системной цене. Web ProductsPage: - Filters.referencePriceFrom/To → systemPriceFrom/To, бэк-параметры systemPriceFrom/To. - Подпись фильтра — динамическое имя системного PriceType (имя из справочника, обновляется при переименовании). - Колонка системной цены получила sortKey='systemPrice'. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/Catalog/ProductsController.cs | 32 + src/food-market.api/Seed/DevDataSeeder.cs | 8 +- ...0_Phase3b_FixPriceTypeIsSystem.Designer.cs | 1904 +++++++++++++++++ ...0425190000_Phase3b_FixPriceTypeIsSystem.cs | 68 + .../src/pages/ProductsPage.tsx | 29 +- 5 files changed, 2024 insertions(+), 17 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.Designer.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.cs diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 167094a..b0fda43 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -25,6 +25,25 @@ public ProductsController(AppDbContext db, ITenantContext tenant) // Проверка пересечения штрихкодов с другими товарами организации. // Возвращает первый конфликт «код → товар» либо null если всё чисто. + /// Проверяет что у каждого PriceType с IsRequired=true есть + /// соответствующая запись в input.Prices с Amount > 0. Возвращает имя + /// первого нарушенного типа либо null если всё ок. + private async Task FindMissingRequiredPriceAsync( + IReadOnlyList? prices, CancellationToken ct) + { + var required = await _db.PriceTypes + .Where(pt => pt.IsRequired) + .Select(pt => new { pt.Id, pt.Name }) + .ToListAsync(ct); + if (required.Count == 0) return null; + foreach (var pt in required) + { + var price = prices?.FirstOrDefault(p => p.PriceTypeId == pt.Id); + if (price is null || price.Amount <= 0m) return pt.Name; + } + return null; + } + private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync( IEnumerable codes, Guid? excludeProductId, CancellationToken ct) { @@ -98,6 +117,8 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) [FromQuery] decimal? referencePriceTo, [FromQuery] int? shelfLifeDaysFrom, [FromQuery] int? shelfLifeDaysTo, + [FromQuery] decimal? systemPriceFrom, + [FromQuery] decimal? systemPriceTo, CancellationToken ct) { var q = QueryIncludes().AsNoTracking(); @@ -127,6 +148,11 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo); if (shelfLifeDaysFrom is not null) q = q.Where(p => p.ShelfLifeDays >= shelfLifeDaysFrom); if (shelfLifeDaysTo is not null) q = q.Where(p => p.ShelfLifeDays <= shelfLifeDaysTo); + // Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true. + if (systemPriceFrom is not null) + q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom)); + if (systemPriceTo is not null) + q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount <= systemPriceTo)); if (!string.IsNullOrWhiteSpace(req.Search)) { @@ -150,6 +176,8 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) ("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name), ("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name), ("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name), + ("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name), + ("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name), ("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name), ("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name), ("name", true) => q.OrderByDescending(p => p.Name), @@ -174,6 +202,8 @@ public async Task> Create([FromBody] ProductInput input { if (input.Barcodes is null || input.Barcodes.Count == 0) return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); + if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) + return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." }); var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct); if (conflict is { } c) return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); @@ -209,6 +239,8 @@ public async Task Update(Guid id, [FromBody] ProductInput input, { if (input.Barcodes is null || input.Barcodes.Count == 0) return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); + if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) + return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." }); var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct); if (conflict is { } c) return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index 816ef9a..f8673c4 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -97,12 +97,16 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, ); } + // Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи. + // Если есть — никогда не создаём «системную копию», корректность IsSystem + // обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту: + // запись с максимумом ProductPrice). var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct); if (!anyPriceType) { db.PriceTypes.AddRange( - new PriceType { OrganizationId = orgId, Name = "Розничная", IsDefault = true, IsRetail = true, SortOrder = 1 }, - new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 2 } + new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsDefault = true, IsRetail = true, SortOrder = 0 }, + new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 } ); } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.Designer.cs new file mode 100644 index 0000000..a586030 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.Designer.cs @@ -0,0 +1,1904 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260425190000_Phase3b_FixPriceTypeIsSystem")] + partial class Phase3b_FixPriceTypeIsSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Bik") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Iin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LegalName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TaxNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("OrganizationId", "Bin"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("counterparties", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatRate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("DefaultCurrencyId"); + + b.ToTable("countries", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Currency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MinorUnit") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currencies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.PriceType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("IsRetail") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("price_types", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Article") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CountryOfOriginId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultSupplierId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsMarked") + .HasColumnType("boolean"); + + b.Property("IsService") + .HasColumnType("boolean"); + + b.Property("Packaging") + .HasColumnType("boolean"); + + b.Property("MaxStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("MinStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductGroupId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LastSupplyAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PurchaseCurrencyId") + .HasColumnType("uuid"); + + b.Property("ReferencePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReferencePriceUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ShelfLifeDays") + .HasColumnType("integer"); + + b.Property("UnitOfMeasureId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vat") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("VatEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("CountryOfOriginId"); + + b.HasIndex("DefaultSupplierId"); + + b.HasIndex("ProductGroupId"); + + b.HasIndex("PurchaseCurrencyId"); + + b.HasIndex("UnitOfMeasureId"); + + b.HasIndex("OrganizationId", "Article"); + + b.HasIndex("OrganizationId", "Name"); + + b.HasIndex("OrganizationId", "ProductGroupId"); + + b.ToTable("products", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("product_barcodes", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkupPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("OrganizationId", "ParentId"); + + b.HasIndex("OrganizationId", "Path"); + + b.ToTable("product_groups", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("product_images", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PriceTypeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("PriceTypeId"); + + b.HasIndex("ProductId", "PriceTypeId") + .IsUnique(); + + b.ToTable("product_prices", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FiscalRegNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FiscalSerial") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("retail_points", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("ManagerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("stores", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("units_of_measure", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReservedQuantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "StoreId"); + + b.HasIndex("OrganizationId", "ProductId", "StoreId") + .IsUnique(); + + b.ToTable("stocks", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("DocumentType", "DocumentId"); + + b.HasIndex("OrganizationId", "ProductId", "OccurredAt"); + + b.HasIndex("OrganizationId", "StoreId", "OccurredAt"); + + b.ToTable("stock_movements", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AllowFractionalPrices") + .HasColumnType("boolean"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MoySkladToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MultiCurrencyEnabled") + .HasColumnType("boolean"); + + b.Property("ShowMarkedOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowReferencePriceOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowMinMaxStock") + .HasColumnType("boolean"); + + b.Property("ShowServiceOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowVatEnabledOnProduct") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCurrencyId"); + + b.HasIndex("Name"); + + b.ToTable("organizations", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("SupplierInvoiceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupplierInvoiceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("StoreId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.HasIndex("OrganizationId", "SupplierId"); + + b.ToTable("supplies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailPriceManuallyOverridden") + .HasColumnType("boolean"); + + b.Property("RetailPriceOverride") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SupplyId") + .HasColumnType("uuid"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplyId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("supply_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashierUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaidCard") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PaidCash") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Payment") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("RetailPointId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("RetailPointId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "CashierUserId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("retail_sales", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailSaleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("RetailSaleId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("retail_sale_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "CountryOfOrigin") + .WithMany() + .HasForeignKey("CountryOfOriginId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "DefaultSupplier") + .WithMany() + .HasForeignKey("DefaultSupplierId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "ProductGroup") + .WithMany() + .HasForeignKey("ProductGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Currency", "PurchaseCurrency") + .WithMany() + .HasForeignKey("PurchaseCurrencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CountryOfOrigin"); + + b.Navigation("DefaultSupplier"); + + b.Navigation("ProductGroup"); + + b.Navigation("PurchaseCurrency"); + + b.Navigation("UnitOfMeasure"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Barcodes") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.PriceType", "PriceType") + .WithMany() + .HasForeignKey("PriceTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Prices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("PriceType"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Store"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply") + .WithMany("Lines") + .HasForeignKey("SupplyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Supply"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint") + .WithMany() + .HasForeignKey("RetailPointId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Customer"); + + b.Navigation("RetailPoint"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale") + .WithMany("Lines") + .HasForeignKey("RetailSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("RetailSale"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Navigation("Barcodes"); + + b.Navigation("Images"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.cs b/src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.cs new file mode 100644 index 0000000..28aad97 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260425190000_Phase3b_FixPriceTypeIsSystem.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» + /// с IsSystem=true в каждой организации, у которой не было IsSystem=true, + /// игнорируя факт что фактически системной была другая запись (с реальными + /// ценами у товаров). В итоге IsSystem оказывался не у той записи. + /// + /// Чиним идемпотентно для каждой организации: + /// 1. Сначала снимаем IsSystem со всех записей. + /// 2. Помечаем IsSystem той записи, у которой больше всего связанных + /// ProductPrice (приоритет — реально использующейся цене); при равенстве + /// выбирается самая старая (CreatedAt ASC). + /// 3. Если в организации вообще нет ни одной PriceType — создаём новую + /// «Розничная цена» с IsSystem=true. + /// 4. У выбранной системной IsRequired ставим true. + /// + public partial class Phase3b_FixPriceTypeIsSystem : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(""" + -- 1. Снимаем IsSystem со всех записей. + UPDATE public.price_types SET "IsSystem" = false; + + -- 2. Для каждой организации находим кандидата с max(count(prices)) + -- и помечаем его IsSystem=true, IsRequired=true. + WITH ranked AS ( + SELECT + pt."Id", + pt."OrganizationId", + COUNT(pp."Id") AS price_count, + pt."CreatedAt", + ROW_NUMBER() OVER ( + PARTITION BY pt."OrganizationId" + ORDER BY COUNT(pp."Id") DESC, pt."CreatedAt" ASC, pt."Id" ASC + ) AS rn + FROM public.price_types pt + LEFT JOIN public.product_prices pp ON pp."PriceTypeId" = pt."Id" + GROUP BY pt."Id", pt."OrganizationId", pt."CreatedAt" + ) + UPDATE public.price_types pt + SET "IsSystem" = true, "IsRequired" = true + FROM ranked + WHERE pt."Id" = ranked."Id" AND ranked.rn = 1; + + -- 3. Если у организации ВООБЩЕ нет PriceType — создадим «Розничная цена». + INSERT INTO public.price_types + ("Id", "OrganizationId", "Name", "IsRequired", "IsSystem", + "IsDefault", "IsRetail", "SortOrder", "CreatedAt") + SELECT gen_random_uuid(), o."Id", 'Розничная цена', true, true, + true, true, 0, now() AT TIME ZONE 'UTC' + FROM public.organizations o + WHERE NOT EXISTS ( + SELECT 1 FROM public.price_types pt WHERE pt."OrganizationId" = o."Id" + ); + """); + } + + protected override void Down(MigrationBuilder b) + { + // Невосстанавливаемо — IsSystem-перенос завязан на исторические данные. + } + } +} diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index b43c2f8..e7d11f9 100644 --- a/src/food-market.web/src/pages/ProductsPage.tsx +++ b/src/food-market.web/src/pages/ProductsPage.tsx @@ -21,8 +21,8 @@ interface Filters { isService: TriFilter packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid isMarked: TriFilter - referencePriceFrom: number | null - referencePriceTo: number | null + systemPriceFrom: number | null + systemPriceTo: number | null shelfLifeDaysFrom: number | null shelfLifeDaysTo: number | null } @@ -32,8 +32,8 @@ const defaultFilters: Filters = { isService: 'all', packaging: null, isMarked: 'all', - referencePriceFrom: null, - referencePriceTo: null, + systemPriceFrom: null, + systemPriceTo: null, shelfLifeDaysFrom: null, shelfLifeDaysTo: null, } @@ -44,8 +44,8 @@ const toExtra = (f: Filters): Record { if (f.isService !== 'all') n++ if (f.packaging) n++ if (f.isMarked !== 'all') n++ - if (f.referencePriceFrom != null) n++ - if (f.referencePriceTo != null) n++ + if (f.systemPriceFrom != null) n++ + if (f.systemPriceTo != null) n++ if (f.shelfLifeDaysFrom != null) n++ if (f.shelfLifeDaysTo != null) n++ return n @@ -140,6 +140,7 @@ export function ProductsPage() { header: systemPriceType?.name ?? 'Розничная цена', width: '170px', className: 'text-right font-mono', + sortKey: 'systemPrice', cell: (r) => { const pr = systemPriceType ? r.prices?.find(x => x.priceTypeId === systemPriceType.id) : undefined if (!pr) return '—' @@ -237,24 +238,22 @@ export function ProductsPage() { { setFilters({ ...filters, isMarked: v }); setPage(1) }} /> )}
- Эталонная цена + {systemPriceType?.name ?? 'Цена'}
{ setFilters({ ...filters, referencePriceFrom: n }); setPage(1) }} + value={filters.systemPriceFrom} + onChange={(n) => { setFilters({ ...filters, systemPriceFrom: n }); setPage(1) }} currencyCode={org.data?.defaultCurrencyCode ?? undefined} currencySymbol={org.data?.defaultCurrencySymbol ?? undefined} - placeholder="от" />
{ setFilters({ ...filters, referencePriceTo: n }); setPage(1) }} + value={filters.systemPriceTo} + onChange={(n) => { setFilters({ ...filters, systemPriceTo: n }); setPage(1) }} currencyCode={org.data?.defaultCurrencyCode ?? undefined} currencySymbol={org.data?.defaultCurrencySymbol ?? undefined} - placeholder="до" />