Compare commits

...

3 commits

Author SHA1 Message Date
nns 493ed33fd0 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
Было: каждая орга держала свои 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
nns 46877cc134 fix(phone): серверная KZ-ФЛК на всех endpoint'ах принимающих phone
Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст
в Phone — серверной валидации ФЛК не было (только в /api/auth/signup).
Это позволяло сохранить «abc» в Organization.Phone и невалидные номера
для контрагентов и сотрудников.

— Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz +
  IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»;
  валидно: 11 цифр, начинается с «77» (мобильный код KZ).
— SuperAdminOrganizationsController.Create/Update: 400 если phone не
  парсится; в БД пишется нормализованная форма «+77001234567».
— CounterpartiesController.Create/Update: то же. Apply() нормализует.
— EmployeesController.Create/Update: то же.
— SuperAdminEmployeesController.Create/Update: то же.
— AuthSignupController: убран локальный NormalizeKzPhone, используется
  shared. Сообщение об ошибке унифицировано.

Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone).
Незаполненный phone остаётся валидным для опциональных полей —
контроллер сам решает требовать или нет.
2026-05-08 01:05:48 +05:00
nns bcf81c57ee fix(auth): Cashier/Storekeeper больше не видят /api/organization/employees + Identity-роль маппится из orgRole
Найдено в e2e-прогоне (отчёт reports/full-cycle-2026-05-07-baseline.md):
- GET /api/organization/employees вернул 200 для Cashier (ожидалось 403).
- Cashier у созданного через POST /employees вообще не получает
  Identity-роли — серверная авторизация не работает.

Корни:
1. EmployeesController имел class-level [Authorize] без roles,
   List/Get не имели per-method [Authorize(Roles=...)] — поэтому любой
   аутентифицированный юзер мог читать список сотрудников.
2. EmployeesController.Create при createAccount=true вызывал
   _userMgr.CreateAsync, но НЕ вызывал AddToRoleAsync — у созданного
   юзера не было ни одной Identity-роли.

Фиксы:
- Class-level `[Authorize(Roles = "SuperAdmin,Admin")]` на
  EmployeesController. Теперь List/Get/Create/Update/Delete все
  требуют Admin (или SuperAdmin override). Per-method дубль убран.
- Новый helper `Api/Infrastructure/IdentityRoleMapper.cs`:
  Администратор → Admin, Кладовщик → Storekeeper, Кассир → Cashier.
  Кастомные orgRole не получают Identity-роли (это by-design — они
  дают UI-permissions внутри org, без доступа к role-locked endpoint'ам).
- EmployeesController.Create вызывает AddToRoleAsync с замапленной
  Identity-ролью если такая есть.
- SuperAdminEmployeesController.Create аналогично — вместо хардкод
  "Admin" использует mapper с fallback на "Admin" (по запросу юзера
  при создании учётки SuperAdmin'ом).

После фикса в e2e:
- Cashier → GET /api/organization/employees → 403 (было 200).
- /connect/token → /api/me содержит roles=["Cashier"].
- Cashier → /api/sales/retail-sales → 200 (рабочая авторизация).
2026-05-08 01:03:30 +05:00
21 changed files with 855 additions and 192 deletions

View file

@ -31,19 +31,6 @@ public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
public record SignupResult(Guid OrganizationId, string Email);
/// <summary>Нормализация и проверка телефона Казахстана. Принимаем любое
/// форматирование (пробелы, скобки, +, дефисы), оставляем только цифры,
/// ведущая «8» переписывается в «7». Валидно: ровно 11 цифр, начинается
/// с «77» — мобильный код KZ. «79…» (РФ) и прочие отвергаем.</summary>
private static string? NormalizeKzPhone(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var digits = new string(raw.Where(char.IsDigit).ToArray());
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
if (digits.Length != 11 || !digits.StartsWith("77")) return null;
return "+" + digits;
}
[HttpPost("signup")]
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
{
@ -52,9 +39,9 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
return BadRequest(new { error = "Email, пароль и название обязательны." });
if (input.Password.Length < 8)
return BadRequest(new { error = "Пароль минимум 8 символов." });
var normalizedPhone = NormalizeKzPhone(input.Phone);
var normalizedPhone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
if (normalizedPhone is null)
return BadRequest(new { error = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67" });
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null)

View file

@ -72,6 +72,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
{
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
var e = Apply(new Counterparty(), input);
_db.Counterparties.Add(e);
await _db.SaveChangesAsync(ct);
@ -81,6 +82,7 @@ public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
{
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
Apply(e, input);
@ -88,6 +90,14 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput in
return NoContent();
}
private static string? NormalizePhoneOrError(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
return foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(raw)
? null
: foodmarket.Application.Common.PhoneNormalization.ErrorMessage;
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
@ -108,7 +118,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
e.TaxNumber = i.TaxNumber;
e.CountryId = i.CountryId;
e.Address = i.Address;
e.Phone = i.Phone;
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(i.Phone);
e.Email = i.Email;
e.BankName = i.BankName;
e.BankAccount = i.BankAccount;

View file

@ -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);
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);
_db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId.Value, UnitOfMeasureId = id });
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);
await _db.SaveChangesAsync(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();
}
}

