From 23d6f2bd5a1e23c73f4b13ff8bd8b7e2f5ca112b Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:59:09 +0500 Subject: [PATCH] feat(domain): pricing model rename and new fields (Phase3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подготовка к новой модели цен МойСклад-style: - Product.PurchasePrice → ReferencePrice (справочная закупочная, не обязательная). + ReferencePriceUpdatedAt для 30-дневного таймера. - Product.+ Cost numeric(18,4) — себестоимость по скользящему среднему. - Product.+ LastSupplyAt — UTC последней Posted приёмки. - ProductGroup.+ MarkupPercent (5,2) — % наценки на cost для авто-розничной. - Organization.+ MultiplePriceTypesEnabled (default false) и ShowReferencePriceOnProduct (default true). - SupplyLine.+ RetailPriceManuallyOverridden + RetailPriceOverride — отметка ручной правки розничной в строке приёмки. Миграция Phase3a_PricingModel: RENAME + AddColumn'ы. Logic перерасчёта себестоимости, автонаценки, recalc-endpoint и Hangfire job — следующими коммитами. DTO/контроллеры/MoySklad-импорт/UI поля переименованы в referencePrice (включая фильтры списка товаров). UI-логика следующего коммита будет показывать Cost и кнопку «привести розничную к себестоимости»; пока referencePrice работает как раньше. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Catalog/ProductGroupsController.cs | 8 +- .../Controllers/Catalog/ProductsController.cs | 25 +- .../OrganizationSettingsController.cs | 14 +- src/food-market.api/Seed/DemoCatalogSeeder.cs | 2 +- .../Catalog/CatalogDtos.cs | 13 +- src/food-market.domain/Catalog/Product.cs | 15 +- .../Catalog/ProductGroup.cs | 4 + .../Organizations/Organization.cs | 9 + src/food-market.domain/Purchases/Supply.cs | 9 + .../MoySklad/MoySkladImportService.cs | 4 +- .../Configurations/CatalogConfigurations.cs | 3 +- ...425110000_Phase3a_PricingModel.Designer.cs | 1915 +++++++++++++++++ .../20260425110000_Phase3a_PricingModel.cs | 70 + .../Migrations/AppDbContextModelSnapshot.cs | 29 +- .../src/components/ProductPicker.tsx | 4 +- src/food-market.web/src/lib/types.ts | 6 +- .../src/pages/ProductEditPage.tsx | 14 +- .../src/pages/ProductsPage.tsx | 30 +- .../src/pages/SupplyEditPage.tsx | 2 +- 19 files changed, 2125 insertions(+), 51 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.Designer.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.cs diff --git a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs index f7fec07..46c0c15 100644 --- a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs @@ -46,7 +46,7 @@ public class ProductGroupsController : ControllerBase }; var items = await q .Skip(req.Skip).Take(req.Take) - .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive)) + .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -55,7 +55,7 @@ public class ProductGroupsController : ControllerBase public async Task> Get(Guid id, CancellationToken ct) { var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); - return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive); + return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent); } [HttpPost, Authorize(Roles = "Admin,Manager")] @@ -66,11 +66,12 @@ public async Task> Create([FromBody] ProductGroupI { Name = input.Name, ParentId = input.ParentId, Path = path, SortOrder = input.SortOrder, IsActive = input.IsActive, + MarkupPercent = input.MarkupPercent, }; _db.ProductGroups.Add(e); await _db.SaveChangesAsync(ct); return CreatedAtAction(nameof(Get), new { id = e.Id }, - new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive)); + new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive, e.MarkupPercent)); } [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] @@ -85,6 +86,7 @@ public async Task Update(Guid id, [FromBody] ProductGroupInput in e.Path = await BuildPathAsync(input.ParentId, input.Name, ct); e.SortOrder = input.SortOrder; e.IsActive = input.IsActive; + e.MarkupPercent = input.MarkupPercent; await _db.SaveChangesAsync(ct); return NoContent(); } diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 8f8bfbf..83a45b2 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -117,8 +117,8 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) if (packaging is not null) q = q.Where(p => p.Packaging == packaging); if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked); if (isActive is not null) q = q.Where(p => p.IsActive == isActive); - if (purchasePriceFrom is not null) q = q.Where(p => p.PurchasePrice >= purchasePriceFrom); - if (purchasePriceTo is not null) q = q.Where(p => p.PurchasePrice <= purchasePriceTo); + if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom); + if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo); if (!string.IsNullOrWhiteSpace(req.Search)) { @@ -140,8 +140,8 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) ("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name), ("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name), ("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name), - ("purchasePrice", false) => q.OrderBy(p => p.PurchasePrice).ThenBy(p => p.Name), - ("purchasePrice", true) => q.OrderByDescending(p => p.PurchasePrice).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), ("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name), ("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name), ("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name), @@ -174,7 +174,7 @@ public async Task> Create([FromBody] ProductInput input var allowFractional = await AllowFractionalAsync(ct); var e = new Product(); Apply(e, input); - e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional); + e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны. if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct); // Авто-артикул: если пользователь не указал — генерируем числовой. @@ -214,7 +214,7 @@ public async Task Update(Guid id, [FromBody] ProductInput input, var allowFractional = await AllowFractionalAsync(ct); Apply(e, input); - e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional); + e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); // Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем, // новые добавляем. Это позволяет избежать массового DELETE+INSERT, на @@ -328,7 +328,9 @@ public async Task>> BarcodeDuplicat p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null, p.IsService, p.Packaging, p.IsMarked, p.MinStock, p.MaxStock, - p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, + p.ReferencePrice, p.ReferencePriceUpdatedAt, + p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, + p.Cost, p.LastSupplyAt, p.ImageUrl, p.IsActive, p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(), p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList()); @@ -349,7 +351,14 @@ private static void Apply(Product e, ProductInput i) e.IsMarked = i.IsMarked; e.MinStock = i.MinStock; e.MaxStock = i.MaxStock; - e.PurchasePrice = i.PurchasePrice; + // ReferencePriceUpdatedAt подбиваем только при реальной смене цены + // (включая переход с null на значение и обратно). Этим помечаем, + // что цена «свежая», 30-дневный таймер автоперезаписи отсчитывается заново. + if (e.ReferencePrice != i.ReferencePrice) + { + e.ReferencePrice = i.ReferencePrice; + e.ReferencePriceUpdatedAt = i.ReferencePrice.HasValue ? DateTime.UtcNow : null; + } e.PurchaseCurrencyId = i.PurchaseCurrencyId; e.ImageUrl = i.ImageUrl; e.IsActive = i.IsActive; diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index 1ce5892..8222d47 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -34,7 +34,9 @@ public record OrgSettingsDto( bool ShowServiceOnProduct, bool ShowMarkedOnProduct, bool ShowMinMaxStock, - bool AllowFractionalPrices); + bool AllowFractionalPrices, + bool MultiplePriceTypesEnabled, + bool ShowReferencePriceOnProduct); // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). public record OrgSettingsInput( @@ -45,7 +47,9 @@ public record OrgSettingsInput( bool ShowServiceOnProduct, bool ShowMarkedOnProduct, bool ShowMinMaxStock, - bool AllowFractionalPrices); + bool AllowFractionalPrices, + bool MultiplePriceTypesEnabled, + bool ShowReferencePriceOnProduct); [HttpGet("settings")] public async Task> Get(CancellationToken ct) @@ -81,6 +85,8 @@ public async Task> Update([FromBody] OrgSettingsInp o.ShowMarkedOnProduct = input.ShowMarkedOnProduct; o.ShowMinMaxStock = input.ShowMinMaxStock; o.AllowFractionalPrices = input.AllowFractionalPrices; + o.MultiplePriceTypesEnabled = input.MultiplePriceTypesEnabled; + o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct; await _db.SaveChangesAsync(ct); await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); @@ -108,5 +114,7 @@ private async Task ReadVatRateAsync(string countryCode, CancellationTok o.ShowServiceOnProduct, o.ShowMarkedOnProduct, o.ShowMinMaxStock, - o.AllowFractionalPrices); + o.AllowFractionalPrices, + o.MultiplePriceTypesEnabled, + o.ShowReferencePriceOnProduct); } diff --git a/src/food-market.api/Seed/DemoCatalogSeeder.cs b/src/food-market.api/Seed/DemoCatalogSeeder.cs index 22e5e01..d1361d4 100644 --- a/src/food-market.api/Seed/DemoCatalogSeeder.cs +++ b/src/food-market.api/Seed/DemoCatalogSeeder.cs @@ -167,7 +167,7 @@ Guid AddGroup(string name, Guid? parentId) CountryOfOriginId = d.Country, Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece, IsActive = true, - PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2), + ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2), PurchaseCurrencyId = kzt.Id, Prices = [ diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index d657189..d411a83 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -26,7 +26,8 @@ public record RetailPointDto( string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive); public record ProductGroupDto( - Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive); + Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive, + decimal? MarkupPercent); public record CounterpartyDto( Guid Id, string Name, string? LegalName, CounterpartyType Type, @@ -47,7 +48,9 @@ public record ProductDto( Guid? CountryOfOriginId, string? CountryOfOriginName, bool IsService, Packaging Packaging, bool IsMarked, decimal? MinStock, decimal? MaxStock, - decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, + decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt, + Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, + decimal Cost, DateTime? LastSupplyAt, string? ImageUrl, bool IsActive, IReadOnlyList Prices, IReadOnlyList Barcodes); @@ -67,7 +70,9 @@ public record RetailPointInput( string Name, string? Code, Guid StoreId, string? Address = null, string? Phone = null, string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true); -public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true); +public record ProductGroupInput( + string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true, + [Range(0, 1000)] decimal? MarkupPercent = null); public record CounterpartyInput( string Name, string? LegalName, CounterpartyType Type, string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, @@ -81,7 +86,7 @@ public record ProductInput( Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null, - [Range(0, 1e10)] decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, + [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null, string? ImageUrl = null, bool IsActive = true, IReadOnlyList? Prices = null, IReadOnlyList? Barcodes = null); diff --git a/src/food-market.domain/Catalog/Product.cs b/src/food-market.domain/Catalog/Product.cs index 9cb3f99..09f0325 100644 --- a/src/food-market.domain/Catalog/Product.cs +++ b/src/food-market.domain/Catalog/Product.cs @@ -36,10 +36,23 @@ public class Product : TenantEntity public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений) public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа) - public decimal? PurchasePrice { get; set; } // закупочная цена по умолчанию + /// «Эталонная» (справочная) цена закупа. Не обязательная. + /// Автоматически заполняется UnitPrice'ом первой проведённой приёмки. + /// Через 30 дней без новых приёмок Hangfire-job переписывает на текущую Cost. + public decimal? ReferencePrice { get; set; } + public DateTime? ReferencePriceUpdatedAt { get; set; } public Guid? PurchaseCurrencyId { get; set; } public Currency? PurchaseCurrency { get; set; } + /// Себестоимость по скользящему среднему. Пересчитывается на каждой + /// проведённой приёмке: (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in). + /// Хранится с 4 знаками для точности; UI показывает 2. + public decimal Cost { get; set; } + + /// UTC-метка последней проведённой приёмки. Используется + /// 30-дневной job для перезаписи ReferencePrice на текущую Cost. + public DateTime? LastSupplyAt { get; set; } + public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) public bool IsActive { get; set; } = true; diff --git a/src/food-market.domain/Catalog/ProductGroup.cs b/src/food-market.domain/Catalog/ProductGroup.cs index 40972bb..4071373 100644 --- a/src/food-market.domain/Catalog/ProductGroup.cs +++ b/src/food-market.domain/Catalog/ProductGroup.cs @@ -12,4 +12,8 @@ public class ProductGroup : TenantEntity public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации public int SortOrder { get; set; } public bool IsActive { get; set; } = true; + + /// Процент наценки на себестоимость для автоматического расчёта + /// розничной цены при проведении приёмки. NULL = автонаценка отключена. + public decimal? MarkupPercent { get; set; } } diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 2175f91..16e0f3a 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -53,4 +53,13 @@ public class Organization : Entity /// иначе шаг 1 и значения округляются до целого даже при попытке прислать /// дробное через API. public bool AllowFractionalPrices { get; set; } + + /// Если true — в карточке товара рендерится список цен по всем + /// PriceType, есть страница «Настройки → Типы цен». Если false (default) + /// — одно поле «Розничная цена», работающее с дефолтным PriceType. + public bool MultiplePriceTypesEnabled { get; set; } + + /// Показывать ли в карточке товара поле «Эталонная цена». + /// Default: true. + public bool ShowReferencePriceOnProduct { get; set; } = true; } diff --git a/src/food-market.domain/Purchases/Supply.cs b/src/food-market.domain/Purchases/Supply.cs index baf2ac4..9a0aa00 100644 --- a/src/food-market.domain/Purchases/Supply.cs +++ b/src/food-market.domain/Purchases/Supply.cs @@ -52,4 +52,13 @@ public class SupplyLine : TenantEntity public decimal LineTotal { get; set; } public int SortOrder { get; set; } + + /// Если true — пользователь вручную задал розничную цену для + /// этой строки (через UI приёмки). При Posting автонаценка по Group.MarkupPercent + /// для этой строки пропускается. + public bool RetailPriceManuallyOverridden { get; set; } + + /// Розничная цена, которую пользователь вписал в колонке «Розничная» + /// строки приёмки. Применяется к Product.Prices[default] при Posting. + public decimal? RetailPriceOverride { get; set; } } diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 894251b..f6b5730 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -263,7 +263,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece; product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED"; product.IsActive = !p.Archived; - product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m; + product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m; updated++; if (progress is not null) progress.Updated = updated; } @@ -283,7 +283,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece, IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", IsActive = !p.Archived, - PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, + ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, PurchaseCurrencyId = kzt.Id, }; diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index d96e025..bae983a 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -118,7 +118,8 @@ private static void ConfigureProduct(EntityTypeBuilder b) 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.PurchasePrice).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. diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.Designer.cs new file mode 100644 index 0000000..36406f1 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.Designer.cs @@ -0,0 +1,1915 @@ +// +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("20260425110000_Phase3a_PricingModel")] + partial class Phase3a_PricingModel + { + /// + 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("IsActive") + .HasColumnType("boolean"); + + 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("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsRetail") + .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("IsActive") + .HasColumnType("boolean"); + + 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("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", "IsActive"); + + 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("IsActive") + .HasColumnType("boolean"); + + 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("IsActive") + .HasColumnType("boolean"); + + 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("MultiplePriceTypesEnabled") + .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/20260425110000_Phase3a_PricingModel.cs b/src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.cs new file mode 100644 index 0000000..ee7af7f --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Новая модель цен: + /// - products.PurchasePrice → ReferencePrice (справочная цена закупа) + /// - products.+ ReferencePriceUpdatedAt timestamptz NULL + /// - products.+ Cost numeric(18,4) NOT NULL DEFAULT 0 (себестоимость, скользящее среднее) + /// - products.+ LastSupplyAt timestamptz NULL + /// - product_groups.+ MarkupPercent numeric(5,2) NULL (% наценки на cost для авто-розничной) + /// - organizations.+ MultiplePriceTypesEnabled boolean DEFAULT false + /// - organizations.+ ShowReferencePriceOnProduct boolean DEFAULT true + /// - supply_lines.+ RetailPriceManuallyOverridden boolean DEFAULT false + /// - supply_lines.+ RetailPriceOverride numeric(18,2) NULL + /// + public partial class Phase3a_PricingModel : Migration + { + protected override void Up(MigrationBuilder b) + { + b.RenameColumn(name: "PurchasePrice", schema: "public", table: "products", newName: "ReferencePrice"); + + b.AddColumn( + name: "ReferencePriceUpdatedAt", schema: "public", table: "products", + type: "timestamp with time zone", nullable: true); + + b.AddColumn( + name: "Cost", schema: "public", table: "products", + type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m); + + b.AddColumn( + name: "LastSupplyAt", schema: "public", table: "products", + type: "timestamp with time zone", nullable: true); + + b.AddColumn( + name: "MarkupPercent", schema: "public", table: "product_groups", + type: "numeric(5,2)", precision: 5, scale: 2, nullable: true); + + b.AddColumn( + name: "MultiplePriceTypesEnabled", schema: "public", table: "organizations", + type: "boolean", nullable: false, defaultValue: false); + + b.AddColumn( + name: "ShowReferencePriceOnProduct", schema: "public", table: "organizations", + type: "boolean", nullable: false, defaultValue: true); + + b.AddColumn( + name: "RetailPriceManuallyOverridden", schema: "public", table: "supply_lines", + type: "boolean", nullable: false, defaultValue: false); + + b.AddColumn( + name: "RetailPriceOverride", schema: "public", table: "supply_lines", + type: "numeric(18,2)", precision: 18, scale: 2, nullable: true); + } + + protected override void Down(MigrationBuilder b) + { + b.DropColumn(name: "RetailPriceOverride", schema: "public", table: "supply_lines"); + b.DropColumn(name: "RetailPriceManuallyOverridden", schema: "public", table: "supply_lines"); + b.DropColumn(name: "ShowReferencePriceOnProduct", schema: "public", table: "organizations"); + b.DropColumn(name: "MultiplePriceTypesEnabled", schema: "public", table: "organizations"); + b.DropColumn(name: "MarkupPercent", schema: "public", table: "product_groups"); + b.DropColumn(name: "LastSupplyAt", schema: "public", table: "products"); + b.DropColumn(name: "Cost", schema: "public", table: "products"); + b.DropColumn(name: "ReferencePriceUpdatedAt", schema: "public", table: "products"); + b.RenameColumn(name: "ReferencePrice", schema: "public", table: "products", newName: "PurchasePrice"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 67cf549..9ba519a 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -594,13 +594,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("PurchasePrice") + b.Property("ReferencePrice") .HasPrecision(18, 4) .HasColumnType("numeric(18,4)"); + b.Property("ReferencePriceUpdatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("UnitOfMeasureId") .HasColumnType("uuid"); @@ -690,6 +700,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("MarkupPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -1093,9 +1107,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MultiCurrencyEnabled") .HasColumnType("boolean"); + b.Property("MultiplePriceTypesEnabled") + .HasColumnType("boolean"); + b.Property("ShowMarkedOnProduct") .HasColumnType("boolean"); + b.Property("ShowReferencePriceOnProduct") + .HasColumnType("boolean"); + b.Property("ShowMinMaxStock") .HasColumnType("boolean"); @@ -1224,6 +1244,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); diff --git a/src/food-market.web/src/components/ProductPicker.tsx b/src/food-market.web/src/components/ProductPicker.tsx index a2453da..f12de0e 100644 --- a/src/food-market.web/src/components/ProductPicker.tsx +++ b/src/food-market.web/src/components/ProductPicker.tsx @@ -74,9 +74,9 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то · {p.unitName} - {p.purchasePrice !== null && ( + {p.referencePrice !== null && (
- закуп: {p.purchasePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''} + закуп: {p.referencePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''}
)} diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index d3f3b82..aa8bdaf 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -38,7 +38,7 @@ export interface RetailPoint { id: string; name: string; code: string | null; storeId: string; storeName: string | null; address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean } -export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean } +export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean; markupPercent: number | null } export interface Counterparty { id: string; name: string; legalName: string | null; type: CounterpartyType; bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null; @@ -57,7 +57,9 @@ export interface Product { countryOfOriginId: string | null; countryOfOriginName: string | null; isService: boolean; packaging: Packaging; isMarked: boolean; minStock: number | null; maxStock: number | null; - purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null; + referencePrice: number | null; referencePriceUpdatedAt: string | null; + purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null; + cost: number; lastSupplyAt: string | null; imageUrl: string | null; isActive: boolean; prices: ProductPrice[]; barcodes: ProductBarcode[] } diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 33b0090..63ecb5f 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -32,7 +32,7 @@ interface Form { isActive: boolean minStock: string maxStock: string - purchasePrice: string + referencePrice: string purchaseCurrencyId: string imageUrl: string prices: PriceRow[] @@ -46,7 +46,7 @@ const emptyForm: Form = { productGroupId: '', defaultSupplierId: '', countryOfOriginId: '', isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true, minStock: '', maxStock: '', - purchasePrice: '', purchaseCurrencyId: '', + referencePrice: '', purchaseCurrencyId: '', imageUrl: '', prices: [], barcodes: [], @@ -87,7 +87,7 @@ export function ProductEditPage() { isActive: p.isActive, minStock: p.minStock?.toString() ?? '', maxStock: p.maxStock?.toString() ?? '', - purchasePrice: p.purchasePrice?.toString() ?? '', + referencePrice: p.referencePrice?.toString() ?? '', purchaseCurrencyId: p.purchaseCurrencyId ?? '', imageUrl: p.imageUrl ?? '', prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })), @@ -147,7 +147,7 @@ export function ProductEditPage() { isActive: form.isActive, minStock: form.minStock === '' ? null : Number(form.minStock), maxStock: form.maxStock === '' ? null : Number(form.maxStock), - purchasePrice: form.purchasePrice === '' ? null : Number(form.purchasePrice), + referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice), purchaseCurrencyId: form.purchaseCurrencyId || null, imageUrl: form.imageUrl || null, prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })), @@ -317,10 +317,10 @@ export function ProductEditPage() {
- + setForm({ ...form, purchasePrice: n == null ? '' : String(n) })} + value={form.referencePrice === '' ? null : Number(form.referencePrice)} + onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })} currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined} currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined} diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index 1f9520c..5353649 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 - purchasePriceFrom: number | null - purchasePriceTo: number | null + referencePriceFrom: number | null + referencePriceTo: number | null } const defaultFilters: Filters = { @@ -31,8 +31,8 @@ const defaultFilters: Filters = { isService: 'all', packaging: null, isMarked: 'all', - purchasePriceFrom: null, - purchasePriceTo: null, + referencePriceFrom: null, + referencePriceTo: null, } const toExtra = (f: Filters): Record => { @@ -42,8 +42,8 @@ const toExtra = (f: Filters): Record { if (f.isService !== 'all') n++ if (f.packaging) n++ if (f.isMarked !== 'all') n++ - if (f.purchasePriceFrom != null) n++ - if (f.purchasePriceTo != null) n++ + if (f.referencePriceFrom != null) n++ + if (f.referencePriceTo != null) n++ return n } @@ -127,10 +127,10 @@ export function ProductsPage() { { header: 'Штрихкод', width: '160px', cell: (r) => ( {r.barcodes[0]?.code ?? '—'} )}, - { header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => { - if (r.purchasePrice == null) return '—' + { header: 'Эталонная цена', width: '160px', className: 'text-right font-mono', sortKey: 'referencePrice', cell: (r) => { + if (r.referencePrice == null) return '—' const fractional = org.data?.allowFractionalPrices ?? false - const num = r.purchasePrice.toLocaleString('ru', + const num = r.referencePrice.toLocaleString('ru', fractional ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } : { maximumFractionDigits: 0 }) @@ -226,8 +226,8 @@ export function ProductsPage() { Закупочная цена
{ setFilters({ ...filters, purchasePriceFrom: n }); setPage(1) }} + value={filters.referencePriceFrom} + onChange={(n) => { setFilters({ ...filters, referencePriceFrom: n }); setPage(1) }} currencyCode={org.data?.defaultCurrencyCode ?? undefined} currencySymbol={org.data?.defaultCurrencySymbol ?? undefined} @@ -236,8 +236,8 @@ export function ProductsPage() {
{ setFilters({ ...filters, purchasePriceTo: n }); setPage(1) }} + value={filters.referencePriceTo} + onChange={(n) => { setFilters({ ...filters, referencePriceTo: n }); setPage(1) }} currencyCode={org.data?.defaultCurrencyCode ?? undefined} currencySymbol={org.data?.defaultCurrencySymbol ?? undefined} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index c303500..c596725 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -175,7 +175,7 @@ export function SupplyEditPage() { productArticle: p.article, unitName: p.unitName, quantity: 1, - unitPrice: p.purchasePrice ?? 0, + unitPrice: p.referencePrice ?? 0, }], }) }