food-market/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs
nns 493ed33fd0
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
phase5c: единицы измерения — глобальный справочник + junction для орг
Было: каждая орга держала свои 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 со списком
  организаций при попытке деактивировать используемую единицу
2026-05-08 01:21:20 +05:00

151 lines
6.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}