diff --git a/src/food-market.api/Controllers/Catalog/CountriesController.cs b/src/food-market.api/Controllers/Catalog/CountriesController.cs index 28cd119..08cd897 100644 --- a/src/food-market.api/Controllers/Catalog/CountriesController.cs +++ b/src/food-market.api/Controllers/Catalog/CountriesController.cs @@ -20,7 +20,7 @@ public class CountriesController : ControllerBase [HttpGet] public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) { - var q = _db.Countries.AsNoTracking().AsQueryable(); + var q = _db.Countries.Include(c => c.DefaultCurrency).AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(req.Search)) { var s = req.Search.Trim().ToLower(); @@ -30,7 +30,12 @@ public async Task>> List([FromQuery] PagedR var items = await q .OrderBy(c => c.SortOrder).ThenBy(c => c.Name) .Skip(req.Skip).Take(req.Take) - .Select(c => new CountryDto(c.Id, c.Code, c.Name, c.SortOrder)) + .Select(c => new CountryDto( + c.Id, c.Code, c.Name, c.SortOrder, + c.DefaultCurrencyId, + c.DefaultCurrency != null ? c.DefaultCurrency.Code : null, + c.DefaultCurrency != null ? c.DefaultCurrency.Symbol : null, + c.VatRate)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -38,20 +43,28 @@ public async Task>> List([FromQuery] PagedR [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { - var c = await _db.Countries.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); - return c is null ? NotFound() : new CountryDto(c.Id, c.Code, c.Name, c.SortOrder); + var c = await _db.Countries.Include(x => x.DefaultCurrency).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return c is null ? NotFound() : Project(c); } - [HttpPost, Authorize(Roles = "SuperAdmin")] + [HttpPost, Authorize(Roles = "SuperAdmin,Admin")] public async Task> Create([FromBody] CountryInput input, CancellationToken ct) { - var e = new Country { Code = input.Code.Trim().ToUpper(), Name = input.Name, SortOrder = input.SortOrder }; + var e = new Country + { + Code = input.Code.Trim().ToUpper(), + Name = input.Name, + SortOrder = input.SortOrder, + DefaultCurrencyId = input.DefaultCurrencyId, + VatRate = input.VatRate, + }; _db.Countries.Add(e); await _db.SaveChangesAsync(ct); - return CreatedAtAction(nameof(Get), new { id = e.Id }, new CountryDto(e.Id, e.Code, e.Name, e.SortOrder)); + await _db.Entry(e).Reference(x => x.DefaultCurrency).LoadAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, Project(e)); } - [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")] + [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")] public async Task Update(Guid id, [FromBody] CountryInput input, CancellationToken ct) { var e = await _db.Countries.FirstOrDefaultAsync(x => x.Id == id, ct); @@ -59,6 +72,8 @@ public async Task Update(Guid id, [FromBody] CountryInput input, e.Code = input.Code.Trim().ToUpper(); e.Name = input.Name; e.SortOrder = input.SortOrder; + e.DefaultCurrencyId = input.DefaultCurrencyId; + e.VatRate = input.VatRate; await _db.SaveChangesAsync(ct); return NoContent(); } @@ -72,4 +87,8 @@ public async Task Delete(Guid id, CancellationToken ct) await _db.SaveChangesAsync(ct); return NoContent(); } + + private static CountryDto Project(Country c) => new( + c.Id, c.Code, c.Name, c.SortOrder, + c.DefaultCurrencyId, c.DefaultCurrency?.Code, c.DefaultCurrency?.Symbol, c.VatRate); } diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 2c299b7..dd74e6c 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -1,5 +1,6 @@ using foodmarket.Application.Catalog; using foodmarket.Application.Common; +using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; @@ -14,8 +15,30 @@ namespace foodmarket.Api.Controllers.Catalog; public class ProductsController : ControllerBase { private readonly AppDbContext _db; + private readonly ITenantContext _tenant; - public ProductsController(AppDbContext db) => _db = db; + public ProductsController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + // Дефолт Vat для нового товара — из страны организации (Country.VatRate). + private async Task ResolveDefaultVatAsync(CancellationToken ct) + { + var orgId = _tenant.OrganizationId; + if (orgId is null) return 0; + var countryCode = await _db.Organizations + .Where(o => o.Id == orgId) + .Select(o => o.CountryCode) + .FirstOrDefaultAsync(ct); + if (string.IsNullOrEmpty(countryCode)) return 0; + var rate = await _db.Countries + .Where(c => c.Code == countryCode) + .Select(c => (decimal?)c.VatRate) + .FirstOrDefaultAsync(ct); + return (int)Math.Round(rate ?? 0m); + } [HttpGet] public async Task>> List( @@ -81,6 +104,8 @@ public async Task> Create([FromBody] ProductInput input { var e = new Product(); Apply(e, input); + // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны. + if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct); foreach (var b in input.Barcodes ?? []) e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); @@ -102,7 +127,10 @@ public async Task Update(Guid id, [FromBody] ProductInput input, .FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); + var existingVat = e.Vat; Apply(e, input); + // Если UI не передал Vat (скрыт) — сохраняем что было, не обнуляем. + if (input.Vat is null) e.Vat = existingVat; _db.ProductBarcodes.RemoveRange(e.Barcodes); e.Barcodes.Clear(); @@ -162,7 +190,7 @@ private static void Apply(Product e, ProductInput i) e.Article = i.Article; e.Description = i.Description; e.UnitOfMeasureId = i.UnitOfMeasureId; - e.Vat = i.Vat; + if (i.Vat is int v) e.Vat = v; e.VatEnabled = i.VatEnabled; e.ProductGroupId = i.ProductGroupId; e.DefaultSupplierId = i.DefaultSupplierId; diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index 5420b59..1a8d102 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -28,14 +28,16 @@ public record OrgSettingsDto( string? DefaultCurrencyCode, string? DefaultCurrencySymbol, bool MultiCurrencyEnabled, - int DefaultVat); + // VAT read-only: из страны организации (countries.VatRate). Источник правды — справочник стран. + decimal VatRate, + bool ShowVatEnabledOnProduct); public record OrgSettingsInput( string Name, string CountryCode, Guid? DefaultCurrencyId, bool MultiCurrencyEnabled, - int DefaultVat); + bool ShowVatEnabledOnProduct); [HttpGet("settings")] public async Task> Get(CancellationToken ct) @@ -45,7 +47,8 @@ public async Task> Get(CancellationToken ct) .Include(o => o.DefaultCurrency) .FirstOrDefaultAsync(o => o.Id == orgId, ct); if (o is null) return NotFound(); - return Project(o); + var vat = await ReadVatRateAsync(o.CountryCode, ct); + return Project(o, vat); } [HttpPut("settings"), Authorize(Roles = "Admin,Manager")] @@ -61,19 +64,29 @@ public async Task> Update([FromBody] OrgSettingsInp o.CountryCode = input.CountryCode; o.DefaultCurrencyId = input.DefaultCurrencyId; o.MultiCurrencyEnabled = input.MultiCurrencyEnabled; - o.DefaultVat = input.DefaultVat; + o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct; await _db.SaveChangesAsync(ct); - // Re-read чтобы подтянуть DefaultCurrency. await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); - return Project(o); + var vat = await ReadVatRateAsync(o.CountryCode, ct); + return Project(o, vat); } - private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o) => new( + private async Task ReadVatRateAsync(string countryCode, CancellationToken ct) + { + var rate = await _db.Countries + .Where(c => c.Code == countryCode) + .Select(c => (decimal?)c.VatRate) + .FirstOrDefaultAsync(ct); + return rate ?? 0m; + } + + private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o, decimal vat) => new( o.Id, o.Name, o.CountryCode, o.DefaultCurrencyId, o.DefaultCurrency?.Code, o.DefaultCurrency?.Symbol, o.MultiCurrencyEnabled, - o.DefaultVat); + vat, + o.ShowVatEnabledOnProduct); } diff --git a/src/food-market.api/Seed/DemoCatalogSeeder.cs b/src/food-market.api/Seed/DemoCatalogSeeder.cs index ec711ef..5a8acc8 100644 --- a/src/food-market.api/Seed/DemoCatalogSeeder.cs +++ b/src/food-market.api/Seed/DemoCatalogSeeder.cs @@ -32,7 +32,7 @@ public async Task StartAsync(CancellationToken ct) var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct); if (hasProducts) return; - // KZ default VAT is 16% (applies as int on Product). + // KZ дефолт — 16% (из Country.VatRate), льготные категории (хлеб) — 0%. const int vatDefault = 16; const int vat0 = 0; @@ -156,6 +156,7 @@ Guid AddGroup(string name, Guid? parentId) Name = d.Name, Article = d.Article, UnitOfMeasureId = d.Unit, + // Хлеб/батон/лепёшка — 0% (льготная категория в KZ). Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка") ? vat0 : vatDefault, VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")), diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index 66772f0..816ef9a 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -51,16 +51,13 @@ public async Task StartAsync(CancellationToken ct) Phone = "+7 (777) 000-00-00", Email = "demo@food-market.local", DefaultCurrencyId = kzt?.Id, - DefaultVat = 16, }; db.Organizations.Add(demoOrg); await db.SaveChangesAsync(ct); } else if (demoOrg.DefaultCurrencyId is null && kzt is not null) { - // backfill для существующей организации на стенде demoOrg.DefaultCurrencyId = kzt.Id; - if (demoOrg.DefaultVat == 0) demoOrg.DefaultVat = 16; await db.SaveChangesAsync(ct); } diff --git a/src/food-market.api/Seed/SystemReferenceSeeder.cs b/src/food-market.api/Seed/SystemReferenceSeeder.cs index 48339c8..8913dd0 100644 --- a/src/food-market.api/Seed/SystemReferenceSeeder.cs +++ b/src/food-market.api/Seed/SystemReferenceSeeder.cs @@ -19,55 +19,55 @@ public async Task StartAsync(CancellationToken ct) using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + await SeedCurrenciesAsync(db, ct); // первыми — на них ссылаются страны + await db.SaveChangesAsync(ct); + await SeedCountriesAsync(db, ct); - await SeedCurrenciesAsync(db, ct); + await db.SaveChangesAsync(ct); + + await BackfillCountryDefaultsAsync(db, ct); await db.SaveChangesAsync(ct); } public Task StopAsync(CancellationToken ct) => Task.CompletedTask; - private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct) - { - // Kazakhstan first, then common trade partners. - var wanted = new[] - { - new Country { Code = "KZ", Name = "Казахстан", SortOrder = 1 }, - new Country { Code = "RU", Name = "Россия", SortOrder = 2 }, - new Country { Code = "CN", Name = "Китай", SortOrder = 3 }, - new Country { Code = "TR", Name = "Турция", SortOrder = 4 }, - new Country { Code = "BY", Name = "Беларусь", SortOrder = 5 }, - new Country { Code = "UZ", Name = "Узбекистан", SortOrder = 6 }, - new Country { Code = "KG", Name = "Кыргызстан", SortOrder = 7 }, - new Country { Code = "DE", Name = "Германия", SortOrder = 10 }, - new Country { Code = "US", Name = "США", SortOrder = 11 }, - new Country { Code = "KR", Name = "Южная Корея", SortOrder = 12 }, - new Country { Code = "IT", Name = "Италия", SortOrder = 13 }, - new Country { Code = "PL", Name = "Польша", SortOrder = 14 }, - }; + private record CountrySeed(string Code, string Name, int SortOrder, string CurrencyCode, decimal VatRate); - var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct); - foreach (var c in wanted) - { - if (!existingCodes.Contains(c.Code)) - { - db.Countries.Add(c); - } - } - } + private static readonly CountrySeed[] CountrySeeds = + { + new("KZ", "Казахстан", 1, "KZT", 16m), + new("RU", "Россия", 2, "RUB", 20m), + new("CN", "Китай", 3, "CNY", 13m), + new("TR", "Турция", 4, "TRY", 18m), + new("BY", "Беларусь", 5, "BYN", 20m), + new("UZ", "Узбекистан", 6, "UZS", 12m), + new("KG", "Кыргызстан", 7, "KGS", 12m), + new("DE", "Германия", 10, "EUR", 19m), + new("US", "США", 11, "USD", 0m), + new("KR", "Южная Корея", 12, "KRW", 10m), + new("IT", "Италия", 13, "EUR", 22m), + new("PL", "Польша", 14, "PLN", 23m), + }; + + private static readonly Currency[] CurrencySeeds = + { + new() { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 }, + new() { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 }, + new() { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 }, + new() { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 }, + new() { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 }, + new() { Code = "BYN", Name = "Белорусский рубль", Symbol = "Br", MinorUnit = 2 }, + new() { Code = "UZS", Name = "Узбекский сум", Symbol = "сум", MinorUnit = 2 }, + new() { Code = "KGS", Name = "Кыргызский сом", Symbol = "сом", MinorUnit = 2 }, + new() { Code = "TRY", Name = "Турецкая лира", Symbol = "₺", MinorUnit = 2 }, + new() { Code = "KRW", Name = "Южнокорейская вона", Symbol = "₩", MinorUnit = 0 }, + new() { Code = "PLN", Name = "Польский злотый", Symbol = "zł", MinorUnit = 2 }, + }; private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken ct) { - var wanted = new[] - { - new Currency { Code = "KZT", Name = "Тенге", Symbol = "₸", MinorUnit = 2 }, - new Currency { Code = "RUB", Name = "Российский рубль", Symbol = "₽", MinorUnit = 2 }, - new Currency { Code = "USD", Name = "Доллар США", Symbol = "$", MinorUnit = 2 }, - new Currency { Code = "EUR", Name = "Евро", Symbol = "€", MinorUnit = 2 }, - new Currency { Code = "CNY", Name = "Китайский юань", Symbol = "¥", MinorUnit = 2 }, - }; - var existingCodes = await db.Currencies.Select(c => c.Code).ToListAsync(ct); - foreach (var c in wanted) + foreach (var c in CurrencySeeds) { if (!existingCodes.Contains(c.Code)) { @@ -75,4 +75,36 @@ private static async Task SeedCurrenciesAsync(AppDbContext db, CancellationToken } } } + + private static async Task SeedCountriesAsync(AppDbContext db, CancellationToken ct) + { + var existingCodes = await db.Countries.Select(c => c.Code).ToListAsync(ct); + foreach (var s in CountrySeeds) + { + if (!existingCodes.Contains(s.Code)) + { + db.Countries.Add(new Country { Code = s.Code, Name = s.Name, SortOrder = s.SortOrder, VatRate = s.VatRate }); + } + } + } + + private static async Task BackfillCountryDefaultsAsync(AppDbContext db, CancellationToken ct) + { + // Привязываем валюту и НДС к странам (обновит и новосозданные и существующие). + var currenciesByCode = await db.Currencies.ToDictionaryAsync(c => c.Code, ct); + var countries = await db.Countries.ToListAsync(ct); + foreach (var country in countries) + { + var seed = Array.Find(CountrySeeds, s => s.Code == country.Code); + if (seed is null) continue; + if (country.DefaultCurrencyId is null && currenciesByCode.TryGetValue(seed.CurrencyCode, out var cur)) + { + country.DefaultCurrencyId = cur.Id; + } + if (country.VatRate == 0m && seed.VatRate > 0m) + { + country.VatRate = seed.VatRate; + } + } + } } diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index e2f8e25..b68df8e 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -2,7 +2,11 @@ namespace foodmarket.Application.Catalog; -public record CountryDto(Guid Id, string Code, string Name, int SortOrder); +public record CountryDto( + Guid Id, string Code, string Name, int SortOrder, + Guid? DefaultCurrencyId, string? DefaultCurrencyCode, string? DefaultCurrencySymbol, + decimal VatRate); + public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive); @@ -48,7 +52,9 @@ public record ProductDto( IReadOnlyList Barcodes); // Upsert payloads (input) -public record CountryInput(string Code, string Name, int SortOrder = 0); +public record CountryInput( + string Code, string Name, int SortOrder = 0, + Guid? DefaultCurrencyId = null, decimal VatRate = 0m); public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true); public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true); public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); @@ -70,7 +76,7 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId); public record ProductInput( string Name, string? Article, string? Description, - Guid UnitOfMeasureId, int Vat, bool VatEnabled, + Guid UnitOfMeasureId, int? Vat, bool VatEnabled, Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, decimal? MinStock = null, decimal? MaxStock = null, diff --git a/src/food-market.domain/Catalog/Country.cs b/src/food-market.domain/Catalog/Country.cs index 4e241e2..386e128 100644 --- a/src/food-market.domain/Catalog/Country.cs +++ b/src/food-market.domain/Catalog/Country.cs @@ -8,8 +8,13 @@ public class Country : Entity public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ" public string Name { get; set; } = null!; public int SortOrder { get; set; } - /// Валюта по умолчанию для этой страны — при выборе страны в настройках - /// организации её валюта подтягивается автоматически. + /// Валюта страны — при выборе страны в настройках организации + /// она становится валютой по умолчанию для этой организации. public Guid? DefaultCurrencyId { get; set; } public Currency? DefaultCurrency { get; set; } + + /// Ставка НДС этой страны, в процентах (например 16.00 для KZ, + /// 20.00 для RU). Единственный источник правды для ставки НДС — + /// Product.Vat на товаре не хранится. + public decimal VatRate { get; set; } } diff --git a/src/food-market.domain/Catalog/Product.cs b/src/food-market.domain/Catalog/Product.cs index 4788e7a..44201c3 100644 --- a/src/food-market.domain/Catalog/Product.cs +++ b/src/food-market.domain/Catalog/Product.cs @@ -11,8 +11,11 @@ public class Product : TenantEntity public Guid UnitOfMeasureId { get; set; } public UnitOfMeasure? UnitOfMeasure { get; set; } - // Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad. - // VatEnabled=true → НДС применяется, false → без НДС. + // Ставка НДС в процентах (0/10/12/16/20). Дефолт при создании берётся из + // Country.VatRate организации, пользователь может менять для исключений + // (хлеб = 0% и т.п.) — но только если в настройках организации включена + // галка «Указывать ставку НДС на товаре». VatEnabled — «в том числе НДС»: + // применяется ли ставка на позицию в документах. public int Vat { get; set; } public bool VatEnabled { get; set; } = true; diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 3e5ef3b..56f7132 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -26,7 +26,9 @@ public class Organization : Entity /// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency. public bool MultiCurrencyEnabled { get; set; } - /// Ставка НДС по умолчанию для новых товаров (KZ=16%, RU=20%). - /// Само значение применяется к товару при создании; пользователь может менять. - public int DefaultVat { get; set; } = 16; + /// Показывать ли пользователю галку «В том числе НДС» на форме товара. + /// Если false (по умолчанию) — магазин работает с одной ставкой НДС и галка + /// скрыта, все товары считаются с НДС. Если true — можно для отдельных товаров + /// (хлеб, медикаменты) снимать галку. + public bool ShowVatEnabledOnProduct { get; set; } } diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 37442c6..9cf68a2 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -144,9 +144,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) var orgId = organizationIdOverride ?? _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); - // Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't - // carry its own vat from MoySklad. - const int kzDefaultVat = 16; + // Дефолт VAT — из страны организации (Country.VatRate). + var defaultVat = (int)Math.Round(await _db.Countries + .Where(c => c.Code == (_db.Organizations + .Where(o => o.Id == orgId).Select(o => o.CountryCode).First())) + .Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m); var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct) ?? await _db.UnitsOfMeasure.FirstAsync(ct); var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct) @@ -218,8 +220,10 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) try { - var vat = p.Vat ?? kzDefaultVat; - var vatEnabled = (p.Vat ?? kzDefaultVat) > 0; + // Vat товара: из MoySklad.vat если задано, иначе дефолт страны. + // VatEnabled: если p.Vat явно 0 — «без НДС» (льготная категория). + var vat = p.Vat ?? defaultVat; + var vatEnabled = (p.Vat ?? -1) != 0; Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId && localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null; Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null; diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 6844a8a..d96e025 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -27,6 +27,7 @@ private static void ConfigureCountry(EntityTypeBuilder b) b.ToTable("countries"); b.Property(x => x.Code).HasMaxLength(2).IsRequired(); b.Property(x => x.Name).HasMaxLength(100).IsRequired(); + b.Property(x => x.VatRate).HasPrecision(5, 2); b.HasOne(x => x.DefaultCurrency).WithMany().HasForeignKey(x => x.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict); b.HasIndex(x => x.Code).IsUnique(); } @@ -120,6 +121,8 @@ private static void ConfigureProduct(EntityTypeBuilder b) b.Property(x => x.PurchasePrice).HasPrecision(18, 4); b.Property(x => x.ImageUrl).HasMaxLength(1000); + // VatEnabled defaults to true в БД — при миграции existing rows сохраняют true. + b.Property(x => x.VatEnabled).HasDefaultValue(true); b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260424062000_Phase5_VatAsCountryProperty.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260424062000_Phase5_VatAsCountryProperty.Designer.cs new file mode 100644 index 0000000..807a533 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260424062000_Phase5_VatAsCountryProperty.Designer.cs @@ -0,0 +1,1881 @@ +// +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("20260424062000_Phase5_VatAsCountryProperty")] + partial class Phase5_VatAsCountryProperty + { + /// + 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("SortOrder") + .HasColumnType("integer"); + + 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("IsActive") + .HasColumnType("boolean"); + + 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("PurchaseCurrencyId") + .HasColumnType("uuid"); + + b.Property("PurchasePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitOfMeasureId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vat") + .HasColumnType("integer"); + + 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("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("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("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("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/20260424062000_Phase5_VatAsCountryProperty.cs b/src/food-market.infrastructure/Persistence/Migrations/20260424062000_Phase5_VatAsCountryProperty.cs new file mode 100644 index 0000000..d73c3d2 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260424062000_Phase5_VatAsCountryProperty.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// VAT теперь основывается на стране, но хранится и на товаре: + /// - countries.VatRate (numeric(5,2), default 0) — ставка страны, источник правды. + /// - organizations.ShowVatEnabledOnProduct (bool, default false) — показывать ли + /// поля Vat/VatEnabled на карточке товара в UI; внутренне они есть всегда. + /// - organizations.DefaultVat (int) удаляется (заменён на Country.VatRate). + /// - products.Vat ОСТАЁТСЯ: для KZ есть льготные категории (хлеб/молоко/ + /// лекарства = 0%) и фискальный чек требует ставку на каждой позиции. + /// Seed: KZ=16%, RU=20%, BY=20%, US=0%, DE=19%, CN=13%, TR=18%, UZ=12%, KG=12%, + /// KR=10%, IT=22%, PL=23%. + public partial class Phase5_VatAsCountryProperty : Migration + { + protected override void Up(MigrationBuilder b) + { + b.AddColumn( + name: "VatRate", schema: "public", table: "countries", + type: "numeric(5,2)", precision: 5, scale: 2, nullable: false, defaultValue: 0m); + + b.AddColumn( + name: "ShowVatEnabledOnProduct", schema: "public", table: "organizations", + type: "boolean", nullable: false, defaultValue: false); + + b.DropColumn(name: "DefaultVat", schema: "public", table: "organizations"); + + b.Sql(""" + UPDATE public.countries SET "VatRate" = CASE "Code" + WHEN 'KZ' THEN 16 + WHEN 'RU' THEN 20 + WHEN 'BY' THEN 20 + WHEN 'DE' THEN 19 + WHEN 'CN' THEN 13 + WHEN 'TR' THEN 18 + WHEN 'UZ' THEN 12 + WHEN 'KG' THEN 12 + WHEN 'KR' THEN 10 + WHEN 'IT' THEN 22 + WHEN 'PL' THEN 23 + ELSE 0 + END; + """); + } + + protected override void Down(MigrationBuilder b) + { + b.AddColumn( + name: "DefaultVat", schema: "public", table: "organizations", + type: "integer", nullable: false, defaultValue: 16); + + b.DropColumn(name: "ShowVatEnabledOnProduct", schema: "public", table: "organizations"); + b.DropColumn(name: "VatRate", schema: "public", table: "countries"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 7248937..0da69a2 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -448,6 +448,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("VatRate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + b.HasKey("Id"); b.HasIndex("Code") @@ -613,7 +617,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer"); b.Property("VatEnabled") - .HasColumnType("boolean"); + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); b.HasKey("Id"); @@ -1079,9 +1085,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DefaultCurrencyId") .HasColumnType("uuid"); - b.Property("DefaultVat") - .HasColumnType("integer"); - b.Property("IsActive") .HasColumnType("boolean"); @@ -1092,6 +1095,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MultiCurrencyEnabled") .HasColumnType("boolean"); + b.Property("ShowVatEnabledOnProduct") + .HasColumnType("boolean"); + b.Property("Name") .IsRequired() .HasMaxLength(200) diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 1e7aff8..c859dc1 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -20,7 +20,13 @@ export const packagingLabel: Record = { [Packaging.Liquid]: 'Разливной', } -export interface Country { id: string; code: string; name: string; sortOrder: number } +export interface Country { + id: string; code: string; name: string; sortOrder: number + defaultCurrencyId: string | null + defaultCurrencyCode: string | null + defaultCurrencySymbol: string | null + vatRate: number +} export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean } export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean } export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean } diff --git a/src/food-market.web/src/lib/useOrgSettings.ts b/src/food-market.web/src/lib/useOrgSettings.ts index d9f433d..babcc6f 100644 --- a/src/food-market.web/src/lib/useOrgSettings.ts +++ b/src/food-market.web/src/lib/useOrgSettings.ts @@ -9,7 +9,8 @@ export interface OrgSettings { defaultCurrencyCode: string | null defaultCurrencySymbol: string | null multiCurrencyEnabled: boolean - defaultVat: number + vatRate: number + showVatEnabledOnProduct: boolean } export function useOrgSettings() { diff --git a/src/food-market.web/src/pages/CountriesPage.tsx b/src/food-market.web/src/pages/CountriesPage.tsx index d9ec650..609af73 100644 --- a/src/food-market.web/src/pages/CountriesPage.tsx +++ b/src/food-market.web/src/pages/CountriesPage.tsx @@ -1,32 +1,132 @@ +import { useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' -import { useCatalogList } from '@/lib/useCatalog' +import { Button } from '@/components/Button' +import { Modal } from '@/components/Modal' +import { Field, TextInput, Select } from '@/components/Field' +import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' +import { useCurrencies } from '@/lib/useLookups' import type { Country } from '@/lib/types' +const URL = '/api/catalog/countries' + +interface Form { + id?: string + code: string + name: string + sortOrder: number + defaultCurrencyId: string | null + vatRate: number +} + +const blank: Form = { code: '', name: '', sortOrder: 0, defaultCurrencyId: null, vatRate: 0 } + export function CountriesPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList('/api/catalog/countries') + const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { create, update, remove } = useCatalogMutations(URL, URL) + const currencies = useCurrencies() + const [form, setForm] = useState
(null) + + const save = async () => { + if (!form) return + const { id, ...payload } = form + if (id) await update.mutateAsync({ id, input: payload }) + else await create.mutateAsync(payload) + setForm(null) + } return ( - } - footer={data && data.total > 0 && ( - - )} - > - r.id} - columns={[ - { header: 'Код', width: '90px', cell: (r) => {r.code} }, - { header: 'Название', cell: (r) => r.name }, - { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, - ]} - /> - + <> + + + + + } + footer={data && data.total > 0 && ( + + )} + > + r.id} + onRowClick={(r) => setForm({ + id: r.id, code: r.code, name: r.name, sortOrder: r.sortOrder, + defaultCurrencyId: r.defaultCurrencyId, vatRate: r.vatRate, + })} + columns={[ + { header: 'Код', width: '90px', cell: (r) => {r.code} }, + { header: 'Название', cell: (r) => r.name }, + { header: 'Валюта', width: '130px', cell: (r) => r.defaultCurrencyCode ? `${r.defaultCurrencyCode} ${r.defaultCurrencySymbol ?? ''}` : '—' }, + { header: 'НДС', width: '100px', className: 'text-right', cell: (r) => `${r.vatRate.toFixed(2)}%` }, + { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, + ]} + /> + + + setForm(null)} + title={form?.id ? 'Редактировать страну' : 'Новая страна'} + footer={ + <> + {form?.id && ( + + )} + + + + } + > + {form && ( +
+
+ + setForm({ ...form, code: e.target.value.toUpperCase() })} /> + + + setForm({ ...form, sortOrder: Number(e.target.value) })} /> + +
+ + setForm({ ...form, name: e.target.value })} /> + +
+ + + + + setForm({ ...form, vatRate: Number(e.target.value) })} + /> + +
+
+ )} +
+ ) } diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index 614b869..5dc5aea 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -8,8 +8,6 @@ import { Field, TextInput, Select, Checkbox } from '@/components/Field' import { useCurrencies, useCountries } from '@/lib/useLookups' import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings' -const vatChoices = [0, 10, 12, 16, 20] - export function OrganizationSettingsPage() { const qc = useQueryClient() const settings = useOrgSettings() @@ -19,21 +17,21 @@ export function OrganizationSettingsPage() { const [form, setForm] = useState(null) useEffect(() => { if (settings.data && !form) setForm(settings.data) }, [settings.data, form]) - // При смене страны подтягиваем её дефолтную валюту. + // При смене страны подтягиваем её валюту и ставку НДС (оба из справочника стран). const onCountryChange = (countryCode: string) => { if (!form) return const country = countries.data?.find((c) => c.code === countryCode) - const fallbackByCode: Record = { KZ: 'KZT', RU: 'RUB', BY: 'BYN', US: 'USD' } - const targetCode = fallbackByCode[countryCode] - const currency = targetCode ? currencies.data?.find((c) => c.code === targetCode) : undefined + const currency = country?.defaultCurrencyId + ? currencies.data?.find((c) => c.id === country.defaultCurrencyId) + : undefined setForm({ ...form, countryCode, - defaultCurrencyId: currency?.id ?? form.defaultCurrencyId, - defaultCurrencyCode: currency?.code ?? form.defaultCurrencyCode, - defaultCurrencySymbol: currency?.symbol ?? form.defaultCurrencySymbol, + defaultCurrencyId: currency?.id ?? country?.defaultCurrencyId ?? null, + defaultCurrencyCode: currency?.code ?? country?.defaultCurrencyCode ?? null, + defaultCurrencySymbol: currency?.symbol ?? country?.defaultCurrencySymbol ?? null, + vatRate: country?.vatRate ?? 0, }) - void country // reserved for future use (sortOrder etc.) } const save = useMutation({ @@ -44,7 +42,7 @@ export function OrganizationSettingsPage() { countryCode: form.countryCode, defaultCurrencyId: form.defaultCurrencyId, multiCurrencyEnabled: form.multiCurrencyEnabled, - defaultVat: form.defaultVat, + showVatEnabledOnProduct: form.showVatEnabledOnProduct, } return (await api.put('/api/organization/settings', payload)).data }, @@ -96,11 +94,30 @@ export function OrganizationSettingsPage() { Если выключено — в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию.