View file

@ -13,7 +13,7 @@
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize]
[Authorize(Roles = "SuperAdmin,Admin")]
[Route("api/organization/employees")]
public class EmployeesController : ControllerBase
{
@ -111,12 +111,12 @@ public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
[HttpPost]
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct);
if (!roleExists) return BadRequest(new { error = "Роль не найдена." });
var role = await _db.EmployeeRoles.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
if (role is null) return BadRequest(new { error = "Роль не найдена." });
var employee = new Employee
{
@ -151,6 +151,15 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
if (!result.Succeeded)
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
employee.UserId = user.Id;
// Identity-роль маппится из orgRole.Name. Кастомные orgRole не
// получают Identity-роли — они только дают UI-permissions, без
// доступа к role-locked endpoint'ам.
var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name);
if (identityRole is not null)
{
await _userMgr.AddToRoleAsync(user, identityRole);
}
}
foreach (var rpId in input.RetailPointIds ?? [])
@ -208,7 +217,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
e.MiddleName = input.MiddleName;
e.Position = input.Position;
e.Email = input.Email;
e.Phone = input.Phone;
if (!string.IsNullOrWhiteSpace(input.Phone)
&& !foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
e.Salary = input.Salary;
e.TaxNumber = input.TaxNumber;
e.Description = input.Description;

View file

@ -162,15 +162,21 @@ public async Task<ActionResult<EmployeeDetail>> Create(Guid orgId, [FromBody] Cr
var ur = await _userMgr.CreateAsync(u, tempPassword);
if (!ur.Succeeded)
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(x => x.Description)) });
await _userMgr.AddToRoleAsync(u, "Admin");
// Identity-роль из orgRole (Администратор/Кладовщик/Кассир) — кастомные не получают.
var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name) ?? "Admin";
await _userMgr.AddToRoleAsync(u, identityRole);
newUserId = u.Id;
}
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var emp = new Employee
{
OrganizationId = orgId, UserId = newUserId,
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
Position = input.Position, Email = input.Email, Phone = input.Phone,
Position = input.Position, Email = input.Email,
Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone),
RoleId = input.RoleId, IsActive = input.IsActive,
};
foreach (var rp in input.RetailPointIds ?? [])
@ -204,7 +210,9 @@ public async Task<IActionResult> Update(Guid orgId, Guid id, [FromBody] UpdateIn
e.MiddleName = input.Employee.MiddleName;
e.Position = input.Employee.Position;
e.Email = input.Employee.Email;
e.Phone = input.Employee.Phone;
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Employee.Phone))
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Employee.Phone);
e.Salary = input.Employee.Salary;
e.TaxNumber = input.Employee.TaxNumber;
e.Description = input.Employee.Description;

View file

