diff --git a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs index 372cf98..36193c1 100644 --- a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs +++ b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs @@ -1,5 +1,6 @@ using foodmarket.Application.Catalog; using foodmarket.Application.Common; +using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; @@ -8,19 +9,50 @@ namespace foodmarket.Api.Controllers.Catalog; +/// Phase5c: единицы измерения — глобальный справочник (управляется +/// SuperAdmin'ом). Орга подключает нужные через junction org_units_of_measure. +/// Этот контроллер: read-only список + toggle включения/выключения. +/// CRUD — на /api/super-admin/units-of-measure. [ApiController] [Authorize] [Route("api/catalog/units-of-measure")] public class UnitsOfMeasureController : ControllerBase { private readonly AppDbContext _db; + private readonly ITenantContext _tenant; - public UnitsOfMeasureController(AppDbContext db) => _db = db; - - [HttpGet] - public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant) { - var q = _db.UnitsOfMeasure.AsNoTracking().AsQueryable(); + _db = db; + _tenant = tenant; + } + + /// Список единиц для текущей орги: только включённые active + /// globals. Для SuperAdmin без override — все active globals (чтобы UI + /// мог показывать справочник в платформенном контексте). + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, CancellationToken ct) + { + var orgId = _tenant.OrganizationId; + var isSuperAdminPlatform = _tenant.IsSuperAdmin && !_tenant.IsTenantOverride; + + // Globals — read через IgnoreQueryFilters: фильтр иначе пропустит + // нашу же null-OrganizationId-запись только потому, что мы её специально + // ищем; явная фильтрация по OrganizationId IS NULL понятнее. + var q = _db.UnitsOfMeasure + .IgnoreQueryFilters() + .AsNoTracking() + .Where(u => u.OrganizationId == null && u.IsActive); + + if (!isSuperAdminPlatform && orgId.HasValue) + { + // Org Admin / Storekeeper: только включённые в его орге. + q = q.Where(u => _db.OrgUnitsOfMeasure + .IgnoreQueryFilters() + .Any(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == u.Id)); + } + if (!string.IsNullOrWhiteSpace(req.Search)) { var s = req.Search.Trim().ToLower(); @@ -36,7 +68,8 @@ 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, u.OrganizationId)) + .Select(u => new UnitOfMeasureDto( + u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -44,47 +77,74 @@ public async Task>> List([FromQuery] [HttpGet("{id:guid}")] 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, u.OrganizationId); + var u = await _db.UnitsOfMeasure + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct); + if (u is null) return NotFound(); + + var orgId = _tenant.OrganizationId; + var enabled = !orgId.HasValue || await _db.OrgUnitsOfMeasure + .IgnoreQueryFilters() + .AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct); + return new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, enabled); } - [HttpPost, Authorize(Roles = "Admin")] - public async Task> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct) + /// Включить global для текущей орги. Идемпотентно: повторный + /// вызов отдаёт 204 и не плодит дубликатов junction. + [HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")] + public async Task Enable(Guid id, CancellationToken ct) { - var e = new UnitOfMeasure + var orgId = _tenant.OrganizationId; + if (!orgId.HasValue) return BadRequest(new { error = "Tenant context required." }); + var unit = await _db.UnitsOfMeasure + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.Id == id && u.OrganizationId == null && u.IsActive, ct); + if (unit is null) return NotFound(); + + var existing = await _db.OrgUnitsOfMeasure + .IgnoreQueryFilters() + .AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct); + if (!existing) { - Code = input.Code, - Name = input.Name, - Description = input.Description, - }; - _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, e.OrganizationId)); - } - - [HttpPut("{id:guid}"), Authorize(Roles = "Admin,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; - e.Description = input.Description; - await _db.SaveChangesAsync(ct); + _db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId.Value, UnitOfMeasureId = id }); + await _db.SaveChangesAsync(ct); + } return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")] - public async Task Delete(Guid id, CancellationToken ct) + /// Отключить global для текущей орги. Если на эту единицу + /// ссылаются продукты орги — 409 со списком названий, чтобы админ + /// перепривязал их сначала. + [HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")] + public async Task Disable(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); + var orgId = _tenant.OrganizationId; + if (!orgId.HasValue) return BadRequest(new { error = "Tenant context required." }); + + var productNames = await _db.Products + .Where(p => p.UnitOfMeasureId == id) + .OrderBy(p => p.Name) + .Select(p => p.Name) + .Take(10) + .ToListAsync(ct); + if (productNames.Count > 0) + { + return Conflict(new + { + error = "Единица используется в товарах. Перепривяжите товары на другую единицу прежде чем отключать.", + products = productNames, + }); + } + + var link = await _db.OrgUnitsOfMeasure + .IgnoreQueryFilters() + .FirstOrDefaultAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct); + if (link is not null) + { + _db.OrgUnitsOfMeasure.Remove(link); + await _db.SaveChangesAsync(ct); + } return NoContent(); } } diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminUnitsOfMeasureController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminUnitsOfMeasureController.cs new file mode 100644 index 0000000..11156f6 --- /dev/null +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminUnitsOfMeasureController.cs @@ -0,0 +1,158 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.SuperAdmin; + +/// SuperAdmin: CRUD над глобальными единицами измерения (Phase5c). +/// GET отдаёт все globals (active+inactive); POST/PUT всегда устанавливают +/// OrganizationId=NULL; DELETE — soft (IsActive=false), а если на единицу +/// ссылаются продукты или активные org-junction'ы — 409 со списком орг, +/// чтобы они сначала отвязались. +[ApiController] +[Authorize(Roles = "SuperAdmin")] +[Route("api/super-admin/units-of-measure")] +public class SuperAdminUnitsOfMeasureController : ControllerBase +{ + private readonly AppDbContext _db; + + public SuperAdminUnitsOfMeasureController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.UnitsOfMeasure + .IgnoreQueryFilters() + .AsNoTracking() + .Where(u => u.OrganizationId == null); + + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s)); + } + var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("code", false) => q.OrderBy(u => u.Code), + ("code", true) => q.OrderByDescending(u => u.Code), + ("name", true) => q.OrderByDescending(u => u.Name), + _ => q.OrderBy(u => u.Name), + }; + var items = await q + .Skip(req.Skip).Take(req.Take) + .Select(u => new UnitOfMeasureDto( + u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var u = await _db.UnitsOfMeasure + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct); + return u is null + ? NotFound() + : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true); + } + + [HttpPost] + public async Task> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Code) || string.IsNullOrWhiteSpace(input.Name)) + return BadRequest(new { error = "Code и Name обязательны." }); + + // Унiqueness среди active globals (filtered index в БД, но проверяем + // и здесь чтобы вернуть осмысленный 409). + var dup = await _db.UnitsOfMeasure.IgnoreQueryFilters() + .AnyAsync(u => u.OrganizationId == null && u.IsActive && u.Code == input.Code, ct); + if (dup) return Conflict(new { error = $"Код «{input.Code}» уже используется." }); + + var e = new UnitOfMeasure + { + OrganizationId = null, + Code = input.Code.Trim(), + Name = input.Name.Trim(), + Description = input.Description, + IsActive = true, + }; + _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, e.OrganizationId, e.IsActive, true)); + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Code) || string.IsNullOrWhiteSpace(input.Name)) + return BadRequest(new { error = "Code и Name обязательны." }); + + var e = await _db.UnitsOfMeasure + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct); + if (e is null) return NotFound(); + + if (e.Code != input.Code.Trim()) + { + var dup = await _db.UnitsOfMeasure.IgnoreQueryFilters() + .AnyAsync(u => u.OrganizationId == null && u.IsActive && u.Code == input.Code && u.Id != id, ct); + if (dup) return Conflict(new { error = $"Код «{input.Code}» уже используется." }); + } + + e.Code = input.Code.Trim(); + e.Name = input.Name.Trim(); + e.Description = input.Description; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + /// Soft-delete: IsActive=false. Если на единицу ссылаются + /// продукты или активные org-junction'ы — 409 со списком орг (до 10), + /// чтобы SuperAdmin понимал кого нужно сначала перевести. + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.UnitsOfMeasure + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct); + if (e is null) return NotFound(); + + var productCount = await _db.Products + .IgnoreQueryFilters() + .CountAsync(p => p.UnitOfMeasureId == id, ct); + + var orgsUsing = await _db.OrgUnitsOfMeasure + .IgnoreQueryFilters() + .Where(j => j.UnitOfMeasureId == id) + .Join(_db.Organizations.IgnoreQueryFilters(), + j => j.OrganizationId, + o => o.Id, + (j, o) => o.Name) + .OrderBy(n => n) + .Take(10) + .ToListAsync(ct); + + if (productCount > 0 || orgsUsing.Count > 0) + { + return Conflict(new + { + error = $"Единица используется: {productCount} товар(ов), {orgsUsing.Count} организаций. Сначала отключите её у этих орг.", + productCount, + organizations = orgsUsing, + }); + } + + e.IsActive = false; + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Seed/DemoCatalogSeeder.cs b/src/food-market.api/Seed/DemoCatalogSeeder.cs index 21f61e5..c442ad8 100644 --- a/src/food-market.api/Seed/DemoCatalogSeeder.cs +++ b/src/food-market.api/Seed/DemoCatalogSeeder.cs @@ -36,12 +36,13 @@ public async Task StartAsync(CancellationToken ct) const decimal vatDefault = 16m; const decimal vat0 = 0m; + // Phase5c: единицы измерения — глобальные (OrganizationId IS NULL). var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters() - .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct); + .FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "796", ct); var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters() - .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct); + .FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "166", ct); var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters() - .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct); + .FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "112", ct); if (unitSht is null) return; diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index eb2caca..d4d78c8 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -38,6 +38,11 @@ public async Task StartAsync(CancellationToken ct) } } + // Глобальные единицы измерения (Phase5c). Идемпотентно: если миграция + // уже их создала, ничего не происходит. Нужно для случая, когда + // развернули с нуля без миграции данных. + await SeedGlobalUnitsAsync(db, 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) @@ -165,17 +170,9 @@ private static async Task SeedAdminEmployeeAsync(AppDbContext db, Guid orgId, Gu /// SuperAdmin UI. public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct) { - var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct); - if (!anyUnit) - { - db.UnitsOfMeasure.AddRange( - new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" }, - new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" }, - new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" }, - new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" }, - new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" } - ); - } + // Phase5c: единицы измерения теперь global. Подключаем к новой + // организации все active globals через junction org_units_of_measure. + await EnableAllActiveUnitsForOrgAsync(db, orgId, ct); // Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи. // Если есть — никогда не создаём «системную копию», корректность IsSystem @@ -272,5 +269,60 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca await db.SaveChangesAsync(ct); } + /// Канонический набор 5 глобальных единиц измерения (Phase5c). + /// Идемпотентно: вставляет только отсутствующие по Code среди globals. + /// На свежем prod-environment где миграция данных не нашла исходных + /// tenant-rows — этот сидер обеспечит наличие минимума. + private static async Task SeedGlobalUnitsAsync(AppDbContext db, CancellationToken ct) + { + var canonical = new (string Code, string Name)[] + { + ("796", "штука"), + ("166", "килограмм"), + ("112", "литр"), + ("006", "метр"), + ("625", "упаковка"), + }; + + var existingCodes = await db.UnitsOfMeasure + .IgnoreQueryFilters() + .Where(u => u.OrganizationId == null) + .Select(u => u.Code) + .ToListAsync(ct); + + foreach (var (code, name) in canonical) + { + if (!existingCodes.Contains(code)) + { + db.UnitsOfMeasure.Add(new UnitOfMeasure { OrganizationId = null, Code = code, Name = name, IsActive = true }); + } + } + await db.SaveChangesAsync(ct); + } + + /// Включить все active globals для новой организации через + /// junction org_units_of_measure. Идемпотентно: на повторный вызов не + /// добавляет дубликатов. + private static async Task EnableAllActiveUnitsForOrgAsync(AppDbContext db, Guid orgId, CancellationToken ct) + { + var globals = await db.UnitsOfMeasure + .IgnoreQueryFilters() + .Where(u => u.OrganizationId == null && u.IsActive) + .Select(u => u.Id) + .ToListAsync(ct); + + var alreadyEnabled = await db.OrgUnitsOfMeasure + .IgnoreQueryFilters() + .Where(j => j.OrganizationId == orgId) + .Select(j => j.UnitOfMeasureId) + .ToListAsync(ct); + + foreach (var unitId in globals.Except(alreadyEnabled)) + { + db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId, UnitOfMeasureId = unitId }); + } + await db.SaveChangesAsync(ct); + } + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; } diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index 256ecef..dc2d545 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -12,7 +12,8 @@ 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? OrganizationId); + Guid Id, string Code, string Name, string? Description, Guid? OrganizationId, + bool IsActive = true, bool IsEnabledForOrg = true); public record PriceTypeDto( Guid Id, string Name, bool IsRequired, bool IsSystem, diff --git a/src/food-market.domain/Catalog/OrgUnitOfMeasure.cs b/src/food-market.domain/Catalog/OrgUnitOfMeasure.cs new file mode 100644 index 0000000..057733a --- /dev/null +++ b/src/food-market.domain/Catalog/OrgUnitOfMeasure.cs @@ -0,0 +1,14 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Catalog; + +/// Junction «организация ↔ глобальная единица измерения». +/// Орга включает только нужные единицы из глобального справочника, чтобы +/// в формах товара не маячили все 50+ ОКЕИ-кодов. Composite PK +/// (OrganizationId, UnitOfMeasureId) — naturally unique. +public class OrgUnitOfMeasure : ITenantEntity +{ + public Guid OrganizationId { get; set; } + public Guid UnitOfMeasureId { get; set; } + public UnitOfMeasure? UnitOfMeasure { get; set; } +} diff --git a/src/food-market.domain/Catalog/UnitOfMeasure.cs b/src/food-market.domain/Catalog/UnitOfMeasure.cs index 2917735..579f168 100644 --- a/src/food-market.domain/Catalog/UnitOfMeasure.cs +++ b/src/food-market.domain/Catalog/UnitOfMeasure.cs @@ -2,13 +2,17 @@ namespace foodmarket.Domain.Catalog; -// Единица измерения как в MoySklad entity/uom: code + name + description. -// Двухуровневый справочник: системные эталонные (OrganizationId=null, -// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения. +// Единица измерения — глобальный справочник (Phase5c): только SuperAdmin +// CRUD'ит. Каждая орга включает нужные ей единицы через junction +// OrgUnitOfMeasure (см. ниже). OrganizationId оставлен nullable для +// обратной совместимости со снимком EF (после миграции всегда NULL). +// IsActive — soft-delete: глобал, на который ссылаются продукты, нельзя +// удалить, но можно деактивировать. 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; } + public bool IsActive { get; set; } = true; } diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index cd8cb2e..6173b58 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -27,6 +27,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Countries => Set(); public DbSet Currencies => Set(); public DbSet UnitsOfMeasure => Set(); + public DbSet OrgUnitsOfMeasure => Set(); public DbSet Counterparties => Set(); public DbSet Stores => Set(); public DbSet RetailPoints => Set(); diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 9ad7927..f004ee9 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -11,6 +11,7 @@ public static void ConfigureCatalog(this ModelBuilder b) b.Entity(ConfigureCountry); b.Entity(ConfigureCurrency); b.Entity(ConfigureUnit); + b.Entity(ConfigureOrgUnit); b.Entity(ConfigureCounterparty); b.Entity(ConfigureStore); b.Entity(ConfigureRetailPoint); @@ -47,7 +48,19 @@ private static void ConfigureUnit(EntityTypeBuilder b) b.Property(x => x.Code).HasMaxLength(10).IsRequired(); b.Property(x => x.Name).HasMaxLength(100).IsRequired(); b.Property(x => x.Description).HasMaxLength(500); - b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); + b.Property(x => x.IsActive).HasDefaultValue(true); + // Phase5c: после миграции OrganizationId всегда NULL — глобальный справочник. + // Уникальность по Code только среди активных, чтобы можно было + // soft-delete старую запись и создать новую с тем же кодом. + b.HasIndex(x => x.Code).IsUnique().HasFilter("\"IsActive\" = true"); + } + + private static void ConfigureOrgUnit(EntityTypeBuilder b) + { + b.ToTable("org_units_of_measure"); + b.HasKey(x => new { x.OrganizationId, x.UnitOfMeasureId }); + b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); + b.HasIndex(x => x.UnitOfMeasureId); } private static void ConfigureCounterparty(EntityTypeBuilder b) diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260508000000_Phase5c_UnitsOfMeasureGlobal.cs b/src/food-market.infrastructure/Persistence/Migrations/20260508000000_Phase5c_UnitsOfMeasureGlobal.cs new file mode 100644 index 0000000..5692cdf --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260508000000_Phase5c_UnitsOfMeasureGlobal.cs @@ -0,0 +1,163 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase5c — рефакторинг справочника единиц измерения в глобальный. + /// До: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк + /// в БД на 19 орг — duplication, и редактирует их кто угодно. + /// После: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. + /// Орга включает нужные единицы через junction org_units_of_measure. + /// + /// Миграция данных: + /// 1. По одной строке на каждую (Code, Name) пару поднимается в global + /// (OrganizationId→NULL). + /// 2. Junction наполняется: каждая орга получает запись о включённости + /// того global'а, чью (Code, Name) она раньше держала локально. + /// 3. products.UnitOfMeasureId remap'ится с tenant-row на global. + /// 4. Оставшиеся tenant-rows (дубликаты) удаляются. + /// 5. Если в БД не было какого-то канонического (Code, Name), он + /// добавляется явно. + /// + /// Безопасно для prod: products FK (OnDelete=Restrict) не падает + /// благодаря шагу 3 перед DELETE на шаге 4. + public partial class Phase5c_UnitsOfMeasureGlobal : Migration + { + protected override void Up(MigrationBuilder b) + { + // 0. IsActive (default true) — для будущего soft-delete. + b.AddColumn( + name: "IsActive", + schema: "public", + table: "units_of_measure", + type: "boolean", + nullable: false, + defaultValue: true); + + // 1. Старый unique index (OrganizationId, Code) — больше не нужен. + b.DropIndex( + name: "IX_units_of_measure_OrganizationId_Code", + schema: "public", + table: "units_of_measure"); + + // 2. Поднять одну строку на (Code, Name) пару в global. + b.Sql(@" + WITH first_per_pair AS ( + SELECT DISTINCT ON (""Code"", ""Name"") ""Id"" + FROM public.units_of_measure + WHERE ""OrganizationId"" IS NOT NULL + ORDER BY ""Code"", ""Name"", ""Id"" + ) + UPDATE public.units_of_measure + SET ""OrganizationId"" = NULL + WHERE ""Id"" IN (SELECT ""Id"" FROM first_per_pair);"); + + // 3. Создать junction org_units_of_measure. + b.CreateTable( + name: "org_units_of_measure", + schema: "public", + columns: table => new + { + OrganizationId = table.Column(type: "uuid", nullable: false), + UnitOfMeasureId = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_org_units_of_measure", x => new { x.OrganizationId, x.UnitOfMeasureId }); + table.ForeignKey( + name: "FK_org_units_of_measure_units_of_measure_UnitOfMeasureId", + column: x => x.UnitOfMeasureId, + principalSchema: "public", + principalTable: "units_of_measure", + principalColumn: "Id", + onDelete: Microsoft.EntityFrameworkCore.Migrations.ReferentialAction.Restrict); + }); + + b.CreateIndex( + name: "IX_org_units_of_measure_UnitOfMeasureId", + schema: "public", + table: "org_units_of_measure", + column: "UnitOfMeasureId"); + + // 4. Заполнить junction: для каждой орги — её активные globals + // (через сравнение Code+Name). + b.Sql(@" + INSERT INTO public.org_units_of_measure (""OrganizationId"", ""UnitOfMeasureId"") + SELECT DISTINCT t.""OrganizationId"", g.""Id"" + FROM public.units_of_measure t + JOIN public.units_of_measure g + ON g.""OrganizationId"" IS NULL + AND g.""Code"" = t.""Code"" + AND g.""Name"" = t.""Name"" + WHERE t.""OrganizationId"" IS NOT NULL + ON CONFLICT DO NOTHING;"); + + // 5. Remap products.UnitOfMeasureId с tenant-row на global. + b.Sql(@" + UPDATE public.products p + SET ""UnitOfMeasureId"" = g.""Id"" + FROM public.units_of_measure t, public.units_of_measure g + WHERE p.""UnitOfMeasureId"" = t.""Id"" + AND t.""OrganizationId"" IS NOT NULL + AND g.""OrganizationId"" IS NULL + AND g.""Code"" = t.""Code"" + AND g.""Name"" = t.""Name"";"); + + // 6. Удалить tenant-row дубликаты (на globals никто уже не ссылается + // напрямую кроме junction и products — те remap'нуты выше). + b.Sql(@"DELETE FROM public.units_of_measure WHERE ""OrganizationId"" IS NOT NULL;"); + + // 7. Доинсёртить канонические globals, если каких-то не было в БД. + b.Sql(@" + INSERT INTO public.units_of_measure (""Id"", ""Code"", ""Name"", ""IsActive"") + SELECT gen_random_uuid(), v.code, v.name, true + FROM (VALUES + ('796','штука'), + ('166','килограмм'), + ('112','литр'), + ('006','метр'), + ('625','упаковка') + ) AS v(code, name) + WHERE NOT EXISTS ( + SELECT 1 FROM public.units_of_measure + WHERE ""OrganizationId"" IS NULL AND ""Code"" = v.code + );"); + + // 8. Новый unique index на Code среди active globals. + b.CreateIndex( + name: "IX_units_of_measure_Code", + schema: "public", + table: "units_of_measure", + column: "Code", + unique: true, + filter: "\"IsActive\" = true"); + } + + protected override void Down(MigrationBuilder b) + { + b.DropIndex( + name: "IX_units_of_measure_Code", + schema: "public", + table: "units_of_measure"); + + b.DropTable( + name: "org_units_of_measure", + schema: "public"); + + b.DropColumn( + name: "IsActive", + schema: "public", + table: "units_of_measure"); + + // Восстановить старый unique index. Данные не возвращаем — это + // одностороння миграция (rollback вернёт лишь схему). + b.CreateIndex( + name: "IX_units_of_measure_OrganizationId_Code", + schema: "public", + table: "units_of_measure", + columns: new[] { "OrganizationId", "Code" }, + unique: true); + } + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index cbedf78..0aab4b5 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -12,6 +12,7 @@ import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage' import { SuperAdminSettingsPage } from '@/pages/SuperAdminSettingsPage' import { CountriesPage } from '@/pages/CountriesPage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' +import { SuperAdminUnitsOfMeasurePage } from '@/pages/SuperAdminUnitsOfMeasurePage' import { PriceTypesPage } from '@/pages/PriceTypesPage' import { StoresPage } from '@/pages/StoresPage' import { RetailPointsPage } from '@/pages/RetailPointsPage' @@ -75,7 +76,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index e9eb89b..ffee482 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -28,7 +28,10 @@ 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; organizationId: string | null } +export interface UnitOfMeasure { + id: string; code: string; name: string; description: string | null; organizationId: string | null; + isActive: boolean; isEnabledForOrg: boolean +} 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; diff --git a/src/food-market.web/src/pages/SuperAdminUnitsOfMeasurePage.tsx b/src/food-market.web/src/pages/SuperAdminUnitsOfMeasurePage.tsx new file mode 100644 index 0000000..3bb3c27 --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminUnitsOfMeasurePage.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { ListPageShell } from '@/components/ListPageShell' +import { DataTable } from '@/components/DataTable' +import { Pagination } from '@/components/Pagination' +import { SearchBar } from '@/components/SearchBar' +import { Button } from '@/components/Button' +import { Modal } from '@/components/Modal' +import { Field, TextInput } from '@/components/Field' +import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' +import type { UnitOfMeasure } from '@/lib/types' + +const URL = '/api/super-admin/units-of-measure' + +interface Form { + id?: string + code: string + name: string + description: string +} + +const blank: Form = { code: '', name: '', description: '' } + +export function SuperAdminUnitsOfMeasurePage() { + const list = useCatalogList(URL) + const { create, update, remove } = useCatalogMutations(URL, URL) + const [form, setForm] = useState
(null) + const [submitError, setSubmitError] = useState(null) + + const save = async () => { + if (!form) return + setSubmitError(null) + const { id, ...payload } = form + try { + if (id) await update.mutateAsync({ id, input: payload }) + else await create.mutateAsync(payload) + setForm(null) + } catch (e: unknown) { + type ErrShape = { response?: { data?: { error?: string } } } + const err = (e as ErrShape).response?.data?.error + setSubmitError(err ?? 'Не удалось сохранить.') + } + } + + const onDelete = async () => { + if (!form?.id) return + if (!confirm('Деактивировать единицу? Если на неё ссылаются товары или орги — операция не пройдёт.')) return + try { + await remove.mutateAsync(form.id) + setForm(null) + } catch (e: unknown) { + type ErrShape = { response?: { data?: { error?: string; organizations?: string[]; productCount?: number } } } + const r = (e as ErrShape).response?.data + const orgs = r?.organizations?.length ? `\n\nОрганизации: ${r.organizations.join(', ')}` : '' + alert((r?.error ?? 'Не удалось удалить.') + orgs) + } + } + + return ( + <> + + + + + } + footer={list.data && list.data.total > 0 && ( + + )} + > + r.id} + sortKey={list.sortKey} + sortOrder={list.sortOrder} + onSortChange={list.setSort} + onRowClick={(r) => { + setSubmitError(null) + 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: 'Описание', cell: (r) => r.description ?? '—' }, + { + header: 'Статус', + width: '110px', + cell: (r) => r.isActive + ? Активна + : Деактивирована, + }, + ]} + /> + + + setForm(null)} + title={form?.id ? 'Редактировать единицу' : 'Новая единица измерения'} + footer={ + <> + {form?.id && ( + + )} + + + + } + > + {form && ( +
+ + setForm({ ...form, code: e.target.value })} /> + + + setForm({ ...form, name: e.target.value })} /> + + + setForm({ ...form, description: e.target.value })} /> + + {submitError && ( +

{submitError}

+ )} +
+ )} +
+ + ) +} diff --git a/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx b/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx index ec112a3..6c8a48b 100644 --- a/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx +++ b/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx @@ -1,121 +1,94 @@ -import { useState } from 'react' -import { Plus, Trash2 } from 'lucide-react' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' -import { Button } from '@/components/Button' -import { Modal } from '@/components/Modal' -import { Field, TextInput } from '@/components/Field' -import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' +import { useCatalogList } from '@/lib/useCatalog' +import { api } from '@/lib/api' +import { useIsSuperAdmin } from '@/lib/useMe' import type { UnitOfMeasure } from '@/lib/types' +import { Link } from 'react-router-dom' const URL = '/api/catalog/units-of-measure' -interface Form { - id?: string - code: string - name: string - description: string -} - -const blankForm: Form = { code: '', name: '', description: '' } - export function UnitsOfMeasurePage() { - const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) - const { create, update, remove } = useCatalogMutations(URL, URL) - const [form, setForm] = useState(null) + const isSuperAdmin = useIsSuperAdmin() + const list = useCatalogList(URL) + const qc = useQueryClient() - const save = async () => { - if (!form) return - const { id, ...payload } = form - if (id) await update.mutateAsync({ id, input: payload }) - else await create.mutateAsync(payload) - setForm(null) - } + const enable = useMutation({ + mutationFn: async (id: string) => (await api.post(`${URL}/${id}/enable`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: [URL] }), + }) + const disable = useMutation({ + mutationFn: async (id: string) => (await api.delete(`${URL}/${id}/enable`)).data, + onError: (err: unknown) => { + type ErrShape = { response?: { data?: { error?: string; products?: string[] } } } + const r = (err as ErrShape).response?.data + const products = r?.products?.length ? `\n\nТовары: ${r.products.join(', ')}` : '' + alert((r?.error ?? 'Не удалось отключить.') + products) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: [URL] }), + }) return ( - <> - - - - - } - footer={data && data.total > 0 && ( - - )} - > - r.id} - sortKey={sortKey} - sortOrder={sortOrder} - onSortChange={setSort} - 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} - {r.organizationId === null && ( - Эталон - )} - - )}, - { header: 'Описание', cell: (r) => r.description ?? '—' }, - ]} - /> - - - setForm(null)} - title={form?.id ? 'Редактировать единицу' : 'Новая единица измерения'} - footer={ - <> - {form?.id && ( - - )} - - - - } - > - {form && ( -
- - setForm({ ...form, code: e.target.value })} /> - - - setForm({ ...form, name: e.target.value })} /> - - - setForm({ ...form, description: e.target.value })} /> - -
- )} -
- + > + {r.isEnabledForOrg ? 'Включено' : 'Выключено'} + + ), + }, + ]} + /> + ) }