[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:
@ -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));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user