[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:
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user