From 58038c9cf79353a8334c6e3c0a8be307a1ac98ae Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:20:47 +0500 Subject: [PATCH] =?UTF-8?q?feat(directories):=20=D0=B4=D0=B2=D1=83=D1=85?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B2=D1=8B=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D0=B8=20=D0=95=D0=B4.?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=B8=D1=8F=20(?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D1=8B=D0=B5=20+=20te?= =?UTF-8?q?nant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми справочниками. Системные эталонные записи (OrganizationId=NULL, управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон» и read-only. Tenant'овские (OrganizationId=) — обычная изоляция, полный CRUD у админа орги. Архитектура: - IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от Entity и реализуют его. - AppDbContext.ApplyOptionalTenantFilter: query-filter для IOptionalTenantEntity пропускает запись с OrganizationId=NULL для всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без override видит всё, в override — только NULL+своё. - StampTenant: при Add для IOptionalTenantEntity — null оставляется если SuperAdmin без override (системная), иначе подставляется tenant.OrganizationId. - Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN OrganizationId DROP NOT NULL на product_groups и units_of_measure. Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как tenant'овские — additive change, ничего не теряется. - DTO: UnitOfMeasureDto и ProductGroupDto получили nullable OrganizationId; фронт читает его для показа badge «Эталон». - Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid() если запись OrganizationId=null и юзер не SuperAdmin (только суперадмин может править/удалять системные). Frontend: - Badge «Эталон» (indigo) рядом с именем системной записи в обеих страницах. - Клик по строке системной записи → alert «Изменения недоступны…». - SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree) и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы реиспользуют существующие компоненты — для SuperAdmin без override фильтр возвращает все записи, что в Phase 4+ можно ужесточить отдельным эндпоинтом «только системные» (?orgId=null). Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее, ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Catalog/ProductGroupsController.cs | 15 +- .../Catalog/UnitsOfMeasureController.cs | 12 +- .../Catalog/CatalogDtos.cs | 4 +- .../Catalog/ProductGroup.cs | 5 +- .../Catalog/UnitOfMeasure.cs | 5 +- src/food-market.domain/Common/TenantEntity.cs | 9 + .../Persistence/AppDbContext.cs | 41 +- ...4d_OptionalTenantOnDirectories.Designer.cs | 2048 +++++++++++++++++ ...000_Phase4d_OptionalTenantOnDirectories.cs | 35 + .../Migrations/AppDbContextModelSnapshot.cs | 4 +- src/food-market.web/src/App.tsx | 2 + .../src/components/SuperAdminLayout.tsx | 3 + src/food-market.web/src/lib/types.ts | 4 +- .../src/pages/ProductGroupsPage.tsx | 17 +- .../src/pages/UnitsOfMeasurePage.tsx | 23 +- 15 files changed, 2200 insertions(+), 27 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.Designer.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.cs diff --git a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs index 9fa83c6..d54d406 100644 --- a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs @@ -44,7 +44,7 @@ public class ProductGroupsController : ControllerBase }; var items = await q .Skip(req.Skip).Take(req.Take) - .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent)) + .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -53,7 +53,7 @@ public class ProductGroupsController : ControllerBase public async Task> Get(Guid id, CancellationToken ct) { var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); - return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent); + return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId); } [HttpPost, Authorize(Roles = "Admin,Manager")] @@ -69,14 +69,17 @@ public async Task> Create([FromBody] ProductGroupI _db.ProductGroups.Add(e); await _db.SaveChangesAsync(ct); return CreatedAtAction(nameof(Get), new { id = e.Id }, - new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent)); + new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId)); } - [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")] public async Task Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct) { var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); + // Системную (эталонную) запись правит только SuperAdmin без override. + if (e.OrganizationId is null && !(User.IsInRole("SuperAdmin"))) + return Forbid(); if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" }); e.Name = input.Name; @@ -88,9 +91,11 @@ public async Task Update(Guid id, [FromBody] ProductGroupInput in return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")] public async Task Delete(Guid id, CancellationToken ct) { + var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct); + if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid(); var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct); if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" }); var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct); diff --git a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs index 58d3dfb..93fb608 100644 --- a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs +++ b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs @@ -36,7 +36,7 @@ public async Task>> List([FromQuery] }; var items = await q .Skip(req.Skip).Take(req.Take) - .Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description)) + .Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -45,7 +45,7 @@ public async Task>> List([FromQuery] public async Task> Get(Guid id, CancellationToken ct) { var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); - return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description); + return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId); } [HttpPost, Authorize(Roles = "Admin,Manager")] @@ -60,14 +60,15 @@ public async Task> Create([FromBody] UnitOfMeasur _db.UnitsOfMeasure.Add(e); await _db.SaveChangesAsync(ct); return CreatedAtAction(nameof(Get), new { id = e.Id }, - new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description)); + new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId)); } - [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")] public async Task Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct) { var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); + if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid(); e.Code = input.Code; e.Name = input.Name; @@ -76,11 +77,12 @@ public async Task Update(Guid id, [FromBody] UnitOfMeasureInput i return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")] public async Task Delete(Guid id, CancellationToken ct) { var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); + if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid(); _db.UnitsOfMeasure.Remove(e); await _db.SaveChangesAsync(ct); return NoContent(); diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index 3a8caac..256ecef 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -12,7 +12,7 @@ public record CountryDto( public record CurrencyDto(Guid Id, string Code, string Name, string Symbol); public record UnitOfMeasureDto( - Guid Id, string Code, string Name, string? Description); + Guid Id, string Code, string Name, string? Description, Guid? OrganizationId); public record PriceTypeDto( Guid Id, string Name, bool IsRequired, bool IsSystem, @@ -28,7 +28,7 @@ public record RetailPointDto( public record ProductGroupDto( Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, - decimal? MarkupPercent); + decimal? MarkupPercent, Guid? OrganizationId); public record CounterpartyDto( Guid Id, string Name, string? LegalName, CounterpartyType Type, diff --git a/src/food-market.domain/Catalog/ProductGroup.cs b/src/food-market.domain/Catalog/ProductGroup.cs index ad9c8f3..d091b03 100644 --- a/src/food-market.domain/Catalog/ProductGroup.cs +++ b/src/food-market.domain/Catalog/ProductGroup.cs @@ -3,8 +3,11 @@ namespace foodmarket.Domain.Catalog; // Иерархическая группа товаров (категория). Произвольная вложенность через ParentId. -public class ProductGroup : TenantEntity +// Двухуровневый справочник (IOptionalTenantEntity): системные эталонные группы +// (OrganizationId=null, управляются SuperAdmin'ом) и tenant'овские (OrganizationId=). +public class ProductGroup : Entity, IOptionalTenantEntity { + public Guid? OrganizationId { get; set; } public string Name { get; set; } = null!; public Guid? ParentId { get; set; } public ProductGroup? Parent { get; set; } diff --git a/src/food-market.domain/Catalog/UnitOfMeasure.cs b/src/food-market.domain/Catalog/UnitOfMeasure.cs index 115430c..2917735 100644 --- a/src/food-market.domain/Catalog/UnitOfMeasure.cs +++ b/src/food-market.domain/Catalog/UnitOfMeasure.cs @@ -3,8 +3,11 @@ namespace foodmarket.Domain.Catalog; // Единица измерения как в MoySklad entity/uom: code + name + description. -public class UnitOfMeasure : TenantEntity +// Двухуровневый справочник: системные эталонные (OrganizationId=null, +// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения. +public class UnitOfMeasure : Entity, IOptionalTenantEntity { + public Guid? OrganizationId { get; set; } public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л) public string Name { get; set; } = null!; // "штука", "килограмм", "литр" public string? Description { get; set; } diff --git a/src/food-market.domain/Common/TenantEntity.cs b/src/food-market.domain/Common/TenantEntity.cs index fa7f95f..b359b10 100644 --- a/src/food-market.domain/Common/TenantEntity.cs +++ b/src/food-market.domain/Common/TenantEntity.cs @@ -9,3 +9,12 @@ public abstract class TenantEntity : Entity, ITenantEntity { public Guid OrganizationId { get; set; } } + +/// Двухуровневый справочник: запись либо системная (OrganizationId=null, +/// видна и читается всеми, мутирует только SuperAdmin), либо tenant'овская +/// (OrganizationId=<orgId>, видна и редактируется этой оргой). Применяется +/// для расширяемых эталонных списков типа единиц измерения и групп товаров. +public interface IOptionalTenantEntity +{ + Guid? OrganizationId { get; set; } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index cd0daae..48d8ed9 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -100,7 +100,9 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ConfigureSales(); builder.ConfigureOrganizationsHr(); - // Apply multi-tenant query filter to every entity that implements ITenantEntity + // Apply multi-tenant query filter to every entity that implements ITenantEntity. + // IOptionalTenantEntity (системные справочники с nullable OrganizationId) — + // через отдельный фильтр, который пропускает запись с NULL для всех. foreach (var entityType in builder.Model.GetEntityTypes()) { if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) @@ -111,6 +113,14 @@ protected override void OnModelCreating(ModelBuilder builder) .MakeGenericMethod(entityType.ClrType); method.Invoke(this, new object[] { builder }); } + else if (typeof(IOptionalTenantEntity).IsAssignableFrom(entityType.ClrType)) + { + var method = typeof(AppDbContext) + .GetMethod(nameof(ApplyOptionalTenantFilter), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .MakeGenericMethod(entityType.ClrType); + method.Invoke(this, new object[] { builder }); + } } } @@ -125,6 +135,17 @@ protected override void OnModelCreating(ModelBuilder builder) || e.OrganizationId == _tenant.OrganizationId); } + private void ApplyOptionalTenantFilter(ModelBuilder builder) where T : class, IOptionalTenantEntity + { + // Системные записи (OrganizationId == null) видны ВСЕМ tenant'ам как + // эталонные. Tenant'овские (свои OrganizationId) — обычная изоляция. + // SuperAdmin без override видит всё. + builder.Entity().HasQueryFilter(e => + (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) + || e.OrganizationId == null + || e.OrganizationId == _tenant.OrganizationId); + } + public override int SaveChanges() { StampTenant(); @@ -143,11 +164,27 @@ private void StampTenant() { foreach (var entry in ChangeTracker.Entries()) { - if (entry.State == EntityState.Added && entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty) + if (entry.State != EntityState.Added) continue; + if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty) { if (_tenant.OrganizationId.HasValue) tenant.OrganizationId = _tenant.OrganizationId.Value; } + else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null) + { + // Если SuperAdmin создаёт запись «как пользователь» (override + // активен), стампим выбранную орг. Если SuperAdmin без override + // (системная консоль) — оставляем null (системная запись). + // Tenant-юзер всегда стампит свой orgId. + if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) + { + // null — системная запись; оставляем + } + else if (_tenant.OrganizationId.HasValue) + { + opt.OrganizationId = _tenant.OrganizationId.Value; + } + } } } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.Designer.cs new file mode 100644 index 0000000..8295340 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.Designer.cs @@ -0,0 +1,2048 @@ +// +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("20260427050000_Phase4d_OptionalTenantOnDirectories")] + partial class Phase4d_OptionalTenantOnDirectories + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Bik") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Iin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LegalName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TaxNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("OrganizationId", "Bin"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("counterparties", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatRate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("DefaultCurrencyId"); + + b.ToTable("countries", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Currency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MinorUnit") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currencies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.PriceType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("IsRetail") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("price_types", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Article") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CountryOfOriginId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultSupplierId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsMarked") + .HasColumnType("boolean"); + + b.Property("IsService") + .HasColumnType("boolean"); + + b.Property("Packaging") + .HasColumnType("boolean"); + + b.Property("MaxStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("MinStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductGroupId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LastSupplyAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PurchaseCurrencyId") + .HasColumnType("uuid"); + + b.Property("ReferencePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReferencePriceUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UnitOfMeasureId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vat") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("VatEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("CountryOfOriginId"); + + b.HasIndex("DefaultSupplierId"); + + b.HasIndex("ProductGroupId"); + + b.HasIndex("PurchaseCurrencyId"); + + b.HasIndex("UnitOfMeasureId"); + + b.HasIndex("OrganizationId", "Article"); + + b.HasIndex("OrganizationId", "Name"); + + b.HasIndex("OrganizationId", "ProductGroupId"); + + b.ToTable("products", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("product_barcodes", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkupPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("OrganizationId", "ParentId"); + + b.HasIndex("OrganizationId", "Path"); + + b.ToTable("product_groups", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("product_images", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PriceTypeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("PriceTypeId"); + + b.HasIndex("ProductId", "PriceTypeId") + .IsUnique(); + + b.ToTable("product_prices", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FiscalRegNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FiscalSerial") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("retail_points", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("ManagerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("stores", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("units_of_measure", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReservedQuantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "StoreId"); + + b.HasIndex("OrganizationId", "ProductId", "StoreId") + .IsUnique(); + + b.ToTable("stocks", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("DocumentType", "DocumentId"); + + b.HasIndex("OrganizationId", "ProductId", "OccurredAt"); + + b.HasIndex("OrganizationId", "StoreId", "OccurredAt"); + + b.ToTable("stock_movements", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AllowFractionalPrices") + .HasColumnType("boolean"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("DefaultCurrencyId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AccountOwnerUserId") + .HasColumnType("uuid"); + + b.Property("MoySkladToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MultiCurrencyEnabled") + .HasColumnType("boolean"); + + b.Property("ShowCountryOfOriginOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowDescriptionOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowMarkedOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowReferencePriceOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowMinMaxStock") + .HasColumnType("boolean"); + + b.Property("ShowServiceOnProduct") + .HasColumnType("boolean"); + + b.Property("ShowVatEnabledOnProduct") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCurrencyId"); + + b.HasIndex("Name"); + + b.HasIndex("IsArchived"); + + 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("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("StoreId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.HasIndex("OrganizationId", "SupplierId"); + + b.ToTable("supplies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailPriceManuallyOverridden") + .HasColumnType("boolean"); + + b.Property("RetailPriceOverride") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SupplyId") + .HasColumnType("uuid"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplyId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("supply_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashierUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaidCard") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PaidCash") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Payment") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("RetailPointId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("RetailPointId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "CashierUserId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("retail_sales", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailSaleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("RetailSaleId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("retail_sale_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "CountryOfOrigin") + .WithMany() + .HasForeignKey("CountryOfOriginId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "DefaultSupplier") + .WithMany() + .HasForeignKey("DefaultSupplierId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "ProductGroup") + .WithMany() + .HasForeignKey("ProductGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Currency", "PurchaseCurrency") + .WithMany() + .HasForeignKey("PurchaseCurrencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CountryOfOrigin"); + + b.Navigation("DefaultSupplier"); + + b.Navigation("ProductGroup"); + + b.Navigation("PurchaseCurrency"); + + b.Navigation("UnitOfMeasure"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Barcodes") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.PriceType", "PriceType") + .WithMany() + .HasForeignKey("PriceTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Prices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("PriceType"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Store"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply") + .WithMany("Lines") + .HasForeignKey("SupplyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Supply"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint") + .WithMany() + .HasForeignKey("RetailPointId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Customer"); + + b.Navigation("RetailPoint"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale") + .WithMany("Lines") + .HasForeignKey("RetailSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("RetailSale"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Navigation("Barcodes"); + + b.Navigation("Images"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("SuperAdminUserId").HasColumnType("uuid"); + b.Property("ActionType").IsRequired().HasMaxLength(50).HasColumnType("character varying(50)"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("EntityType").HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("EntityId").HasColumnType("uuid"); + b.Property("Description").HasMaxLength(500).HasColumnType("character varying(500)"); + b.Property("Reason").HasMaxLength(1000).HasColumnType("character varying(1000)"); + b.Property("ChangesJson").HasColumnType("jsonb"); + b.Property("IpAddress").HasMaxLength(45).HasColumnType("character varying(45)"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("CreatedAt"); + b.HasIndex("SuperAdminUserId", "CreatedAt"); + b.HasIndex("OrganizationId", "CreatedAt"); + b.ToTable("super_admin_audit_log", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRole", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("Name").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("Description").HasMaxLength(500).HasColumnType("character varying(500)"); + b.Property("IsSystem").HasColumnType("boolean"); + b.Property("SortOrder").HasColumnType("integer"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("OrganizationId", "Name").IsUnique(); + b.ToTable("employee_roles", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRole", b => + { + b.OwnsOne("foodmarket.Domain.Organizations.RolePermissions", "Permissions", o => + { + o.Property("EmployeeRoleId").HasColumnType("uuid"); + o.Property("ProductsView").HasColumnType("boolean"); + o.Property("ProductsEdit").HasColumnType("boolean"); + o.Property("ProductsDelete").HasColumnType("boolean"); + o.Property("ProductGroupsManage").HasColumnType("boolean"); + o.Property("PriceTypesManage").HasColumnType("boolean"); + o.Property("UnitsManage").HasColumnType("boolean"); + o.Property("SuppliesView").HasColumnType("boolean"); + o.Property("SuppliesEdit").HasColumnType("boolean"); + o.Property("SuppliesPost").HasColumnType("boolean"); + o.Property("SuppliesDelete").HasColumnType("boolean"); + o.Property("DemandsView").HasColumnType("boolean"); + o.Property("DemandsEdit").HasColumnType("boolean"); + o.Property("DemandsPost").HasColumnType("boolean"); + o.Property("RetailSalesOperate").HasColumnType("boolean"); + o.Property("RetailSalesRefund").HasColumnType("boolean"); + o.Property("CounterpartiesView").HasColumnType("boolean"); + o.Property("CounterpartiesEdit").HasColumnType("boolean"); + o.Property("CounterpartiesDelete").HasColumnType("boolean"); + o.Property("StocksView").HasColumnType("boolean"); + o.Property("InventoryEdit").HasColumnType("boolean"); + o.Property("LossEdit").HasColumnType("boolean"); + o.Property("EnterEdit").HasColumnType("boolean"); + o.Property("ReportsView").HasColumnType("boolean"); + o.Property("ReportsFinanceView").HasColumnType("boolean"); + o.Property("ReportsStockView").HasColumnType("boolean"); + o.Property("OrgSettingsManage").HasColumnType("boolean"); + o.Property("EmployeesManage").HasColumnType("boolean"); + o.Property("RolesManage").HasColumnType("boolean"); + o.Property("StoresManage").HasColumnType("boolean"); + o.Property("RetailPointsManage").HasColumnType("boolean"); + o.Property("CashRegistersManage").HasColumnType("boolean"); + o.Property("IntegrationsManage").HasColumnType("boolean"); + o.HasKey("EmployeeRoleId"); + o.ToJson("permissions"); + o.WithOwner().HasForeignKey("EmployeeRoleId"); + }); + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Employee", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("UserId").HasColumnType("uuid"); + b.Property("LastName").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("FirstName").IsRequired().HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("MiddleName").HasMaxLength(100).HasColumnType("character varying(100)"); + b.Property("Position").HasMaxLength(150).HasColumnType("character varying(150)"); + b.Property("Email").HasMaxLength(200).HasColumnType("character varying(200)"); + b.Property("Phone").HasMaxLength(50).HasColumnType("character varying(50)"); + b.Property("Salary").HasPrecision(18, 2).HasColumnType("numeric(18,2)"); + b.Property("TaxNumber").HasMaxLength(20).HasColumnType("character varying(20)"); + b.Property("Description").HasMaxLength(2000).HasColumnType("character varying(2000)"); + b.Property("ImageUrl").HasMaxLength(500).HasColumnType("character varying(500)"); + b.Property("RoleId").HasColumnType("uuid"); + b.Property("IsActive").HasColumnType("boolean"); + b.Property("FiredAt").HasColumnType("timestamp with time zone"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("RoleId"); + b.HasIndex("OrganizationId", "LastName"); + b.HasIndex("OrganizationId", "UserId").IsUnique().HasFilter("\"UserId\" IS NOT NULL"); + b.ToTable("employees", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Employee", b => + { + b.HasOne("foodmarket.Domain.Organizations.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.Navigation("Role"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRetailPointAssignment", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); + b.Property("OrganizationId").HasColumnType("uuid"); + b.Property("EmployeeId").HasColumnType("uuid"); + b.Property("RetailPointId").HasColumnType("uuid"); + b.Property("CreatedAt").HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt").HasColumnType("timestamp with time zone"); + b.HasKey("Id"); + b.HasIndex("EmployeeId", "RetailPointId").IsUnique(); + b.ToTable("employee_retail_point_assignments", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.EmployeeRetailPointAssignment", b => + { + b.HasOne("foodmarket.Domain.Organizations.Employee", "Employee") + .WithMany("RetailPointAssignments") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("Employee"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.cs b/src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.cs new file mode 100644 index 0000000..d700954 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260427050000_Phase4d_OptionalTenantOnDirectories.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// ProductGroup и UnitOfMeasure становятся двухуровневыми + /// справочниками: системные эталонные (OrganizationId=NULL, управляются + /// SuperAdmin'ом, видны всем tenant'ам как «Эталон») и tenant'овские. + /// На уровне БД — DROP NOT NULL на колонке OrganizationId; существующие + /// записи остаются tenant'овскими (OrganizationId сохраняется), миграция + /// additive — никакие данные не теряются. + public partial class Phase4d_OptionalTenantOnDirectories : Migration + { + protected override void Up(MigrationBuilder b) + { + b.AlterColumn( + name: "OrganizationId", schema: "public", table: "product_groups", + type: "uuid", nullable: true, + oldClrType: typeof(System.Guid), oldType: "uuid"); + b.AlterColumn( + name: "OrganizationId", schema: "public", table: "units", + type: "uuid", nullable: true, + oldClrType: typeof(System.Guid), oldType: "uuid"); + } + + protected override void Down(MigrationBuilder b) + { + // Защита от потери данных при downgrade — если есть NULL'ы (системные + // записи), нельзя вернуть NOT NULL без миграции данных. Оставляем + // как есть; downgrade в проде делать через отдельный скрипт. + b.Sql("/* No-op: оптимально не возвращать NOT NULL без data migration */"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index a2c0b75..fddd6f6 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -698,7 +698,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("OrganizationId") + b.Property("OrganizationId") .HasColumnType("uuid"); b.Property("ParentId") @@ -927,7 +927,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); - b.Property("OrganizationId") + b.Property("OrganizationId") .HasColumnType("uuid"); b.Property("UpdatedAt") diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index e461cd8..d6ba755 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -58,6 +58,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard: diff --git a/src/food-market.web/src/components/SuperAdminLayout.tsx b/src/food-market.web/src/components/SuperAdminLayout.tsx index cadbd5d..f2e0b73 100644 --- a/src/food-market.web/src/components/SuperAdminLayout.tsx +++ b/src/food-market.web/src/components/SuperAdminLayout.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload, Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe, + FolderTree, Ruler, } from 'lucide-react' import { api, getOrgOverride, setOrgOverride } from '@/lib/api' import { logout } from '@/lib/auth' @@ -22,6 +23,8 @@ const NAV: NavSection[] = [ ]}, { group: 'Справочники', items: [ { to: '/super-admin/countries', icon: Globe, label: 'Страны' }, + { to: '/super-admin/groups', icon: FolderTree, label: 'Группы (эталон)' }, + { to: '/super-admin/units', icon: Ruler, label: 'Ед. измерения (эталон)' }, ]}, { group: 'Аудит', items: [ { to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал действий' }, diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index a545feb..e9eb89b 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -28,7 +28,7 @@ export interface Country { vatRate: number } export interface Currency { id: string; code: string; name: string; symbol: string } -export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null } +export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; organizationId: string | null } export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number } export interface Store { id: string; name: string; code: string | null; address: string | null; phone: string | null; @@ -38,7 +38,7 @@ export interface RetailPoint { id: string; name: string; code: string | null; storeId: string; storeName: string | null; address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean } -export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null } +export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null } export interface Counterparty { id: string; name: string; legalName: string | null; type: CounterpartyType; bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null; diff --git a/src/food-market.web/src/pages/ProductGroupsPage.tsx b/src/food-market.web/src/pages/ProductGroupsPage.tsx index 9d6aa2b..8bbc421 100644 --- a/src/food-market.web/src/pages/ProductGroupsPage.tsx +++ b/src/food-market.web/src/pages/ProductGroupsPage.tsx @@ -63,9 +63,22 @@ export function ProductGroupsPage() { sortKey={sortKey} sortOrder={sortOrder} onSortChange={setSort} - onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent })} + onRowClick={(r) => { + if (r.organizationId === null) { + alert('Эталонная группа. Изменения недоступны — управляются на уровне платформы.') + return + } + setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent }) + }} columns={[ - { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Название', sortKey: 'name', cell: (r) => ( + + {r.name} + {r.organizationId === null && ( + Эталон + )} + + )}, { header: 'Путь', sortKey: 'path', cell: (r) => {r.path} }, { header: 'Наценка', width: '140px', cell: (r) => (
e.stopPropagation()}> diff --git a/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx b/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx index 8b70123..ec112a3 100644 --- a/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx +++ b/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx @@ -56,13 +56,26 @@ export function UnitsOfMeasurePage() { sortKey={sortKey} sortOrder={sortOrder} onSortChange={setSort} - onRowClick={(r) => setForm({ - id: r.id, code: r.code, name: r.name, - description: r.description ?? '' - })} + onRowClick={(r) => { + if (r.organizationId === null) { + alert('Эталонная единица измерения. Изменения недоступны — управляются на уровне платформы.') + return + } + setForm({ + id: r.id, code: r.code, name: r.name, + description: r.description ?? '' + }) + }} columns={[ { header: 'Код', width: '90px', sortKey: 'code', cell: (r) => {r.code} }, - { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Название', sortKey: 'name', cell: (r) => ( + + {r.name} + {r.organizationId === null && ( + Эталон + )} + + )}, { header: 'Описание', cell: (r) => r.description ?? '—' }, ]} />