- - + +
+ + + берётся из страны ({form.countryCode}) — чтобы изменить, откройте справочник{' '} + Страны + +
+ + setForm({ ...form, showVatEnabledOnProduct: v })} + /> +

+ Если выключено — поля «НДС %» и «В том числе НДС» на карточке товара скрыты, + все новые товары получают ставку из страны организации. Если включено — у каждого + товара можно задать ставку вручную (хлеб = 0%, лекарства = 0% и т.п.). +

diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 973f876..7695df9 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -38,13 +38,11 @@ interface Form { barcodes: BarcodeRow[] } -// KZ default VAT rate. -const defaultVat = 16 const vatChoices = [0, 10, 12, 16, 20] const emptyForm: Form = { name: '', article: '', description: '', - unitOfMeasureId: '', vat: defaultVat, vatEnabled: true, + unitOfMeasureId: '', vat: 16, vatEnabled: true, productGroupId: '', defaultSupplierId: '', countryOfOriginId: '', isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true, minStock: '', maxStock: '', @@ -108,11 +106,7 @@ export function ProductEditPage() { : currencies.data?.find(c => c.code === 'KZT') setForm((f) => ({ ...f, purchaseCurrencyId: def?.id ?? currencies.data?.[0]?.id ?? '' })) } - // Default VAT для нового товара берём из настроек организации. - if (isNew && org.data?.defaultVat !== undefined && form.vat === 16 && org.data.defaultVat !== 16) { - setForm((f) => ({ ...f, vat: org.data!.defaultVat })) - } - }, [isNew, units.data, currencies.data, org.data?.defaultCurrencyId, org.data?.defaultVat, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat]) + }, [isNew, units.data, currencies.data, org.data?.defaultCurrencyId, form.unitOfMeasureId, form.purchaseCurrencyId]) const save = useMutation({ mutationFn: async () => { @@ -121,7 +115,9 @@ export function ProductEditPage() { article: form.article || null, description: form.description || null, unitOfMeasureId: form.unitOfMeasureId, - vat: form.vat, + // Если UI скрывает Vat (showVatEnabledOnProduct=false), посылаем null, + // бэкенд заполнит дефолтом из страны при создании / сохранит прежнее при update. + vat: org.data?.showVatEnabledOnProduct ? form.vat : null, vatEnabled: form.vatEnabled, productGroupId: form.productGroupId || null, defaultSupplierId: form.defaultSupplierId || null, @@ -247,11 +243,6 @@ export function ProductEditPage() { {units.data?.map((u) => )} - - - + {org.data?.showVatEnabledOnProduct && ( + + + + + + )}
- setForm({ ...form, vatEnabled: v })} /> + {org.data?.showVatEnabledOnProduct && ( + setForm({ ...form, vatEnabled: v })} /> + )} setForm({ ...form, isService: v })} /> setForm({ ...form, isMarked: v })} /> setForm({ ...form, isActive: v })} /> diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index 144055f..c69efb4 100644 --- a/src/food-market.web/src/pages/ProductsPage.tsx +++ b/src/food-market.web/src/pages/ProductsPage.tsx @@ -6,6 +6,7 @@ import { SearchBar } from '@/components/SearchBar' import { Button } from '@/components/Button' import { Plus, Filter, X } from 'lucide-react' import { useCatalogList } from '@/lib/useCatalog' +import { useOrgSettings } from '@/lib/useOrgSettings' import { ProductGroupTree } from '@/components/ProductGroupTree' import type { Product } from '@/lib/types' @@ -96,8 +97,34 @@ export function ProductsPage() { const [filters, setFilters] = useState(defaultFilters) const [filtersOpen, setFiltersOpen] = useState(false) const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL, toExtra(filters)) + const org = useOrgSettings() + const showVat = org.data?.showVatEnabledOnProduct ?? false const activeCount = activeFilterCount(filters) + type Col = { + header: string + width?: string + className?: string + cell: (r: Product) => React.ReactNode + } + const baseColumns: Col[] = [ + { header: 'Название', cell: (r) => ( +
+
{r.name}
+ {r.article &&
{r.article}
} +
+ )}, + { header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' }, + { header: 'Ед.', width: '70px', cell: (r) => r.unitName }, + ] + if (showVat) { + baseColumns.push({ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => r.vatEnabled ? `${r.vat}%` : '—' }) + } + baseColumns.push( + { header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length }, + { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + ) + return (
{/* Left: groups tree */} @@ -171,19 +198,7 @@ export function ProductsPage() { isLoading={isLoading} rowKey={(r) => r.id} onRowClick={(r) => navigate(`/catalog/products/${r.id}`)} - columns={[ - { header: 'Название', cell: (r) => ( -
-
{r.name}
- {r.article &&
{r.article}
} -
- )}, - { header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' }, - { header: 'Ед.', width: '70px', cell: (r) => r.unitName }, - { header: 'НДС', width: '80px', className: 'text-right', cell: (r) => r.vatEnabled ? `${r.vat}%` : '—' }, - { header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length }, - { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, - ]} + columns={baseColumns} empty="Товаров ещё нет. Они появятся после приёмки или через API." />