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
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 не показывался — без
изменений.
151 lines
6.6 KiB
C#
151 lines
6.6 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.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();
|
||
}
|
||
}
|