[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

@ -18,6 +18,12 @@ public class DepartmentsController(IMediator mediator) : ControllerBase
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListDepartmentsQuery { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
// Cây tổ chức phân cấp phòng ban (nền trang Hồ sơ Nhân sự). Authz = giống
// [HttpGet] List ở trên (chỉ class-level [Authorize], không attribute per-action).
[HttpGet("tree")]
public async Task<ActionResult<List<DepartmentTreeNodeDto>>> Tree(CancellationToken ct)
=> Ok(await mediator.Send(new GetDepartmentTreeQuery(), ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<DepartmentDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetDepartmentQuery(id), ct));

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);
}
}

View File

@ -8,4 +8,8 @@ public class Department : AuditableEntity
public string Name { get; set; } = string.Empty; // vd "Phòng Kiểm soát Chi phí"
public Guid? ManagerUserId { get; set; } // TPB — Trưởng Phòng ban
public string? Note { get; set; }
// Phân cấp phòng ban (cây tổ chức) — loose-Guid KHÔNG FK vật lý (convention hệ
// này: PE.ProjectId/WorkItemId/SelectedSupplierId đều loose-Guid). Root = null.
public Guid? ParentId { get; set; }
}

View File

@ -16,6 +16,7 @@ public class DepartmentConfiguration : IEntityTypeConfiguration<Department>
b.Property(x => x.Note).HasMaxLength(1000);
b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 47 (gotcha #57 EXT) — soft-deleted slot reusable, khớp HasQueryFilter !IsDeleted app-check
b.HasIndex(x => x.ParentId); // cây tổ chức — loose-Guid KHÔNG HasOne self-FK (convention loose-Guid hệ này)
b.HasQueryFilter(x => !x.IsDeleted);
}

View File

@ -0,0 +1,38 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddDepartmentParentId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ParentId",
table: "Departments",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Departments_ParentId",
table: "Departments",
column: "ParentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Departments_ParentId",
table: "Departments");
migrationBuilder.DropColumn(
name: "ParentId",
table: "Departments");
}
}
}

View File

@ -3101,6 +3101,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid?>("ParentId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
@ -3113,6 +3116,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.HasIndex("ParentId");
b.ToTable("Departments", (string)null);
});