Compare commits
No commits in common. "493ed33fd0dd1be5d8bc7662ecb2c7f743c18082" and "7bb941259a40a6ff48caedaba534f275ab8ead64" have entirely different histories.
493ed33fd0
...
7bb941259a
|
|
@ -31,6 +31,19 @@ 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)
|
||||
{
|
||||
|
|
@ -39,9 +52,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 = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
|
||||
var normalizedPhone = NormalizeKzPhone(input.Phone);
|
||||
if (normalizedPhone is null)
|
||||
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
||||
return BadRequest(new { error = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67" });
|
||||
|
||||
var existing = await _userMgr.FindByEmailAsync(input.Email);
|
||||
if (existing is not null)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ 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);
|
||||
|
|
@ -82,7 +81,6 @@ 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);
|
||||
|
|
@ -90,14 +88,6 @@ 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)
|
||||
{
|
||||
|
|
@ -118,7 +108,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
|||
e.TaxNumber = i.TaxNumber;
|
||||
e.CountryId = i.CountryId;
|
||||
e.Address = i.Address;
|
||||
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(i.Phone);
|
||||
e.Phone = i.Phone;
|
||||
e.Email = i.Email;
|
||||
e.BankName = i.BankName;
|
||||
e.BankAccount = i.BankAccount;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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;
|
||||
|
|
@ -9,50 +8,19 @@
|
|||
|
||||
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;
|
||||
}
|
||||
public UnitsOfMeasureController(AppDbContext db) => _db = db;
|
||||
|
||||
/// <summary>Список единиц для текущей орги: только включённые active
|
||||
/// globals. Для SuperAdmin без override — все active globals (чтобы UI
|
||||
/// мог показывать справочник в платформенном контексте).</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List(
|
||||
[FromQuery] PagedRequest req, CancellationToken ct)
|
||||
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));
|
||||
}
|
||||
|
||||
var q = _db.UnitsOfMeasure.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
|
|
@ -68,8 +36,7 @@ public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
|
|||
};
|
||||
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))
|
||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -77,74 +44,47 @@ public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
|
|||
[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);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
||||
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
|
||||
[HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
|
||||
[HttpPost, Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, 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)
|
||||
var e = new UnitOfMeasure
|
||||
{
|
||||
_db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId.Value, UnitOfMeasureId = id });
|
||||
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);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отключить global для текущей орги. Если на эту единицу
|
||||
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
|
||||
/// перепривязал их сначала.</summary>
|
||||
[HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Delete(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);
|
||||
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);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
namespace foodmarket.Api.Controllers.Organizations;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = "SuperAdmin,Admin")]
|
||||
[Authorize]
|
||||
[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]
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
|
||||
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||
var role = await _db.EmployeeRoles.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
|
||||
if (role is null) return BadRequest(new { error = "Роль не найдена." });
|
||||
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct);
|
||||
if (!roleExists) return BadRequest(new { error = "Роль не найдена." });
|
||||
|
||||
var employee = new Employee
|
||||
{
|
||||
|
|
@ -151,15 +151,6 @@ 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 ?? [])
|
||||
|
|
@ -217,10 +208,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
|
|||
e.MiddleName = input.MiddleName;
|
||||
e.Position = input.Position;
|
||||
e.Email = input.Email;
|
||||
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.Phone = input.Phone;
|
||||
e.Salary = input.Salary;
|
||||
e.TaxNumber = input.TaxNumber;
|
||||
e.Description = input.Description;
|
||||
|
|
|
|||
|
|
@ -162,21 +162,15 @@ 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)) });
|
||||
// Identity-роль из orgRole (Администратор/Кладовщик/Кассир) — кастомные не получают.
|
||||
var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name) ?? "Admin";
|
||||
await _userMgr.AddToRoleAsync(u, identityRole);
|
||||
await _userMgr.AddToRoleAsync(u, "Admin");
|
||||
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 = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone),
|
||||
Position = input.Position, Email = input.Email, Phone = input.Phone,
|
||||
RoleId = input.RoleId, IsActive = input.IsActive,
|
||||
};
|
||||
foreach (var rp in input.RetailPointIds ?? [])
|
||||
|
|
@ -210,9 +204,7 @@ 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;
|
||||
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.Phone = input.Employee.Phone;
|
||||
e.Salary = input.Employee.Salary;
|
||||
e.TaxNumber = input.Employee.TaxNumber;
|
||||
e.Description = input.Employee.Description;
|
||||
|
|
|
|||
|
|
@ -92,15 +92,11 @@ 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 = phone, Email = input.Org.Email,
|
||||
Phone = input.Org.Phone, Email = input.Org.Email,
|
||||
DefaultCurrencyId = input.Org.DefaultCurrencyId,
|
||||
};
|
||||
_db.Organizations.Add(org);
|
||||
|
|
@ -151,13 +147,10 @@ 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 = phone; o.Email = input.Email;
|
||||
o.Phone = input.Phone; o.Email = input.Email;
|
||||
o.DefaultCurrencyId = input.DefaultCurrencyId;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||
|
|
|
|||
|
|
@ -1,158 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -36,13 +36,12 @@ 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 == null && u.Code == "796", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
|
||||
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "166", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
|
||||
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == null && u.Code == "112", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
|
||||
|
||||
if (unitSht is null) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -38,11 +38,6 @@ 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)
|
||||
|
|
@ -170,9 +165,17 @@ private static async Task SeedAdminEmployeeAsync(AppDbContext db, Guid orgId, Gu
|
|||
/// SuperAdmin UI.</summary>
|
||||
public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
||||
{
|
||||
// Phase5c: единицы измерения теперь global. Подключаем к новой
|
||||
// организации все active globals через junction org_units_of_measure.
|
||||
await EnableAllActiveUnitsForOrgAsync(db, orgId, 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 = "упаковка" }
|
||||
);
|
||||
}
|
||||
|
||||
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
|
||||
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
||||
|
|
@ -269,60 +272,5 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ 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,
|
||||
bool IsActive = true, bool IsEnabledForOrg = true);
|
||||
Guid Id, string Code, string Name, string? Description, Guid? OrganizationId);
|
||||
|
||||
public record PriceTypeDto(
|
||||
Guid Id, string Name, bool IsRequired, bool IsSystem,
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
|
|
@ -2,17 +2,13 @@
|
|||
|
||||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Единица измерения — глобальный справочник (Phase5c): только SuperAdmin
|
||||
// CRUD'ит. Каждая орга включает нужные ей единицы через junction
|
||||
// OrgUnitOfMeasure (см. ниже). OrganizationId оставлен nullable для
|
||||
// обратной совместимости со снимком EF (после миграции всегда NULL).
|
||||
// IsActive — soft-delete: глобал, на который ссылаются продукты, нельзя
|
||||
// удалить, но можно деактивировать.
|
||||
// Единица измерения как в MoySklad entity/uom: code + name + description.
|
||||
// Двухуровневый справочник: системные эталонные (OrganizationId=null,
|
||||
// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения.
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ 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>();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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);
|
||||
|
|
@ -48,19 +47,7 @@ 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.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);
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ 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'
|
||||
|
|
@ -76,7 +75,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={<SuperAdminUnitsOfMeasurePage />} />
|
||||
<Route path="units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="settings" element={<SuperAdminSettingsPage />} />
|
||||
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ 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;
|
||||
isActive: boolean; isEnabledForOrg: boolean
|
||||
}
|
||||
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; organizationId: string | null }
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,94 +1,121 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 { useCatalogList } from '@/lib/useCatalog'
|
||||
import { api } from '@/lib/api'
|
||||
import { useIsSuperAdmin } from '@/lib/useMe'
|
||||
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'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const URL = '/api/catalog/units-of-measure'
|
||||
|
||||
export function UnitsOfMeasurePage() {
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const list = useCatalogList<UnitOfMeasure>(URL)
|
||||
const qc = useQueryClient()
|
||||
interface Form {
|
||||
id?: string
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
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] }),
|
||||
})
|
||||
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 save = async () => {
|
||||
if (!form) return
|
||||
const { id, ...payload } = form
|
||||
if (id) await update.mutateAsync({ id, input: payload })
|
||||
else await create.mutateAsync(payload)
|
||||
setForm(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListPageShell
|
||||
title="Единицы измерения"
|
||||
description={
|
||||
isSuperAdmin
|
||||
? 'Глобальный справочник ОКЕИ. Управление полным справочником — на странице платформы.'
|
||||
: 'Включите единицы, нужные вашей организации. Добавление новых единиц — у платформы.'
|
||||
}
|
||||
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
|
||||
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>
|
||||
)}
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
<Button onClick={() => setForm(blankForm)}><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} />
|
||||
footer={data && data.total > 0 && (
|
||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={list.data?.items ?? []}
|
||||
isLoading={list.isLoading}
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={list.sortKey}
|
||||
sortOrder={list.sortOrder}
|
||||
onSortChange={list.setSort}
|
||||
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) => r.name },
|
||||
{ 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 ?? '—' },
|
||||
{
|
||||
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'
|
||||
}
|
||||
>
|
||||
{r.isEnabledForOrg ? 'Включено' : 'Выключено'}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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)
|
||||
}
|
||||
}}>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue