feat(api): EmployeesController + EmployeeRolesController + invite-with-temp-password
Some checks failed
Some checks failed
EmployeeRolesController (/api/organization/employee-roles):
- List/Get/Create/Update/Delete. Системные роли (IsSystem=true) — нельзя
удалить (409), но имя/описание/permissions редактируются (чтобы можно
было кастомизировать набор галок). Удаление 409 если роль уже
используется сотрудниками.
EmployeesController (/api/organization/employees):
- List с поиском по фамилии/имени/email/телефону.
- Create:
- LastName, FirstName, MiddleName, Position, Email, Phone, RoleId, IsActive
- RetailPointIds[] — для роли Кассир привязка к нескольким кассам;
хранится в employee_retail_point_assignments.
- CreateAccount=true → одновременно создаём User (Identity) с email и
случайным temp-паролем (12 символов, все классы), возвращаем в
response.GeneratedPassword один раз — UI покажет «выдайте сотруднику».
- Update — replace assignments wholesale; IsActive false → проставляем
FiredAt=now (восстановление обнуляет).
- Delete — без проверок на FK документов (на этом этапе нет других
ссылок на Employee, кроме CASCADE-связи с retail-point assignments).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b40d1d9835
commit
062eb44fbb
|
|
@ -0,0 +1,102 @@
|
|||
using foodmarket.Application.Common;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Domain.Organizations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Controllers.Organizations;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/organization/employee-roles")]
|
||||
public class EmployeeRolesController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITenantContext _tenant;
|
||||
|
||||
public EmployeeRolesController(AppDbContext db, ITenantContext tenant)
|
||||
{
|
||||
_db = db; _tenant = tenant;
|
||||
}
|
||||
|
||||
public record EmployeeRoleDto(
|
||||
Guid Id, string Name, string? Description,
|
||||
bool IsSystem, int SortOrder, RolePermissions Permissions);
|
||||
|
||||
public record EmployeeRoleInput(
|
||||
string Name, string? Description, RolePermissions Permissions);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<EmployeeRoleDto>>> List(
|
||||
[FromQuery] PagedRequest req, CancellationToken ct)
|
||||
{
|
||||
var q = _db.EmployeeRoles.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
q = q.Where(r => r.Name.ToLower().Contains(s));
|
||||
}
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await q
|
||||
.OrderBy(r => r.SortOrder).ThenBy(r => r.Name)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(r => new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<EmployeeRoleDto>
|
||||
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<EmployeeRoleDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var r = await _db.EmployeeRoles.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return r is null ? NotFound() : new EmployeeRoleDto(r.Id, r.Name, r.Description, r.IsSystem, r.SortOrder, r.Permissions);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||
public async Task<ActionResult<EmployeeRoleDto>> Create([FromBody] EmployeeRoleInput input, CancellationToken ct)
|
||||
{
|
||||
var role = new EmployeeRole
|
||||
{
|
||||
Name = input.Name,
|
||||
Description = input.Description,
|
||||
IsSystem = false,
|
||||
SortOrder = await _db.EmployeeRoles.MaxAsync(r => (int?)r.SortOrder, ct) + 10 ?? 100,
|
||||
Permissions = input.Permissions ?? new RolePermissions(),
|
||||
};
|
||||
_db.EmployeeRoles.Add(role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = role.Id },
|
||||
new EmployeeRoleDto(role.Id, role.Name, role.Description, role.IsSystem, role.SortOrder, role.Permissions));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeRoleInput input, CancellationToken ct)
|
||||
{
|
||||
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (r is null) return NotFound();
|
||||
// Системные роли — имя редактируем (можно перевести), но IsSystem нельзя
|
||||
// снять; permissions можно править, чтобы кастомизировать под себя.
|
||||
r.Name = input.Name;
|
||||
r.Description = input.Description;
|
||||
r.Permissions = input.Permissions ?? new RolePermissions();
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var r = await _db.EmployeeRoles.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (r is null) return NotFound();
|
||||
if (r.IsSystem) return Conflict(new { error = "Системную роль удалить нельзя." });
|
||||
var inUse = await _db.Employees.AnyAsync(e => e.RoleId == id, ct);
|
||||
if (inUse) return Conflict(new { error = "Роль используется сотрудниками." });
|
||||
_db.EmployeeRoles.Remove(r);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_ = _tenant; // suppress warning
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
using foodmarket.Application.Common;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Domain.Organizations;
|
||||
using foodmarket.Infrastructure.Identity;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Controllers.Organizations;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/organization/employees")]
|
||||
public class EmployeesController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITenantContext _tenant;
|
||||
private readonly UserManager<User> _userMgr;
|
||||
|
||||
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr)
|
||||
{
|
||||
_db = db; _tenant = tenant; _userMgr = userMgr;
|
||||
}
|
||||
|
||||
public record EmployeeDto(
|
||||
Guid Id, Guid? UserId, string LastName, string FirstName, string? MiddleName,
|
||||
string? Position, string? Email, string? Phone,
|
||||
Guid RoleId, string RoleName,
|
||||
bool IsActive, DateTime? FiredAt,
|
||||
IReadOnlyList<Guid> RetailPointIds);
|
||||
|
||||
public record EmployeeInput(
|
||||
string LastName, string FirstName, string? MiddleName,
|
||||
string? Position, string? Email, string? Phone,
|
||||
Guid RoleId, bool IsActive,
|
||||
IReadOnlyList<Guid>? RetailPointIds,
|
||||
// CreateAccount=true → создаём User c email + temp password.
|
||||
// Возвращается в response один раз (showOnce).
|
||||
bool CreateAccount = false);
|
||||
|
||||
public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
|
||||
[FromQuery] PagedRequest req, CancellationToken ct)
|
||||
{
|
||||
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
q = q.Where(e =>
|
||||
e.LastName.ToLower().Contains(s) ||
|
||||
e.FirstName.ToLower().Contains(s) ||
|
||||
(e.Email != null && e.Email.ToLower().Contains(s)) ||
|
||||
(e.Phone != null && e.Phone.Contains(s)));
|
||||
}
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await q
|
||||
.OrderBy(e => e.LastName).ThenBy(e => e.FirstName)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(e => new EmployeeDto(
|
||||
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
|
||||
e.Position, e.Email, e.Phone,
|
||||
e.RoleId, e.Role.Name,
|
||||
e.IsActive, e.FiredAt,
|
||||
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<EmployeeDto>
|
||||
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var dto = await ProjectAsync(id, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct);
|
||||
if (!roleExists) return BadRequest(new { error = "Роль не найдена." });
|
||||
|
||||
var employee = new Employee
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
|
||||
Position = input.Position, Email = input.Email, Phone = input.Phone,
|
||||
RoleId = input.RoleId, IsActive = input.IsActive,
|
||||
};
|
||||
|
||||
string? tempPassword = null;
|
||||
if (input.CreateAccount)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Email))
|
||||
return BadRequest(new { error = "Для создания учётной записи нужен email." });
|
||||
var existing = await _userMgr.FindByEmailAsync(input.Email);
|
||||
if (existing is not null)
|
||||
return BadRequest(new { error = $"Пользователь с email «{input.Email}» уже существует." });
|
||||
|
||||
tempPassword = GenerateTempPassword();
|
||||
var user = new User
|
||||
{
|
||||
UserName = input.Email,
|
||||
Email = input.Email,
|
||||
EmailConfirmed = true,
|
||||
FullName = $"{input.LastName} {input.FirstName}".Trim(),
|
||||
OrganizationId = orgId,
|
||||
IsActive = input.IsActive,
|
||||
};
|
||||
var result = await _userMgr.CreateAsync(user, tempPassword);
|
||||
if (!result.Succeeded)
|
||||
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
|
||||
employee.UserId = user.Id;
|
||||
}
|
||||
|
||||
foreach (var rpId in input.RetailPointIds ?? [])
|
||||
{
|
||||
employee.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
|
||||
{ OrganizationId = orgId, RetailPointId = rpId });
|
||||
}
|
||||
_db.Employees.Add(employee);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var dto = await ProjectAsync(employee.Id, ct);
|
||||
return new EmployeeCreateResult(dto!, tempPassword);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin,Manager")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||
var e = await _db.Employees.Include(x => x.RetailPointAssignments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
e.LastName = input.LastName;
|
||||
e.FirstName = input.FirstName;
|
||||
e.MiddleName = input.MiddleName;
|
||||
e.Position = input.Position;
|
||||
e.Email = input.Email;
|
||||
e.Phone = input.Phone;
|
||||
e.RoleId = input.RoleId;
|
||||
var nowActive = input.IsActive;
|
||||
if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow;
|
||||
if (!e.IsActive && nowActive) e.FiredAt = null;
|
||||
e.IsActive = nowActive;
|
||||
|
||||
// Replace assignments wholesale
|
||||
_db.EmployeeRetailPointAssignments.RemoveRange(e.RetailPointAssignments);
|
||||
e.RetailPointAssignments.Clear();
|
||||
foreach (var rpId in input.RetailPointIds ?? [])
|
||||
e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
|
||||
{ OrganizationId = orgId, RetailPointId = rpId });
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
_db.Employees.Remove(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
return await _db.Employees.AsNoTracking()
|
||||
.Include(e => e.Role)
|
||||
.Include(e => e.RetailPointAssignments)
|
||||
.Where(e => e.Id == id)
|
||||
.Select(e => new EmployeeDto(
|
||||
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
|
||||
e.Position, e.Email, e.Phone,
|
||||
e.RoleId, e.Role.Name,
|
||||
e.IsActive, e.FiredAt,
|
||||
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
private static string GenerateTempPassword()
|
||||
{
|
||||
// 12 символов: цифры + строчные/заглавные + спецсимвол — соответствует
|
||||
// дефолтным правилам ASP.NET Identity (>=8, разные классы символов).
|
||||
const string lower = "abcdefghkmnpqrstuvwxyz";
|
||||
const string upper = "ABCDEFGHKMNPQRSTUVWXYZ";
|
||||
const string digits = "23456789";
|
||||
const string special = "!@#$%&*";
|
||||
var rnd = new Random();
|
||||
var chars = new List<char>
|
||||
{
|
||||
upper[rnd.Next(upper.Length)],
|
||||
lower[rnd.Next(lower.Length)],
|
||||
digits[rnd.Next(digits.Length)],
|
||||
special[rnd.Next(special.Length)],
|
||||
};
|
||||
var pool = lower + upper + digits;
|
||||
for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]);
|
||||
return new string(chars.OrderBy(_ => rnd.Next()).ToArray());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue