food-market/src/food-market.api/Controllers/Organizations/EmployeesController.cs
nns e8a28ba1f6 feat(employees): двухступенчатое удаление — «уволить» → «удалить»
Полное физическое удаление сотрудника невозможно — у него FK из
retail_sales и supplies. Поэтому теперь два шага:

  IsActive=true                                  → активный
  IsActive=false + FiredAt                       → уволен (кнопка «Уволить»)
  IsActive=false + IsDeleted=true + DeletedAt    → удалён (кнопка «Удалить»)

— Domain: Employee получил поля IsDeleted/DeletedAt + миграция
  Phase5a_EmployeeSoftDelete (drop column возможен через Down).
- API EmployeesController.Delete:
  · если активен — переводит в Fired;
  · если уже уволен — ставит IsDeleted=true + DeletedAt;
  · если уже удалён — 409 Conflict;
  · гарды Owner и self применяются на ОБОИХ шагах.
- API EmployeesController.List: новый query-param ?status=
  active|fired|deleted|all (default: всё кроме deleted).
- DTO дополнен полями isDeleted, deletedAt, status (active/fired/deleted) —
  фронтэнд использует для бейджа и логики кнопок.
- UI EmployeesPage:
  · фильтр статуса в actions: «Активные и уволенные» (default),
    «Только активные», «Только уволенные», «Только удалённые»,
    «Все, включая удалённых».
  · колонка «Статус» теперь с цветным бейджем (emerald/amber/rose).
  · ФИО уволенного помечается «(уволен)», удалённого — line-through
    + «(удалён)».
  · кнопка-действие в модалке: «Уволить» если active, «Удалить» если
    fired, скрыта если уже deleted (заменена на pojaснение).
  · confirm-текст обоих шагов разный — юзер понимает что произойдёт.

Существующие связанные документы (продажи, поставки) ссылаются на
employees по FK; имена для UI берутся из employee.LastName/FirstName +
status — отображение «Иванов И.И. (удалён)» работает автоматически.
2026-05-06 11:32:07 +05:00

339 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Claims;
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.Http;
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,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt,
bool IsDeleted, DateTime? DeletedAt,
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
string Status,
IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf);
public record EmployeeInput(
string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
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,
[FromQuery] string? status, // active | fired | deleted | all (default: active+fired)
CancellationToken ct = default)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
// Фильтр по статусу. По умолчанию (status=null) — показываем активных
// и уволенных, удалённых скрываем; «all» включает удалённых.
switch (status)
{
case "active": q = q.Where(e => e.IsActive && !e.IsDeleted); break;
case "fired": q = q.Where(e => !e.IsActive && !e.IsDeleted); break;
case "deleted": q = q.Where(e => e.IsDeleted); break;
case "all": /* без фильтра */ break;
default: q = q.Where(e => !e.IsDeleted); break;
}
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.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
.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")]
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,
Salary = input.Salary, TaxNumber = input.TaxNumber,
Description = input.Description, ImageUrl = input.ImageUrl,
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")]
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();
// Гард для главного администратора организации (Organization.AccountOwnerUserId).
// По требованию: его роль и активность может менять только Супер-администратор
// платформы. В обычной tenant-админке — отказ. Удаление главного администратора
// обрабатывается отдельной веткой в DELETE.
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var isOwner = e.UserId is not null && ownerUserId == e.UserId;
if (isOwner)
{
if (input.RoleId != e.RoleId)
{
var newRole = await _db.EmployeeRoles.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
if (newRole is null || newRole.Name != "Администратор")
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя сменить роль главного администратора организации. " +
"Это действие выполняет только Супер-администратор платформы.",
});
}
if (!input.IsActive)
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя деактивировать главного администратора организации. " +
"Это действие выполняет только Супер-администратор платформы.",
});
}
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.Salary = input.Salary;
e.TaxNumber = input.TaxNumber;
e.Description = input.Description;
e.ImageUrl = input.ImageUrl;
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();
// Двухступенчатое удаление:
// IsActive=true → этот endpoint выполняет «увольнение» (Fired).
// IsActive=false && IsDeleted=false → этот endpoint выполняет soft-delete.
// IsDeleted=true → 409, уже удалён.
// Гарды (главный админ + self) применяются на ОБОИХ шагах.
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
if (currentUserId is not null && e.UserId == currentUserId)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя уволить или удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
});
}
if (e.UserId is not null)
{
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == e.OrganizationId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
if (ownerUserId == e.UserId)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя удалить главного администратора организации. " +
"Это действие выполняет только Супер-администратор платформы.",
});
}
}
if (e.IsDeleted)
return Conflict(new { error = "Сотрудник уже удалён." });
if (e.IsActive)
{
// Шаг 1: увольнение.
e.IsActive = false;
e.FiredAt ??= DateTime.UtcNow;
}
else
{
// Шаг 2: soft-delete (физически не удаляем — есть FK из retail_sales/supplies).
e.IsDeleted = true;
e.DeletedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static Guid? ParseUserId(string? raw) => Guid.TryParse(raw, out var g) ? g : null;
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
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.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
.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());
}
}