food-market/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs
nns bf53629092
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m24s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m33s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
refactor(units): drop Description, hide Code from non-SuperAdmin UI
Description у пяти канонических ОКЕИ-единиц никогда не заполнялось ни UI,
ни импортом, ни сидером — выкидываем поле полностью (Domain → EF-config
→ DTO → Input → frontend types → Super-Admin форма). Migration
Phase5d_DropUnitOfMeasureDescription дропает колонку.

Code оставляем в БД (нужен для интеграций МойСклад/1С), но скрываем от
org Admin'а:
- /catalog/units-of-measure — только колонки Name + кнопка toggle, без
  Code и Description; поиск/сортировка только по Name.
- /super-admin/units-of-measure — Code продолжает показываться в таблице
  и форме редактирования.

Дропдаун единиц в ProductEditPage / ProductQuickCreateModal уже отдаёт
только {u.name} в options, проверено. На SupplyEditPage/RetailSaleEditPage
в строках документа отображается unitName, Code не показывался — без
изменений.
2026-05-08 11:02:10 +05:00

151 lines
6.6 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.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.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();
}
}