[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)
|
CancellationToken ct = default)
|
||||||
=> Ok(await mediator.Send(new ListDepartmentsQuery { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
|
=> 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}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<DepartmentDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<DepartmentDto>> Get(Guid id, CancellationToken ct)
|
||||||
=> Ok(await mediator.Send(new GetDepartmentQuery(id), ct));
|
=> Ok(await mediator.Send(new GetDepartmentQuery(id), ct));
|
||||||
|
|||||||
@ -14,9 +14,92 @@ public record DepartmentDto(
|
|||||||
string Name,
|
string Name,
|
||||||
Guid? ManagerUserId,
|
Guid? ManagerUserId,
|
||||||
string? Note,
|
string? Note,
|
||||||
|
Guid? ParentId,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime? UpdatedAt);
|
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 record ListDepartmentsQuery : PagedRequest, IRequest<PagedResult<DepartmentDto>>;
|
||||||
|
|
||||||
public class ListDepartmentsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListDepartmentsQuery, 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 total = await query.CountAsync(ct);
|
||||||
var items = await query
|
var items = await query
|
||||||
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
|
.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);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<DepartmentDto>(items, total, request.Page, request.PageSize);
|
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)
|
var x = await db.Departments.AsNoTracking().FirstOrDefaultAsync(p => p.Id == request.Id, ct)
|
||||||
?? throw new NotFoundException("Department", request.Id);
|
?? 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>
|
public class CreateDepartmentCommandValidator : AbstractValidator<CreateDepartmentCommand>
|
||||||
{
|
{
|
||||||
@ -79,6 +162,7 @@ public class CreateDepartmentCommandHandler(IApplicationDbContext db) : IRequest
|
|||||||
{
|
{
|
||||||
Code = request.Code, Name = request.Name,
|
Code = request.Code, Name = request.Name,
|
||||||
ManagerUserId = request.ManagerUserId, Note = request.Note,
|
ManagerUserId = request.ManagerUserId, Note = request.Note,
|
||||||
|
ParentId = request.ParentId,
|
||||||
};
|
};
|
||||||
db.Departments.Add(entity);
|
db.Departments.Add(entity);
|
||||||
await db.SaveChangesAsync(ct);
|
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>
|
public class UpdateDepartmentCommandValidator : AbstractValidator<UpdateDepartmentCommand>
|
||||||
{
|
{
|
||||||
@ -106,10 +190,33 @@ public class UpdateDepartmentCommandHandler(IApplicationDbContext db) : IRequest
|
|||||||
?? throw new NotFoundException("Department", request.Id);
|
?? 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))
|
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.");
|
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.Code = request.Code;
|
||||||
entity.Name = request.Name;
|
entity.Name = request.Name;
|
||||||
entity.ManagerUserId = request.ManagerUserId;
|
entity.ManagerUserId = request.ManagerUserId;
|
||||||
entity.Note = request.Note;
|
entity.Note = request.Note;
|
||||||
|
entity.ParentId = request.ParentId;
|
||||||
await db.SaveChangesAsync(ct);
|
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 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 Guid? ManagerUserId { get; set; } // TPB — Trưởng Phòng ban
|
||||||
public string? Note { get; set; }
|
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.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.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);
|
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)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("nvarchar(1000)");
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@ -3113,6 +3116,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("[IsDeleted] = 0");
|
.HasFilter("[IsDeleted] = 0");
|
||||||
|
|
||||||
|
b.HasIndex("ParentId");
|
||||||
|
|
||||||
b.ToTable("Departments", (string)null);
|
b.ToTable("Departments", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user