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 SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
|
||||||
public record SignupResult(Guid OrganizationId, string Email);
|
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")]
|
[HttpPost("signup")]
|
||||||
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
|
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, пароль и название обязательны." });
|
return BadRequest(new { error = "Email, пароль и название обязательны." });
|
||||||
if (input.Password.Length < 8)
|
if (input.Password.Length < 8)
|
||||||
return BadRequest(new { error = "Пароль минимум 8 символов." });
|
return BadRequest(new { error = "Пароль минимум 8 символов." });
|
||||||
var normalizedPhone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
|
var normalizedPhone = NormalizeKzPhone(input.Phone);
|
||||||
if (normalizedPhone is null)
|
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);
|
var existing = await _userMgr.FindByEmailAsync(input.Email);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
|
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);
|
var e = Apply(new Counterparty(), input);
|
||||||
_db.Counterparties.Add(e);
|
_db.Counterparties.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
@ -82,7 +81,6 @@ public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
|
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);
|
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
|
@ -90,14 +88,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput in
|
||||||
return NoContent();
|
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")]
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
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.TaxNumber = i.TaxNumber;
|
||||||
e.CountryId = i.CountryId;
|
e.CountryId = i.CountryId;
|
||||||
e.Address = i.Address;
|
e.Address = i.Address;
|
||||||
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(i.Phone);
|
e.Phone = i.Phone;
|
||||||
e.Email = i.Email;
|
e.Email = i.Email;
|
||||||
e.BankName = i.BankName;
|
e.BankName = i.BankName;
|
||||||
e.BankAccount = i.BankAccount;
|
e.BankAccount = i.BankAccount;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using foodmarket.Application.Catalog;
|
using foodmarket.Application.Catalog;
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Application.Common.Tenancy;
|
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
@ -9,50 +8,19 @@
|
||||||
|
|
||||||
namespace foodmarket.Api.Controllers.Catalog;
|
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]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/catalog/units-of-measure")]
|
[Route("api/catalog/units-of-measure")]
|
||||||
public class UnitsOfMeasureController : ControllerBase
|
public class UnitsOfMeasureController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly ITenantContext _tenant;
|
|
||||||
|
|
||||||
public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
|
public UnitsOfMeasureController(AppDbContext db) => _db = db;
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_tenant = tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Список единиц для текущей орги: только включённые active
|
|
||||||
/// globals. Для SuperAdmin без override — все active globals (чтобы UI
|
|
||||||
/// мог показывать справочник в платформенном контексте).</summary>
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List(
|
public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
|
||||||
[FromQuery] PagedRequest req, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId;
|
var q = _db.UnitsOfMeasure.AsNoTracking().AsQueryable();
|
||||||
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))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
var s = req.Search.Trim().ToLower();
|
var s = req.Search.Trim().ToLower();
|
||||||
|
|
@ -68,8 +36,7 @@ public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
|
||||||
};
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(u => new UnitOfMeasureDto(
|
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId))
|
||||||
u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true))
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
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}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var u = await _db.UnitsOfMeasure
|
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
.IgnoreQueryFilters()
|
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId);
|
||||||
.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
|
|
||||||
if (u is null) return NotFound();
|
|
||||||
|
|
||||||
var orgId = _tenant.OrganizationId;
|
|
||||||
var enabled = !orgId.HasValue || await _db.OrgUnitsOfMeasure
|
|
||||||
.IgnoreQueryFilters()
|
|
||||||
.AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
|
|
||||||
return new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, enabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
[HttpPost, Authorize(Roles = "Admin")]
|
||||||
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
|
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||||
[HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
|
||||||
public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId;
|
var e = new UnitOfMeasure
|
||||||
if (!orgId.HasValue) return BadRequest(new { error = "Tenant context required." });
|
|
||||||
var unit = await _db.UnitsOfMeasure
|
|
||||||
.IgnoreQueryFilters()
|
|
||||||
.FirstOrDefaultAsync(u => u.Id == id && u.OrganizationId == null && u.IsActive, ct);
|
|
||||||
if (unit is null) return NotFound();
|
|
||||||
|
|
||||||
var existing = await _db.OrgUnitsOfMeasure
|
|
||||||
.IgnoreQueryFilters()
|
|
||||||
.AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
|
|
||||||
if (!existing)
|
|
||||||
{
|
{
|
||||||
_db.OrgUnitsOfMeasure.Add(new OrgUnitOfMeasure { OrganizationId = orgId.Value, UnitOfMeasureId = id });
|
Code = input.Code,
|
||||||
|
Name = input.Name,
|
||||||
|
Description = input.Description,
|
||||||
|
};
|
||||||
|
_db.UnitsOfMeasure.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Отключить global для текущей орги. Если на эту единицу
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||||
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
/// перепривязал их сначала.</summary>
|
|
||||||
[HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
|
||||||
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId;
|
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (!orgId.HasValue) return BadRequest(new { error = "Tenant context required." });
|
if (e is null) return NotFound();
|
||||||
|
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||||
var productNames = await _db.Products
|
_db.UnitsOfMeasure.Remove(e);
|
||||||
.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);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
namespace foodmarket.Api.Controllers.Organizations;
|
namespace foodmarket.Api.Controllers.Organizations;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = "SuperAdmin,Admin")]
|
[Authorize]
|
||||||
[Route("api/organization/employees")]
|
[Route("api/organization/employees")]
|
||||||
public class EmployeesController : ControllerBase
|
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);
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost, Authorize(Roles = "SuperAdmin,Admin")]
|
||||||
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
|
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
var role = await _db.EmployeeRoles.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
|
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct);
|
||||||
if (role is null) return BadRequest(new { error = "Роль не найдена." });
|
if (!roleExists) return BadRequest(new { error = "Роль не найдена." });
|
||||||
|
|
||||||
var employee = new Employee
|
var employee = new Employee
|
||||||
{
|
{
|
||||||
|
|
@ -151,15 +151,6 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
|
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
|
||||||
employee.UserId = user.Id;
|
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 ?? [])
|
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.MiddleName = input.MiddleName;
|
||||||
e.Position = input.Position;
|
e.Position = input.Position;
|
||||||
e.Email = input.Email;
|
e.Email = input.Email;
|
||||||
if (!string.IsNullOrWhiteSpace(input.Phone)
|
e.Phone = 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.Salary = input.Salary;
|
||||||
e.TaxNumber = input.TaxNumber;
|
e.TaxNumber = input.TaxNumber;
|
||||||
e.Description = input.Description;
|
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);
|
var ur = await _userMgr.CreateAsync(u, tempPassword);
|
||||||
if (!ur.Succeeded)
|
if (!ur.Succeeded)
|
||||||
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(x => x.Description)) });
|
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(x => x.Description)) });
|
||||||
// Identity-роль из orgRole (Администратор/Кладовщик/Кассир) — кастомные не получают.
|
await _userMgr.AddToRoleAsync(u, "Admin");
|
||||||
var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name) ?? "Admin";
|
|
||||||
await _userMgr.AddToRoleAsync(u, identityRole);
|
|
||||||
newUserId = u.Id;
|
newUserId = u.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
|
|
||||||
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
|
||||||
|
|
||||||
var emp = new Employee
|
var emp = new Employee
|
||||||
{
|
{
|
||||||
OrganizationId = orgId, UserId = newUserId,
|
OrganizationId = orgId, UserId = newUserId,
|
||||||
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
|
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
|
||||||
Position = input.Position, Email = input.Email,
|
Position = input.Position, Email = input.Email, Phone = input.Phone,
|
||||||
Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone),
|
|
||||||
RoleId = input.RoleId, IsActive = input.IsActive,
|
RoleId = input.RoleId, IsActive = input.IsActive,
|
||||||
};
|
};
|
||||||
foreach (var rp in input.RetailPointIds ?? [])
|
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.MiddleName = input.Employee.MiddleName;
|
||||||
e.Position = input.Employee.Position;
|
e.Position = input.Employee.Position;
|
||||||
e.Email = input.Employee.Email;
|
e.Email = input.Employee.Email;
|
||||||
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Employee.Phone))
|
e.Phone = 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.Salary = input.Employee.Salary;
|
||||||
e.TaxNumber = input.Employee.TaxNumber;
|
e.TaxNumber = input.Employee.TaxNumber;
|
||||||
e.Description = input.Employee.Description;
|
e.Description = input.Employee.Description;
|
||||||
|
|
|
||||||
|
|
@ -92,15 +92,11 @@ public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
|
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
|
var org = new Organization
|
||||||
{
|
{
|
||||||
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
|
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
|
||||||
Bin = input.Org.Bin, Address = input.Org.Address,
|
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,
|
DefaultCurrencyId = input.Org.DefaultCurrencyId,
|
||||||
};
|
};
|
||||||
_db.Organizations.Add(org);
|
_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);
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (o is null) return NotFound();
|
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}\"}}";
|
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||||
o.Name = input.Name; o.CountryCode = input.CountryCode;
|
o.Name = input.Name; o.CountryCode = input.CountryCode;
|
||||||
o.Bin = input.Bin; o.Address = input.Address;
|
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;
|
o.DefaultCurrencyId = input.DefaultCurrencyId;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
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 vatDefault = 16m;
|
||||||
const decimal vat0 = 0m;
|
const decimal vat0 = 0m;
|
||||||
|
|
||||||
// Phase5c: единицы измерения — глобальные (OrganizationId IS NULL).
|
|
||||||
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
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()
|
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()
|
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;
|
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 kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
|
||||||
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
|
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
|
||||||
if (demoOrg is null)
|
if (demoOrg is null)
|
||||||
|
|
@ -170,9 +165,17 @@ private static async Task SeedAdminEmployeeAsync(AppDbContext db, Guid orgId, Gu
|
||||||
/// SuperAdmin UI.</summary>
|
/// SuperAdmin UI.</summary>
|
||||||
public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Phase5c: единицы измерения теперь global. Подключаем к новой
|
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
|
||||||
// организации все active globals через junction org_units_of_measure.
|
if (!anyUnit)
|
||||||
await EnableAllActiveUnitsForOrgAsync(db, orgId, ct);
|
{
|
||||||
|
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 ТОЛЬКО если у организации не было ни одной записи.
|
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
|
||||||
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
||||||
|
|
@ -269,60 +272,5 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
|
||||||
await db.SaveChangesAsync(ct);
|
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;
|
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 CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
||||||
|
|
||||||
public record UnitOfMeasureDto(
|
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(
|
public record PriceTypeDto(
|
||||||
Guid Id, string Name, bool IsRequired, bool IsSystem,
|
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;
|
namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
// Единица измерения — глобальный справочник (Phase5c): только SuperAdmin
|
// Единица измерения как в MoySklad entity/uom: code + name + description.
|
||||||
// CRUD'ит. Каждая орга включает нужные ей единицы через junction
|
// Двухуровневый справочник: системные эталонные (OrganizationId=null,
|
||||||
// OrgUnitOfMeasure (см. ниже). OrganizationId оставлен nullable для
|
// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения.
|
||||||
// обратной совместимости со снимком EF (после миграции всегда NULL).
|
|
||||||
// IsActive — soft-delete: глобал, на который ссылаются продукты, нельзя
|
|
||||||
// удалить, но можно деактивировать.
|
|
||||||
public class UnitOfMeasure : Entity, IOptionalTenantEntity
|
public class UnitOfMeasure : Entity, IOptionalTenantEntity
|
||||||
{
|
{
|
||||||
public Guid? OrganizationId { get; set; }
|
public Guid? OrganizationId { get; set; }
|
||||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||||
public string? Description { get; set; }
|
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<Country> Countries => Set<Country>();
|
||||||
public DbSet<Currency> Currencies => Set<Currency>();
|
public DbSet<Currency> Currencies => Set<Currency>();
|
||||||
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
||||||
public DbSet<OrgUnitOfMeasure> OrgUnitsOfMeasure => Set<OrgUnitOfMeasure>();
|
|
||||||
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
||||||
public DbSet<Store> Stores => Set<Store>();
|
public DbSet<Store> Stores => Set<Store>();
|
||||||
public DbSet<RetailPoint> RetailPoints => Set<RetailPoint>();
|
public DbSet<RetailPoint> RetailPoints => Set<RetailPoint>();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ public static void ConfigureCatalog(this ModelBuilder b)
|
||||||
b.Entity<Country>(ConfigureCountry);
|
b.Entity<Country>(ConfigureCountry);
|
||||||
b.Entity<Currency>(ConfigureCurrency);
|
b.Entity<Currency>(ConfigureCurrency);
|
||||||
b.Entity<UnitOfMeasure>(ConfigureUnit);
|
b.Entity<UnitOfMeasure>(ConfigureUnit);
|
||||||
b.Entity<OrgUnitOfMeasure>(ConfigureOrgUnit);
|
|
||||||
b.Entity<Counterparty>(ConfigureCounterparty);
|
b.Entity<Counterparty>(ConfigureCounterparty);
|
||||||
b.Entity<Store>(ConfigureStore);
|
b.Entity<Store>(ConfigureStore);
|
||||||
b.Entity<RetailPoint>(ConfigureRetailPoint);
|
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.Code).HasMaxLength(10).IsRequired();
|
||||||
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||||
b.Property(x => x.Description).HasMaxLength(500);
|
b.Property(x => x.Description).HasMaxLength(500);
|
||||||
b.Property(x => x.IsActive).HasDefaultValue(true);
|
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
||||||
// 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)
|
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 { SuperAdminSettingsPage } from '@/pages/SuperAdminSettingsPage'
|
||||||
import { CountriesPage } from '@/pages/CountriesPage'
|
import { CountriesPage } from '@/pages/CountriesPage'
|
||||||
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
||||||
import { SuperAdminUnitsOfMeasurePage } from '@/pages/SuperAdminUnitsOfMeasurePage'
|
|
||||||
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
||||||
import { StoresPage } from '@/pages/StoresPage'
|
import { StoresPage } from '@/pages/StoresPage'
|
||||||
import { RetailPointsPage } from '@/pages/RetailPointsPage'
|
import { RetailPointsPage } from '@/pages/RetailPointsPage'
|
||||||
|
|
@ -76,7 +75,7 @@ export default function App() {
|
||||||
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
|
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
|
||||||
<Route path="countries" element={<CountriesPage />} />
|
<Route path="countries" element={<CountriesPage />} />
|
||||||
<Route path="groups" element={<ProductGroupsPage />} />
|
<Route path="groups" element={<ProductGroupsPage />} />
|
||||||
<Route path="units" element={<SuperAdminUnitsOfMeasurePage />} />
|
<Route path="units" element={<UnitsOfMeasurePage />} />
|
||||||
<Route path="settings" element={<SuperAdminSettingsPage />} />
|
<Route path="settings" element={<SuperAdminSettingsPage />} />
|
||||||
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
|
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,7 @@ export interface Country {
|
||||||
vatRate: number
|
vatRate: number
|
||||||
}
|
}
|
||||||
export interface Currency { id: string; code: string; name: string; symbol: string }
|
export interface Currency { id: string; code: string; name: string; symbol: string }
|
||||||
export interface UnitOfMeasure {
|
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; organizationId: string | null }
|
||||||
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 PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
|
||||||
export interface Store {
|
export interface Store {
|
||||||
id: string; name: string; code: string | null; address: string | null; phone: string | null;
|
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 { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { Button } from '@/components/Button'
|
||||||
import { api } from '@/lib/api'
|
import { Modal } from '@/components/Modal'
|
||||||
import { useIsSuperAdmin } from '@/lib/useMe'
|
import { Field, TextInput } from '@/components/Field'
|
||||||
|
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||||
import type { UnitOfMeasure } from '@/lib/types'
|
import type { UnitOfMeasure } from '@/lib/types'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
const URL = '/api/catalog/units-of-measure'
|
const URL = '/api/catalog/units-of-measure'
|
||||||
|
|
||||||
export function UnitsOfMeasurePage() {
|
interface Form {
|
||||||
const isSuperAdmin = useIsSuperAdmin()
|
id?: string
|
||||||
const list = useCatalogList<UnitOfMeasure>(URL)
|
code: string
|
||||||
const qc = useQueryClient()
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
const enable = useMutation({
|
const blankForm: Form = { code: '', name: '', description: '' }
|
||||||
mutationFn: async (id: string) => (await api.post(`${URL}/${id}/enable`)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: [URL] }),
|
export function UnitsOfMeasurePage() {
|
||||||
})
|
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<UnitOfMeasure>(URL)
|
||||||
const disable = useMutation({
|
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||||
mutationFn: async (id: string) => (await api.delete(`${URL}/${id}/enable`)).data,
|
const [form, setForm] = useState<Form | null>(null)
|
||||||
onError: (err: unknown) => {
|
|
||||||
type ErrShape = { response?: { data?: { error?: string; products?: string[] } } }
|
const save = async () => {
|
||||||
const r = (err as ErrShape).response?.data
|
if (!form) return
|
||||||
const products = r?.products?.length ? `\n\nТовары: ${r.products.join(', ')}` : ''
|
const { id, ...payload } = form
|
||||||
alert((r?.error ?? 'Не удалось отключить.') + products)
|
if (id) await update.mutateAsync({ id, input: payload })
|
||||||
},
|
else await create.mutateAsync(payload)
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: [URL] }),
|
setForm(null)
|
||||||
})
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
title="Единицы измерения"
|
title="Единицы измерения"
|
||||||
description={
|
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
|
||||||
isSuperAdmin
|
|
||||||
? 'Глобальный справочник ОКЕИ. Управление полным справочником — на странице платформы.'
|
|
||||||
: 'Включите единицы, нужные вашей организации. Добавление новых единиц — у платформы.'
|
|
||||||
}
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={list.search} onChange={list.setSearch} />
|
<SearchBar value={search} onChange={setSearch} />
|
||||||
{isSuperAdmin && (
|
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
<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 && (
|
footer={data && data.total > 0 && (
|
||||||
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={list.data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={list.isLoading}
|
isLoading={isLoading}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
sortKey={list.sortKey}
|
sortKey={sortKey}
|
||||||
sortOrder={list.sortOrder}
|
sortOrder={sortOrder}
|
||||||
onSortChange={list.setSort}
|
onSortChange={setSort}
|
||||||
|
onRowClick={(r) => {
|
||||||
|
if (r.organizationId === null) {
|
||||||
|
alert('Эталонная единица измерения. Изменения недоступны — управляются на уровне платформы.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setForm({
|
||||||
|
id: r.id, code: r.code, name: r.name,
|
||||||
|
description: r.description ?? ''
|
||||||
|
})
|
||||||
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
{ 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: 'Описание', 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>
|
</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