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 со списком организаций при попытке деактивировать используемую единицу
151 lines
6.7 KiB
C#
151 lines
6.7 KiB
C#
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;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
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, ITenantContext tenant)
|
||
{
|
||
_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();
|
||
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);
|
||
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);
|
||
}
|
||
|
||
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
||
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
|
||
[HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
||
public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
|
||
{
|
||
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)
|
||
{
|
||
_db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId.Value, UnitOfMeasureId = id });
|
||
await _db.SaveChangesAsync(ct);
|
||
}
|
||
return NoContent();
|
||
}
|
||
|
||
/// <summary>Отключить global для текущей орги. Если на эту единицу
|
||
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
|
||
/// перепривязал их сначала.</summary>
|
||
[HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
||
public async Task<IActionResult> Disable(Guid id, CancellationToken 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();
|
||
}
|
||
}
|