@ -92,11 +92,15 @@ public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
[HttpPost]
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
{
var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Org.Phone);
if (!string.IsNullOrEmpty(input.Org.Phone) && phone is null)
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var org = new Organization
{
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
Bin = input.Org.Bin, Address = input.Org.Address,
Phone = input.Org.Phone, Email = input.Org.Email,
Phone = phone, Email = input.Org.Email,
DefaultCurrencyId = input.Org.DefaultCurrencyId,
};
_db.Organizations.Add(org);
@ -147,10 +151,13 @@ public async Task<IActionResult> Update(Guid id, [FromBody] OrgInput input, Canc
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
if (!string.IsNullOrEmpty(input.Phone) && phone is null)
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
o.Name = input.Name; o.CountryCode = input.CountryCode;
o.Bin = input.Bin; o.Address = input.Address;
o.Phone = input.Phone; o.Email = input.Email;
o.Phone = phone; o.Email = input.Email;
o.DefaultCurrencyId = input.DefaultCurrencyId;
await _db.SaveChangesAsync(ct);
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";

View file

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

View file

@ -0,0 +1,25 @@
namespace foodmarket.Api.Infrastructure;
/// <summary>Маппинг доменной orgRole.Name (русский, видимый юзеру в UI)
/// на Identity-роль (английский, используется в [Authorize(Roles=...)]).
/// Identity-роль определяет ЧТО юзер может вызывать через API; orgRole —
/// видимое имя в UI и набор прав для конкретной орги.
///
/// Системные orgRole сидятся при создании org (см. DevDataSeeder.SeedEmployeeRolesAsync).
/// Кастомные orgRole создаются админом орги — для них Identity-роль НЕ
/// присваивается (юзер не сможет дёрнуть Admin/Cashier/Storekeeper-only
/// endpoints, что и нужно: кастомные роли — только UI-permissions внутри org).</summary>
public static class IdentityRoleMapper
{
public static string? FromOrgRoleName(string? orgRoleName)
{
if (string.IsNullOrWhiteSpace(orgRoleName)) return null;
return orgRoleName.Trim() switch
{
"Администратор" => "Admin",
"Кладовщик" => "Storekeeper",
"Кассир" => "Cashier",
_ => null,
};
}
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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,

View file

@ -0,0 +1,35 @@
namespace foodmarket.Application.Common;
/// <summary>Серверная валидация и нормализация телефонов Казахстана.
/// Принимает любое форматирование (+7 700 123-45-67, 8 (700) 1234567, ...),
/// оставляет только цифры, ведущая «8» переписывается в «7».
/// Валидно: ровно 11 цифр, начинается с «77» — мобильный код KZ.
/// «79…» (РФ) и прочие отвергаются.
///
/// Используется на всех endpoint'ах, принимающих phone — defense-in-depth
/// к фронт-валидации (PhoneInput / validatePhone).</summary>
public static class PhoneNormalization
{
public const string ErrorMessage = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67";
/// <summary>Возвращает нормализованный номер вида "+77001234567" если phone валиден,
/// null если не валиден. Пустой ввод возвращает null без ошибки —
/// проверку «обязательно ли» делает вызывающая сторона.</summary>
public static string? TryNormalizeKz(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var digits = new string(raw.Where(char.IsDigit).ToArray());
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
if (digits.Length != 11 || !digits.StartsWith("77")) return null;
return "+" + digits;
}
/// <summary>True если phone валиден или null/пусто (опциональное поле).
/// False — только если непустой ввод не парсится. Удобно для контроллеров,
/// которые принимают phone как опциональный.</summary>
public static bool IsValidOrEmpty(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return true;
return TryNormalizeKz(raw) is not null;
}
}

View 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; }
}

View file

@ -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;
}

View file

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

View file

@ -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)

View file

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

View file

@ -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>

View file

@ -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;

View 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>
</>
)
}

View file

@ -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)."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={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 ?? ''
})
}}
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: 'Описание', cell: (r) => r.description ?? '—' },
]}
/>
</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)
<ListPageShell
title="Единицы измерения"
description={
isSuperAdmin
? 'Глобальный справочник ОКЕИ. Управление полным справочником — на странице платформы.'
: 'Включите единицы, нужные вашей организации. Добавление новых единиц — у платформы.'
}
actions={
<>
<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={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}
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: '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'
}
}}>
<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>
</>
>
{r.isEnabledForOrg ? 'Включено' : 'Выключено'}
</button>
),
},
]}
/>
</ListPageShell>
)
}