From 062eb44fbba39195ee34c7ce3b7ccdb754dca1b9 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:03:39 +0500 Subject: [PATCH] feat(api): EmployeesController + EmployeeRolesController + invite-with-temp-password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Organizations/EmployeeRolesController.cs | 102 +++++++++ .../Organizations/EmployeesController.cs | 208 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 src/food-market.api/Controllers/Organizations/EmployeeRolesController.cs create mode 100644 src/food-market.api/Controllers/Organizations/EmployeesController.cs diff --git a/src/food-market.api/Controllers/Organizations/EmployeeRolesController.cs b/src/food-market.api/Controllers/Organizations/EmployeeRolesController.cs new file mode 100644 index 0000000..18abcde --- /dev/null +++ b/src/food-market.api/Controllers/Organizations/EmployeeRolesController.cs @@ -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>> 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 + { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> 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> 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 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 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(); + } +} diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs new file mode 100644 index 0000000..fafb362 --- /dev/null +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -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 _userMgr; + + public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager 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 RetailPointIds); + + public record EmployeeInput( + string LastName, string FirstName, string? MiddleName, + string? Position, string? Email, string? Phone, + Guid RoleId, bool IsActive, + IReadOnlyList? RetailPointIds, + // CreateAccount=true → создаём User c email + temp password. + // Возвращается в response один раз (showOnce). + bool CreateAccount = false); + + public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword); + + [HttpGet] + public async Task>> 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 + { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> 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> 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 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 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 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 + { + 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()); + } +}