feat(api): EmployeesController + EmployeeRolesController + invite-with-temp-password

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:
nns 2026-04-26 12:03:39 +05:00
parent 0ff31d1450
commit c714ec265c
2 changed files with 310 additions and 0 deletions

View file

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

View file

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