[CLAUDE] Infra: phân cấp phòng ban (Department.ParentId) + /tree + gán phòng cha

Nền cây tổ chức cho trang Hồ sơ Nhân sự (anh: bố trí giống NamGroup; chốt phân
cấp thật + tự gán phòng cha trong quản lý phòng ban).
- Mig AddDepartmentParentId: +ParentId Guid? loose-Guid (no FK vật lý, convention
  PE) + IX_Departments_ParentId. AddColumn+CreateIndex, no new table, Down reversible
  (DropIndex->DropColumn). Chưa apply (CI/prod seed apply).
- GET /api/departments/tree: ráp cây in-memory + đếm NV active theo User.DepartmentId
  (EmployeeProfile KHÔNG có DepartmentId — link qua User, field phòng ban ở User Mig 11)
  + rollup TotalEmployeeCount + cycle-guard HashSet. Authz = class [Authorize] (= GET list).
- Create/Update +ParentId (write vẫn khóa Admin,CatalogManager). Update có cycle-guard:
  chặn tự-làm-cha + đi ngược chuỗi cha gặp lại Id (chống vòng A->B->A).
Build PASS (0 warn/err full solution). Test-after (test-specialist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-16 10:34:52 +07:00
parent c98030f27c
commit 0f44d9754d
7 changed files with 6370 additions and 4 deletions

View File

@ -14,9 +14,92 @@ public record DepartmentDto(
string Name,
Guid? ManagerUserId,
string? Note,
Guid? ParentId,
DateTime CreatedAt,
DateTime? UpdatedAt);
// ===== Cây tổ chức (Department hierarchy) — nền trang Hồ sơ Nhân sự =====
// DirectEmployeeCount = NV active thuộc trực tiếp phòng. TotalEmployeeCount =
// Direct + Σ con đệ quy (rollup). Children sort theo Code ổn định.
public record DepartmentTreeNodeDto(
Guid Id,
string Code,
string Name,
Guid? ParentId,
int DirectEmployeeCount,
int TotalEmployeeCount,
List<DepartmentTreeNodeDto> Children);
public record GetDepartmentTreeQuery : IRequest<List<DepartmentTreeNodeDto>>;
public class GetDepartmentTreeQueryHandler(IApplicationDbContext db) : IRequestHandler<GetDepartmentTreeQuery, List<DepartmentTreeNodeDto>>
{
public async Task<List<DepartmentTreeNodeDto>> Handle(GetDepartmentTreeQuery request, CancellationToken ct)
{
// 1) Load tất cả phòng phẳng (HasQueryFilter !IsDeleted tự áp).
var flat = await db.Departments.AsNoTracking()
.Select(d => new { d.Id, d.Code, d.Name, d.ParentId })
.ToListAsync(ct);
// 2) Đếm NV active per-dept. Nguồn org-chart = User.DepartmentId
// (EmployeeProfile KHÔNG có DepartmentId — liên kết qua UserId; field
// phòng ban nằm trên User entity từ Mig 11). Active = IsActive=true.
var directCounts = await db.Users.AsNoTracking()
.Where(u => u.DepartmentId != null && u.IsActive)
.GroupBy(u => u.DepartmentId!.Value)
.Select(g => new { DeptId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.DeptId, x => x.Count, ct);
// 3) Dựng node + index theo ParentId.
var nodes = flat.ToDictionary(
f => f.Id,
f => new DepartmentTreeNodeDto(
f.Id, f.Code, f.Name, f.ParentId,
directCounts.TryGetValue(f.Id, out var c) ? c : 0,
0, // TotalEmployeeCount tính sau khi ráp tree
new List<DepartmentTreeNodeDto>()));
var roots = new List<DepartmentTreeNodeDto>();
foreach (var f in flat)
{
var node = nodes[f.Id];
// Root = ParentId null HOẶC trỏ tới phòng không tồn tại (orphan an toàn coi như root).
if (f.ParentId is Guid pid && nodes.TryGetValue(pid, out var parent))
parent.Children.Add(node);
else
roots.Add(node);
}
// 4) Rollup Total + sort Children theo Code — đệ quy có cycle-guard
// (HashSet visited Id) để chống vòng lặp vô hạn nếu data cha-trỏ-vòng.
var visited = new HashSet<Guid>();
var result = new List<DepartmentTreeNodeDto>();
foreach (var root in roots.OrderBy(r => r.Code, StringComparer.Ordinal))
{
var rolled = RollupAndSort(root, visited);
if (rolled is not null) result.Add(rolled);
}
return result;
}
// Trả node mới với TotalEmployeeCount = Direct + Σ(Children.Total) + Children
// đã sort & rollup. Cycle-guard: node đã thăm → bỏ qua (return null).
private static DepartmentTreeNodeDto? RollupAndSort(DepartmentTreeNodeDto node, HashSet<Guid> visited)
{
if (!visited.Add(node.Id)) return null; // đã thăm → cắt vòng lặp
var children = new List<DepartmentTreeNodeDto>();
foreach (var child in node.Children.OrderBy(c => c.Code, StringComparer.Ordinal))
{
var rolled = RollupAndSort(child, visited);
if (rolled is not null) children.Add(rolled);
}
var total = node.DirectEmployeeCount + children.Sum(c => c.TotalEmployeeCount);
return node with { Children = children, TotalEmployeeCount = total };
}
}
public record ListDepartmentsQuery : PagedRequest, IRequest<PagedResult<DepartmentDto>>;
public class ListDepartmentsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListDepartmentsQuery, PagedResult<DepartmentDto>>
@ -40,7 +123,7 @@ public class ListDepartmentsQueryHandler(IApplicationDbContext db) : IRequestHan
var total = await query.CountAsync(ct);
var items = await query
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.CreatedAt, x.UpdatedAt))
.Select(x => new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.ParentId, x.CreatedAt, x.UpdatedAt))
.ToListAsync(ct);
return new PagedResult<DepartmentDto>(items, total, request.Page, request.PageSize);
}
@ -54,11 +137,11 @@ public class GetDepartmentQueryHandler(IApplicationDbContext db) : IRequestHandl
{
var x = await db.Departments.AsNoTracking().FirstOrDefaultAsync(p => p.Id == request.Id, ct)
?? throw new NotFoundException("Department", request.Id);
return new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.CreatedAt, x.UpdatedAt);
return new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.ParentId, x.CreatedAt, x.UpdatedAt);
}
}
public record CreateDepartmentCommand(string Code, string Name, Guid? ManagerUserId, string? Note) : IRequest<Guid>;
public record CreateDepartmentCommand(string Code, string Name, Guid? ManagerUserId, string? Note, Guid? ParentId) : IRequest<Guid>;
public class CreateDepartmentCommandValidator : AbstractValidator<CreateDepartmentCommand>
{
@ -79,6 +162,7 @@ public class CreateDepartmentCommandHandler(IApplicationDbContext db) : IRequest
{
Code = request.Code, Name = request.Name,
ManagerUserId = request.ManagerUserId, Note = request.Note,
ParentId = request.ParentId,
};
db.Departments.Add(entity);
await db.SaveChangesAsync(ct);
@ -86,7 +170,7 @@ public class CreateDepartmentCommandHandler(IApplicationDbContext db) : IRequest
}
}
public record UpdateDepartmentCommand(Guid Id, string Code, string Name, Guid? ManagerUserId, string? Note) : IRequest;
public record UpdateDepartmentCommand(Guid Id, string Code, string Name, Guid? ManagerUserId, string? Note, Guid? ParentId) : IRequest;
public class UpdateDepartmentCommandValidator : AbstractValidator<UpdateDepartmentCommand>
{
@ -106,10 +190,33 @@ public class UpdateDepartmentCommandHandler(IApplicationDbContext db) : IRequest
?? throw new NotFoundException("Department", request.Id);
if (entity.Code != request.Code && await db.Departments.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct))
throw new ConflictException($"Mã phòng ban '{request.Code}' đã tồn tại.");
// Chống vòng lặp phân cấp khi gán phòng cha: (1) không tự làm cha mình;
// (2) không gán cha là phòng con-cháu của chính nó — đi ngược chuỗi cha
// từ phòng cha mới, nếu gặp lại Id này ⇒ sẽ tạo vòng. HashSet guard cắt
// loop nếu data cũ đã lỗi (cha-trỏ-vòng sẵn).
if (request.ParentId is Guid newParent)
{
if (newParent == request.Id)
throw new ConflictException("Phòng không thể là cấp cha của chính nó.");
var cursor = newParent;
var guard = new HashSet<Guid>();
while (guard.Add(cursor))
{
var grandParent = await db.Departments.AsNoTracking()
.Where(x => x.Id == cursor).Select(x => x.ParentId).FirstOrDefaultAsync(ct);
if (grandParent is null) break;
if (grandParent.Value == request.Id)
throw new ConflictException("Không thể gán: sẽ tạo vòng lặp phân cấp phòng ban.");
cursor = grandParent.Value;
}
}
entity.Code = request.Code;
entity.Name = request.Name;
entity.ManagerUserId = request.ManagerUserId;
entity.Note = request.Note;
entity.ParentId = request.ParentId;
await db.SaveChangesAsync(ct);
}
}