phase5c: единицы измерения — глобальный справочник + junction для орг
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 59s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m26s
Docker Web / Build + push Web (push) Successful in 35s
Docker API / Deploy API on stage (push) Failing after 38s
Docker Web / Deploy Web on stage (push) Successful in 12s
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 59s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m26s
Docker Web / Build + push Web (push) Successful in 35s
Docker API / Deploy API on stage (push) Failing after 38s
Docker Web / Deploy Web on stage (push) Successful in 12s
Было: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк в БД на 19 орг — duplication, любой Admin мог их редактировать. Стало: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. Орга включает нужные единицы у себя через junction org_units_of_measure. Backend: - UnitOfMeasure: добавлен IsActive (для soft-delete с filtered unique index) - Новый OrgUnitOfMeasure (junction PK Organization+Unit, FK Restrict) - Migration Phase5c_UnitsOfMeasureGlobal: безопасная для prod — поднимает по одной строке на (Code, Name) до global, remap'ит products.UnitOfMeasureId, наполняет junction по факту существующих привязок, удаляет дубликаты. - /api/catalog/units-of-measure для org Admin: read-only список enabled-globals + POST/DELETE /enable для toggle - /api/super-admin/units-of-measure: full CRUD; DELETE soft (IsActive=false) с 409 если есть products или active org-junction (со списком орг) - DevDataSeeder.SeedTenantReferencesAsync вместо создания per-tenant юнитов — auto-enable всех active globals через junction Frontend: - /catalog/units — checkbox-список (включить/выключить); CTA на платформу для SuperAdmin - /super-admin/units — full CRUD над глобалами, 409 со списком организаций при попытке деактивировать используемую единицу
This commit is contained in:
parent
46877cc134
commit
493ed33fd0
|
|
@ -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;
|
||||
|
||||
/// <summary>Phase5c: единицы измерения — глобальный справочник (управляется
|
||||
/// SuperAdmin'ом). Орга подключает нужные через junction org_units_of_measure.
|
||||
/// Этот контроллер: read-only список + toggle включения/выключения.
|
||||
/// CRUD — на /api/super-admin/units-of-measure.</summary>
|
||||
[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<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
|
||||
public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
|
||||
{
|
||||
var q = _db.UnitsOfMeasure.AsNoTracking().AsQueryable();
|
||||
_db = db;
|
||||
_tenant = tenant;
|
||||
}
|
||||
|
||||
/// <summary>Список единиц для текущей орги: только включённые active
|
||||
/// globals. Для SuperAdmin без override — все active globals (чтобы UI
|
||||
/// мог показывать справочник в платформенном контексте).</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> 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<ActionResult<PagedResult<UnitOfMeasureDto>>> 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<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -44,47 +77,74 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
|||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UnitOfMeasureDto>> 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<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
||||
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
|
||||
[HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> 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);
|
||||
_db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId.Value, UnitOfMeasureId = id });
|
||||
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<IActionResult> 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);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
/// <summary>Отключить global для текущей орги. Если на эту единицу
|
||||
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
|
||||
/// перепривязал их сначала.</summary>
|
||||
[HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> 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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>SuperAdmin: CRUD над глобальными единицами измерения (Phase5c).
|
||||
/// GET отдаёт все globals (active+inactive); POST/PUT всегда устанавливают
|
||||
/// OrganizationId=NULL; DELETE — soft (IsActive=false), а если на единицу
|
||||
/// ссылаются продукты или активные org-junction'ы — 409 со списком орг,
|
||||
/// чтобы они сначала отвязались.</summary>
|
||||
[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<ActionResult<PagedResult<UnitOfMeasureDto>>> 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<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UnitOfMeasureDto>> 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<ActionResult<UnitOfMeasureDto>> 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<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>Soft-delete: IsActive=false. Если на единицу ссылаются
|
||||
/// продукты или активные org-junction'ы — 409 со списком орг (до 10),
|
||||
/// чтобы SuperAdmin понимал кого нужно сначала перевести.</summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Канонический набор 5 глобальных единиц измерения (Phase5c).
|
||||
/// Идемпотентно: вставляет только отсутствующие по Code среди globals.
|
||||
/// На свежем prod-environment где миграция данных не нашла исходных
|
||||
/// tenant-rows — этот сидер обеспечит наличие минимума.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Включить все active globals для новой организации через
|
||||
/// junction org_units_of_measure. Идемпотентно: на повторный вызов не
|
||||
/// добавляет дубликатов.</summary>
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
src/food-market.domain/Catalog/OrgUnitOfMeasure.cs
Normal file
14
src/food-market.domain/Catalog/OrgUnitOfMeasure.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
/// <summary>Junction «организация ↔ глобальная единица измерения».
|
||||
/// Орга включает только нужные единицы из глобального справочника, чтобы
|
||||
/// в формах товара не маячили все 50+ ОКЕИ-кодов. Composite PK
|
||||
/// (OrganizationId, UnitOfMeasureId) — naturally unique.</summary>
|
||||
public class OrgUnitOfMeasure : ITenantEntity
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public Guid UnitOfMeasureId { get; set; }
|
||||
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
public DbSet<Country> Countries => Set<Country>();
|
||||
public DbSet<Currency> Currencies => Set<Currency>();
|
||||
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
||||
public DbSet<OrgUnitOfMeasure> OrgUnitsOfMeasure => Set<OrgUnitOfMeasure>();
|
||||
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
||||
public DbSet<Store> Stores => Set<Store>();
|
||||
public DbSet<RetailPoint> RetailPoints => Set<RetailPoint>();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public static void ConfigureCatalog(this ModelBuilder b)
|
|||
b.Entity<Country>(ConfigureCountry);
|
||||
b.Entity<Currency>(ConfigureCurrency);
|
||||
b.Entity<UnitOfMeasure>(ConfigureUnit);
|
||||
b.Entity<OrgUnitOfMeasure>(ConfigureOrgUnit);
|
||||
b.Entity<Counterparty>(ConfigureCounterparty);
|
||||
b.Entity<Store>(ConfigureStore);
|
||||
b.Entity<RetailPoint>(ConfigureRetailPoint);
|
||||
|
|
@ -47,7 +48,19 @@ private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> 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<OrgUnitOfMeasure> 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<Counterparty> b)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
public partial class Phase5c_UnitsOfMeasureGlobal : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b)
|
||||
{
|
||||
// 0. IsActive (default true) — для будущего soft-delete.
|
||||
b.AddColumn<bool>(
|
||||
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<System.Guid>(type: "uuid", nullable: false),
|
||||
UnitOfMeasureId = table.Column<System.Guid>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
|
||||
<Route path="countries" element={<CountriesPage />} />
|
||||
<Route path="groups" element={<ProductGroupsPage />} />
|
||||
<Route path="units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="units" element={<SuperAdminUnitsOfMeasurePage />} />
|
||||
<Route path="settings" element={<SuperAdminSettingsPage />} />
|
||||
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
135
src/food-market.web/src/pages/SuperAdminUnitsOfMeasurePage.tsx
Normal file
135
src/food-market.web/src/pages/SuperAdminUnitsOfMeasurePage.tsx
Normal file
|
|
@ -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<UnitOfMeasure>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(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 (
|
||||
<>
|
||||
<ListPageShell
|
||||
title="Единицы измерения (платформа)"
|
||||
description="Глобальный справочник. Орги включают нужные единицы у себя в каталоге."
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={list.search} onChange={list.setSearch} />
|
||||
<Button onClick={() => { setSubmitError(null); setForm(blank) }}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||
</>
|
||||
}
|
||||
footer={list.data && list.data.total > 0 && (
|
||||
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={list.data?.items ?? []}
|
||||
isLoading={list.isLoading}
|
||||
rowKey={(r) => 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) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||
{
|
||||
header: 'Статус',
|
||||
width: '110px',
|
||||
cell: (r) => r.isActive
|
||||
? <span className="text-xs text-green-700 dark:text-green-300">Активна</span>
|
||||
: <span className="text-xs text-gray-500">Деактивирована</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
||||
<Modal
|
||||
open={!!form}
|
||||
onClose={() => setForm(null)}
|
||||
title={form?.id ? 'Редактировать единицу' : 'Новая единица измерения'}
|
||||
footer={
|
||||
<>
|
||||
{form?.id && (
|
||||
<Button variant="danger" size="sm" onClick={onDelete}>
|
||||
<Trash2 className="w-4 h-4" /> Деактивировать
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
|
||||
<Button onClick={save} disabled={!form?.code || !form?.name}>Сохранить</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{form && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Код ОКЕИ">
|
||||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Название">
|
||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Описание">
|
||||
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</Field>
|
||||
{submitError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{submitError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<UnitOfMeasure>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(null)
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const list = useCatalogList<UnitOfMeasure>(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 (
|
||||
<>
|
||||
<ListPageShell
|
||||
title="Единицы измерения"
|
||||
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
|
||||
description={
|
||||
isSuperAdmin
|
||||
? 'Глобальный справочник ОКЕИ. Управление полным справочником — на странице платформы.'
|
||||
: 'Включите единицы, нужные вашей организации. Добавление новых единиц — у платформы.'
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||
<SearchBar value={list.search} onChange={list.setSearch} />
|
||||
{isSuperAdmin && (
|
||||
<Link
|
||||
to="/super-admin/units"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
>Управление справочником</Link>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
footer={list.data && list.data.total > 0 && (
|
||||
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rows={list.data?.items ?? []}
|
||||
isLoading={list.isLoading}
|
||||
rowKey={(r) => 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 ?? ''
|
||||
})
|
||||
}}
|
||||
sortKey={list.sortKey}
|
||||
sortOrder={list.sortOrder}
|
||||
onSortChange={list.setSort}
|
||||
columns={[
|
||||
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => (
|
||||
<span>
|
||||
{r.name}
|
||||
{r.organizationId === null && (
|
||||
<span className="ml-2 text-[10px] uppercase px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">Эталон</span>
|
||||
)}
|
||||
</span>
|
||||
)},
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||
{
|
||||
header: 'Для орги',
|
||||
width: '120px',
|
||||
cell: (r) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={enable.isPending || disable.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (r.isEnabledForOrg) disable.mutate(r.id)
|
||||
else enable.mutate(r.id)
|
||||
}}
|
||||
className={
|
||||
r.isEnabledForOrg
|
||||
? 'px-2 py-1 text-xs rounded bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'px-2 py-1 text-xs rounded bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'
|
||||
}
|
||||
>
|
||||
{r.isEnabledForOrg ? 'Включено' : 'Выключено'}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
||||
<Modal
|
||||
open={!!form}
|
||||
onClose={() => setForm(null)}
|
||||
title={form?.id ? 'Редактировать единицу' : 'Новая единица измерения'}
|
||||
footer={
|
||||
<>
|
||||
{form?.id && (
|
||||
<Button variant="danger" size="sm" onClick={async () => {
|
||||
if (confirm('Удалить единицу измерения?')) {
|
||||
await remove.mutateAsync(form.id!)
|
||||
setForm(null)
|
||||
}
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4" /> Удалить
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
|
||||
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{form && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Код ОКЕИ">
|
||||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Название">
|
||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Описание">
|
||||
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue