diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs new file mode 100644 index 0000000..5420b59 --- /dev/null +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -0,0 +1,79 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Organizations; + +[ApiController] +[Authorize] +[Route("api/organization")] +public class OrganizationSettingsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public OrganizationSettingsController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public record OrgSettingsDto( + Guid Id, + string Name, + string CountryCode, + Guid? DefaultCurrencyId, + string? DefaultCurrencyCode, + string? DefaultCurrencySymbol, + bool MultiCurrencyEnabled, + int DefaultVat); + + public record OrgSettingsInput( + string Name, + string CountryCode, + Guid? DefaultCurrencyId, + bool MultiCurrencyEnabled, + int DefaultVat); + + [HttpGet("settings")] + public async Task> Get(CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var o = await _db.Organizations + .Include(o => o.DefaultCurrency) + .FirstOrDefaultAsync(o => o.Id == orgId, ct); + if (o is null) return NotFound(); + return Project(o); + } + + [HttpPut("settings"), Authorize(Roles = "Admin,Manager")] + public async Task> Update([FromBody] OrgSettingsInput input, CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var o = await _db.Organizations + .Include(o => o.DefaultCurrency) + .FirstOrDefaultAsync(o => o.Id == orgId, ct); + if (o is null) return NotFound(); + + o.Name = input.Name; + o.CountryCode = input.CountryCode; + o.DefaultCurrencyId = input.DefaultCurrencyId; + o.MultiCurrencyEnabled = input.MultiCurrencyEnabled; + o.DefaultVat = input.DefaultVat; + await _db.SaveChangesAsync(ct); + + // Re-read чтобы подтянуть DefaultCurrency. + await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); + return Project(o); + } + + private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o) => new( + o.Id, o.Name, o.CountryCode, + o.DefaultCurrencyId, + o.DefaultCurrency?.Code, + o.DefaultCurrency?.Symbol, + o.MultiCurrencyEnabled, + o.DefaultVat); +} diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index 9300f59..66772f0 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -38,6 +38,7 @@ public async Task StartAsync(CancellationToken ct) } } + var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct); if (demoOrg is null) { @@ -48,11 +49,20 @@ public async Task StartAsync(CancellationToken ct) Bin = "000000000000", Address = "Алматы, ул. Пример 1", Phone = "+7 (777) 000-00-00", - Email = "demo@food-market.local" + 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); + } await SeedTenantReferencesAsync(db, demoOrg.Id, ct); diff --git a/src/food-market.domain/Catalog/Country.cs b/src/food-market.domain/Catalog/Country.cs index 2042c84..4e241e2 100644 --- a/src/food-market.domain/Catalog/Country.cs +++ b/src/food-market.domain/Catalog/Country.cs @@ -8,4 +8,8 @@ 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; } } diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 0f6a023..3e5ef3b 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -1,3 +1,4 @@ +using foodmarket.Domain.Catalog; using foodmarket.Domain.Common; namespace foodmarket.Domain.Organizations; @@ -15,4 +16,17 @@ public class Organization : Entity /// Персональный API-токен MoySklad. Храним per-organization чтобы /// пользователю не нужно было вводить его каждый раз при импорте. public string? MoySkladToken { get; set; } + + /// Валюта организации по умолчанию. Если MultiCurrencyEnabled=false, + /// в UI выбор валюты скрыт — всё в этой валюте. + public Guid? DefaultCurrencyId { get; set; } + public Currency? DefaultCurrency { get; set; } + + /// Разрешены ли продажи/закупки в нескольких валютах. По умолчанию + /// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency. + public bool MultiCurrencyEnabled { get; set; } + + /// Ставка НДС по умолчанию для новых товаров (KZ=16%, RU=20%). + /// Само значение применяется к товару при создании; пользователь может менять. + public int DefaultVat { get; set; } = 16; } diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index f573264..e55f69f 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -70,6 +70,7 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired(); b.Property(o => o.Bin).HasMaxLength(20); b.Property(o => o.MoySkladToken).HasMaxLength(200); + b.HasOne(o => o.DefaultCurrency).WithMany().HasForeignKey(o => o.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict); b.HasIndex(o => o.Name); }); diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 034175a..6844a8a 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.HasOne(x => x.DefaultCurrency).WithMany().HasForeignKey(x => x.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict); b.HasIndex(x => x.Code).IsUnique(); } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260424001500_Phase4_CountryCurrencyOrgDefaults.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260424001500_Phase4_CountryCurrencyOrgDefaults.Designer.cs new file mode 100644 index 0000000..42e2f24 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260424001500_Phase4_CountryCurrencyOrgDefaults.Designer.cs @@ -0,0 +1,1875 @@ +// +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("20260424001500_Phase4_CountryCurrencyOrgDefaults")] + partial class Phase4_CountryCurrencyOrgDefaults + { + /// + 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.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("IsWeighed") + .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") + .HasColumnType("boolean"); + + 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("DefaultVat") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MoySkladToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MultiCurrencyEnabled") + .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/20260424001500_Phase4_CountryCurrencyOrgDefaults.cs b/src/food-market.infrastructure/Persistence/Migrations/20260424001500_Phase4_CountryCurrencyOrgDefaults.cs new file mode 100644 index 0000000..b8fb63e --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260424001500_Phase4_CountryCurrencyOrgDefaults.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Country ↔ Currency связка + дефолты организации: + /// - countries.DefaultCurrencyId (nullable FK → currencies.Id) + /// - organizations.DefaultCurrencyId (FK → currencies.Id) + /// - organizations.MultiCurrencyEnabled (bool, default false) + /// - organizations.DefaultVat (int, default 16) + /// Seed: KZ→KZT, RU→RUB; org → KZ+KZT. + public partial class Phase4_CountryCurrencyOrgDefaults : Migration + { + protected override void Up(MigrationBuilder b) + { + b.AddColumn( + name: "DefaultCurrencyId", schema: "public", table: "countries", + type: "uuid", nullable: true); + + b.AddColumn( + name: "DefaultCurrencyId", schema: "public", table: "organizations", + type: "uuid", nullable: true); + b.AddColumn( + name: "MultiCurrencyEnabled", schema: "public", table: "organizations", + type: "boolean", nullable: false, defaultValue: false); + b.AddColumn( + name: "DefaultVat", schema: "public", table: "organizations", + type: "integer", nullable: false, defaultValue: 16); + + b.CreateIndex( + name: "IX_countries_DefaultCurrencyId", schema: "public", + table: "countries", column: "DefaultCurrencyId"); + b.CreateIndex( + name: "IX_organizations_DefaultCurrencyId", schema: "public", + table: "organizations", column: "DefaultCurrencyId"); + + b.AddForeignKey( + name: "FK_countries_currencies_DefaultCurrencyId", + schema: "public", table: "countries", column: "DefaultCurrencyId", + principalSchema: "public", principalTable: "currencies", principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + b.AddForeignKey( + name: "FK_organizations_currencies_DefaultCurrencyId", + schema: "public", table: "organizations", column: "DefaultCurrencyId", + principalSchema: "public", principalTable: "currencies", principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + // Backfill: привяжем валюты к странам по ISO-коду. + b.Sql(""" + UPDATE public.countries SET "DefaultCurrencyId" = c."Id" + FROM public.currencies c + WHERE (public.countries."Code" = 'KZ' AND c."Code" = 'KZT') + OR (public.countries."Code" = 'RU' AND c."Code" = 'RUB') + OR (public.countries."Code" = 'BY' AND c."Code" = 'BYN') + OR (public.countries."Code" = 'US' AND c."Code" = 'USD') + OR (public.countries."Code" = 'DE' AND c."Code" = 'EUR') + OR (public.countries."Code" = 'CN' AND c."Code" = 'CNY') + OR (public.countries."Code" = 'TR' AND c."Code" = 'TRY'); + """); + + // Дефолт для организации — KZT, если существует. + b.Sql(""" + UPDATE public.organizations SET "DefaultCurrencyId" = c."Id" + FROM public.currencies c + WHERE c."Code" = 'KZT' AND public.organizations."DefaultCurrencyId" IS NULL; + """); + } + + protected override void Down(MigrationBuilder b) + { + b.DropForeignKey(name: "FK_countries_currencies_DefaultCurrencyId", schema: "public", table: "countries"); + b.DropForeignKey(name: "FK_organizations_currencies_DefaultCurrencyId", schema: "public", table: "organizations"); + b.DropIndex(name: "IX_countries_DefaultCurrencyId", schema: "public", table: "countries"); + b.DropIndex(name: "IX_organizations_DefaultCurrencyId", schema: "public", table: "organizations"); + b.DropColumn(name: "DefaultCurrencyId", schema: "public", table: "countries"); + b.DropColumn(name: "DefaultCurrencyId", schema: "public", table: "organizations"); + b.DropColumn(name: "MultiCurrencyEnabled", schema: "public", table: "organizations"); + b.DropColumn(name: "DefaultVat", schema: "public", table: "organizations"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index f187ff2..6a71409 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -434,6 +434,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + b.Property("Name") .IsRequired() .HasMaxLength(100) @@ -450,6 +453,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Code") .IsUnique(); + b.HasIndex("DefaultCurrencyId"); + b.ToTable("countries", "public"); }); @@ -1071,6 +1076,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Email") .HasColumnType("text"); + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + + b.Property("DefaultVat") + .HasColumnType("integer"); + b.Property("IsActive") .HasColumnType("boolean"); @@ -1078,6 +1089,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("MultiCurrencyEnabled") + .HasColumnType("boolean"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -1091,6 +1105,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("DefaultCurrencyId"); + b.HasIndex("Name"); b.ToTable("organizations", "public"); diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 0950c83..031938d 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -13,6 +13,7 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage' import { ProductsPage } from '@/pages/ProductsPage' import { ProductEditPage } from '@/pages/ProductEditPage' import { MoySkladImportPage } from '@/pages/MoySkladImportPage' +import { OrganizationSettingsPage } from '@/pages/OrganizationSettingsPage' import { StockPage } from '@/pages/StockPage' import { StockMovementsPage } from '@/pages/StockMovementsPage' import { SuppliesPage } from '@/pages/SuppliesPage' @@ -60,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index e105798..f1f5821 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -4,9 +4,9 @@ import { api } from '@/lib/api' import { logout } from '@/lib/auth' import { cn } from '@/lib/utils' import { - LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag, + LayoutDashboard, Package, FolderTree, Ruler, Tag, Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download, - Boxes, History, TruckIcon, ShoppingCart, + Boxes, History, TruckIcon, ShoppingCart, Settings, } from 'lucide-react' import { Logo } from './Logo' @@ -26,7 +26,6 @@ const nav = [ { to: '/catalog/products', icon: Package, label: 'Товары' }, { to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, { to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' }, - { to: '/catalog/vat-rates', icon: Percent, label: 'Ставки НДС' }, { to: '/catalog/price-types', icon: Tag, label: 'Типы цен' }, ]}, { group: 'Контрагенты', items: [ @@ -53,6 +52,9 @@ const nav = [ { group: 'Импорт', items: [ { to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' }, ]}, + { group: 'Настройки', items: [ + { to: '/settings/organization', icon: Settings, label: 'Организация' }, + ]}, ] as const export function AppLayout() { diff --git a/src/food-market.web/src/lib/useOrgSettings.ts b/src/food-market.web/src/lib/useOrgSettings.ts new file mode 100644 index 0000000..d9f433d --- /dev/null +++ b/src/food-market.web/src/lib/useOrgSettings.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/api' + +export interface OrgSettings { + id: string + name: string + countryCode: string + defaultCurrencyId: string | null + defaultCurrencyCode: string | null + defaultCurrencySymbol: string | null + multiCurrencyEnabled: boolean + defaultVat: number +} + +export function useOrgSettings() { + return useQuery({ + queryKey: ['/api/organization/settings'], + queryFn: async () => (await api.get('/api/organization/settings')).data, + staleTime: 5 * 60 * 1000, + }) +} diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx new file mode 100644 index 0000000..614b869 --- /dev/null +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Save } from 'lucide-react' +import { api } from '@/lib/api' +import { PageHeader } from '@/components/PageHeader' +import { Button } from '@/components/Button' +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() + const currencies = useCurrencies() + const countries = useCountries() + + 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 + setForm({ + ...form, + countryCode, + defaultCurrencyId: currency?.id ?? form.defaultCurrencyId, + defaultCurrencyCode: currency?.code ?? form.defaultCurrencyCode, + defaultCurrencySymbol: currency?.symbol ?? form.defaultCurrencySymbol, + }) + void country // reserved for future use (sortOrder etc.) + } + + const save = useMutation({ + mutationFn: async () => { + if (!form) return + const payload = { + name: form.name, + countryCode: form.countryCode, + defaultCurrencyId: form.defaultCurrencyId, + multiCurrencyEnabled: form.multiCurrencyEnabled, + defaultVat: form.defaultVat, + } + return (await api.put('/api/organization/settings', payload)).data + }, + onSuccess: (d) => { + if (d) setForm(d) + qc.invalidateQueries({ queryKey: ['/api/organization/settings'] }) + }, + }) + + if (!form) return
Загрузка…
+ + return ( +
+
+ + +
+ + setForm({ ...form, name: e.target.value })} /> + + +
+ + + + + + +
+ + setForm({ ...form, multiCurrencyEnabled: v })} + /> +

