Полное физическое удаление сотрудника невозможно — у него 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 — отображение «Иванов И.И. (удалён)» работает автоматически.
339 lines
16 KiB
C#
339 lines
16 KiB
C#
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());
|
||
}
|
||
}
|