+ Если выключено — в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию. +

+ + + + +
+ +
+ + {save.isSuccess && Сохранено} + {save.error && {(save.error as Error).message}} +
+
+
+ ) +} diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 17630a1..d610d20 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -8,6 +8,7 @@ import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field import { useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers, } from '@/lib/useLookups' +import { useOrgSettings } from '@/lib/useOrgSettings' import { BarcodeType, type Product } from '@/lib/types' interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string } @@ -63,6 +64,7 @@ export function ProductEditPage() { const countries = useCountries() const currencies = useCurrencies() const priceTypes = usePriceTypes() + const org = useOrgSettings() const suppliers = useSuppliers() const existing = useQuery({ @@ -100,9 +102,16 @@ export function ProductEditPage() { setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' })) } if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) { - setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' })) + const def = org.data?.defaultCurrencyId + ? currencies.data?.find(c => c.id === org.data!.defaultCurrencyId) + : currencies.data?.find(c => c.code === 'KZT') + setForm((f) => ({ ...f, purchaseCurrencyId: def?.id ?? currencies.data?.[0]?.id ?? '' })) } - }, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId]) + // 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]) const save = useMutation({ mutationFn: async () => { @@ -278,12 +287,14 @@ export function ProductEditPage() { setForm({ ...form, purchasePrice: e.target.value })} /> - - - + {org.data?.multiCurrencyEnabled && ( + + + + )} @@ -308,19 +319,24 @@ export function ProductEditPage() {
{form.prices.map((p, i) => (
-
+
-
+
updatePrice(i, { amount: Number(e.target.value) })} /> + {!org.data?.multiCurrencyEnabled && ( + {org.data?.defaultCurrencySymbol ?? ''} + )}
-
- -
+ {org.data?.multiCurrencyEnabled && ( +
+ +
+ )}