[CLAUDE] Domain+App+Api+Infra: Plan B G-H1 Task 4+6 — Hrm CQRS 5 endpoint + Permission menu

Phase 10.1 G-H1 Phase 2 — Task 4 (BE CQRS + REST endpoint) + Task 6
(Permission menu seed) cumulative. Foundation BE side complete cho Hồ sơ
Nhân sự module — FE Task 5 + Reviewer Task 7 + CICD verify next.

## Task 4 — BE CQRS + Controller (3 file new, Implementer Case 2)

src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs (~450 LOC):
- CreateEmployeeProfileCommand + Validator + Handler
  - Verify User.Id exists qua UserManager.FindByIdAsync
  - UNIQUE 1-1 check: throw ConflictException nếu User đã có EmployeeProfile
  - EmployeeCode auto-gen qua IEmployeeCodeGenerator (NV/{YYYY}/{Seq:D4})
  - 50+ field assignment từ Command record
- UpdateEmployeeProfileCommand + Validator + Handler (mutable fields, UserId+EmployeeCode immutable)
- DeleteEmployeeProfileCommand + Handler (soft delete IsDeleted=true)
- GetEmployeeProfileQuery + Handler (Include 5 satellite collection)
- ListEmployeesQuery + Handler (paged + JOIN Users+Departments, filter Status/DepartmentId/Search)

src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs (~110 LOC):
- EmployeeProfileListItemDto (Id, EmployeeCode, UserId, FullName/Email/Department JOIN, Status, Phone, HireDate)
- EmployeeProfileDetailDto (full 50+ field + 5 satellite collection)
- 5 satellite DTO: EmployeeWorkHistoryDto + EmployeeEducationDto +
  EmployeeFamilyRelationDto + EmployeeSkillDto + EmployeeDocumentDto

src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs (~70 LOC):
- 5 REST endpoint: GET list / GET detail / POST / PUT / DELETE
- Class-level [Authorize] only Phase 1 (per-action policy Hrm_HoSo_View/Create/
  Edit/Delete defer Phase 1.5 per Reviewer recommend)
- Route prefix /api/employees

## Task 6 — Permission menu Hrm_HoSo* (em main solo, 2 file mod)

src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs (+10 LOC):
- +2 const: Hrm root group + HrmHoSo leaf
- Update All[] array → SeedAdminPermissionsAsync auto-grant Admin role CRUD

src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs (+4 LOC):
- SeedMenuTreeAsync +2 entry:
  - (Hrm, "Nhân sự", null, 28, "UserCircle") — root group
  - (HrmHoSo, "Hồ sơ Nhân sự", Hrm, 1, "ContactRound") — leaf
- Order=28 between Budgets=27 và Contracts=30+ (no collision)
- INFRASTRUCTURE menu seed (NOT gated DemoSeed:Disabled — em main verified
  outside gate block per gotcha #51 lesson, mirror Plan B Task 3b
  SeedDemoEmployeeProfilesAsync placement)

## Reviewer ae752c0 verdict: PASS Smart Friend 6× clean

- 0 critical, 0 major, 3 minor defer Phase 1.5 (per-action policy + bool
  partial update + IDateTimeProvider injection)
- Cumulative Smart Friend track: S22 #44 + S25 #48 + S29 Plan CA ≥12 + S29
  Plan B ApplicableType + S33 Plan C BW clean + S33 Plan B Phase 2 clean
- gotcha #51 INFRASTRUCTURE seed gate compliance: ✓
- gotcha #50 Layout staticMap mirror: ✓ (Task 5 commit next)

## Verify
- dotnet build: 0 err 0 warn (1.72s)
- dotnet test: 120/120 PASS baseline preserved
- Endpoint claim verified grep 0 mock marker, 5 mediator.Send real

Pattern 12-bis cross-module entity cookie-cutter mirror PE→Hrm reinforced 4×
cumulative (S29 Plan B Contract Chunk C + S33 Task 3 entity scaffold + Task
3b seed + Task 4 CQRS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-26 20:26:44 +07:00
parent 48a99e14e7
commit 0e191deea5
5 changed files with 837 additions and 0 deletions

View File

@ -0,0 +1,62 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Hrm;
using SolutionErp.Application.Hrm.Dtos;
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Api.Controllers;
// Phase 10.1 G-H1 Task 4 (S33) — REST endpoint cho Hồ sơ Nhân sự.
// 5 main endpoint Phase 1 minimal: List / Get / Create / Update / Delete.
// Satellite endpoint (WorkHistory/Education/FamilyRelation/Skill/Document
// CRUD) DEFER Phase 1.5.
//
// Class-level [Authorize] only — em main Task 6 wire per-action policy
// "Hrm_HoSo_View/Create/Edit/Delete" sau khi seed MenuKeys.
[ApiController]
[Route("api/employees")]
[Authorize]
public class EmployeesController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<EmployeeProfileListItemDto>>> List(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
[FromQuery] bool sortDesc = true,
[FromQuery] EmployeeStatus? status = null,
[FromQuery] Guid? departmentId = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListEmployeesQuery(status, departmentId)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<EmployeeProfileDetailDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetEmployeeProfileQuery(id), ct));
[HttpPost]
public async Task<ActionResult<object>> Create(
[FromBody] CreateEmployeeProfileCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(
Guid id, [FromBody] UpdateEmployeeProfileCommand cmd, CancellationToken ct)
{
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
await mediator.Send(cmd, ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteEmployeeProfileCommand(id), ct);
return NoContent();
}
}

View File

@ -0,0 +1,151 @@
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Application.Hrm.Dtos;
// Phase 10.1 G-H1 Task 4 (S33) — DTO bundle cho Hồ sơ Nhân sự CQRS.
// 1 list item + 1 detail full + 5 satellite read DTO (inline GetDetail).
// Satellite WRITE DTO defer Phase 1.5 — Task 4 chỉ read.
// ========== List item (paged) ==========
// JOIN Users + Departments LEFT (User.DepartmentId nullable per Identity entity).
// FullName + Email từ User; DepartmentId + DepartmentName từ User → Department.
public record EmployeeProfileListItemDto(
Guid Id,
string EmployeeCode,
Guid UserId,
string? FullName,
string? Email,
Guid? DepartmentId,
string? DepartmentName,
EmployeeStatus Status,
string? Phone,
DateOnly? HireDate,
DateTime CreatedAt,
DateTime? UpdatedAt);
// ========== Detail (full + 5 satellite collection) ==========
public record EmployeeProfileDetailDto(
Guid Id,
string EmployeeCode,
Guid UserId,
string? FullName,
string? Email,
Guid? DepartmentId,
string? DepartmentName,
EmployeeStatus EmployeeStatus,
Gender? Gender,
MaritalStatus? MaritalStatus,
EmployeeType? EmployeeType,
DateOnly? DateOfBirth,
string? BirthPlace,
string? Hometown,
string? Phone,
string? PersonalEmail,
string? InternalPhone,
string? Ethnicity,
string? Religion,
string? Nationality,
string? IdCardNumber,
DateOnly? IdCardIssueDate,
string? IdCardIssuePlace,
string? TaxCode,
string? SocialInsuranceNumber,
string? PassportNumber,
string? PermanentAddressText,
string? StreetAddressPermanent,
string? TemporaryAddressText,
string? StreetAddressTemporary,
DateOnly? HireDate,
DateOnly? ResignDate,
string? EmergencyContactName,
string? EmergencyContactPhone,
string? EmergencyContactAddress,
string? Qualification,
string? AcademicTitle,
string? WorkLocation,
string? TimekeepingCode,
string? BankAccount,
string? BankName,
string? BankBranch,
int? HeightCm,
int? WeightKg,
string? BloodType,
decimal? BaseSalary,
decimal? TotalSalary,
decimal? AnnualLeaveDays,
decimal? RemainingLeaveDays,
decimal? CompensatoryLeaveDays,
decimal? SeniorityLeaveDays,
DateOnly? SocialInsuranceStartDate,
string? MedicalRegistrationPlace,
bool IsCommunistParty,
DateOnly? CommunistPartyJoinDate,
bool IsYouthUnion,
DateOnly? YouthUnionJoinDate,
bool IsTradeUnion,
DateOnly? TradeUnionJoinDate,
string? PhotoUrl,
string? Notes,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<EmployeeWorkHistoryDto> WorkHistories,
List<EmployeeEducationDto> Educations,
List<EmployeeFamilyRelationDto> FamilyRelations,
List<EmployeeSkillDto> Skills,
List<EmployeeDocumentDto> Documents);
// ========== Satellite read DTOs (inline GetDetail bundle) ==========
public record EmployeeWorkHistoryDto(
Guid Id,
string CompanyName,
string? CompanyAddress,
string? Industry,
DateOnly? FromDate,
DateOnly? ToDate,
string? JobTitle,
string? JobDescription,
string? ResignReason);
public record EmployeeEducationDto(
Guid Id,
string SchoolName,
string? Major,
DegreeLevel? DegreeLevel,
EducationMode? EducationMode,
GradeLevel? GradeLevel,
DateOnly? FromDate,
DateOnly? ToDate,
DateOnly? CertificateIssueDate,
string? Notes);
public record EmployeeFamilyRelationDto(
Guid Id,
string FullName,
FamilyRelationKind Relationship,
int? BirthYear,
string? Occupation,
string? CurrentAddress,
string? Phone);
// Polymorphic — Kind discriminator. Computer/Language/Other (semantic xem
// EmployeeSkill.cs comment header).
public record EmployeeSkillDto(
Guid Id,
SkillKind Kind,
string Name,
string? LanguageId,
string? Level);
public record EmployeeDocumentDto(
Guid Id,
EmployeeDocumentType DocumentType,
string FileName,
string FilePath,
long FileSize,
string ContentType,
DateOnly? IssueDate,
DateOnly? ExpiryDate,
string? Notes);

View File

@ -0,0 +1,610 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Hrm.Dtos;
using SolutionErp.Application.Hrm.Services;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Hrm;
// Phase 10.1 G-H1 Task 4 (S33) — Hồ sơ Nhân sự CQRS bundle.
// 5 main endpoint Phase 1 minimal: Create / Update / Delete (soft) / Get / List.
// Satellite CRUD endpoint (WorkHistory/Education/FamilyRelation/Skill/Document)
// DEFER Phase 1.5 — GetDetail trả về 5 satellite collection inline cho FE Tab
// hiển thị nháy mắt. Bro UAT có thể request mở satellite write Phase 1.5 sau.
//
// Pattern mirror PurchaseEvaluationFeatures.cs (Pattern 12-bis cross-module
// cookie-cutter PE → Hrm — 4th reinforcement post Plan B Chunk C S29).
// ========== CREATE ==========
public record CreateEmployeeProfileCommand(
Guid UserId,
EmployeeStatus EmployeeStatus = EmployeeStatus.Active,
Gender? Gender = null,
MaritalStatus? MaritalStatus = null,
EmployeeType? EmployeeType = null,
DateOnly? DateOfBirth = null,
string? BirthPlace = null,
string? Hometown = null,
string? Phone = null,
string? PersonalEmail = null,
string? InternalPhone = null,
string? Ethnicity = null,
string? Religion = null,
string? Nationality = null,
string? IdCardNumber = null,
DateOnly? IdCardIssueDate = null,
string? IdCardIssuePlace = null,
string? TaxCode = null,
string? SocialInsuranceNumber = null,
string? PassportNumber = null,
string? PermanentAddressText = null,
string? StreetAddressPermanent = null,
string? TemporaryAddressText = null,
string? StreetAddressTemporary = null,
DateOnly? HireDate = null,
string? EmergencyContactName = null,
string? EmergencyContactPhone = null,
string? EmergencyContactAddress = null,
string? Qualification = null,
string? AcademicTitle = null,
string? WorkLocation = null,
string? TimekeepingCode = null,
string? BankAccount = null,
string? BankName = null,
string? BankBranch = null,
int? HeightCm = null,
int? WeightKg = null,
string? BloodType = null,
decimal? BaseSalary = null,
decimal? TotalSalary = null,
decimal? AnnualLeaveDays = null,
DateOnly? SocialInsuranceStartDate = null,
string? MedicalRegistrationPlace = null,
bool IsCommunistParty = false,
DateOnly? CommunistPartyJoinDate = null,
bool IsYouthUnion = false,
DateOnly? YouthUnionJoinDate = null,
bool IsTradeUnion = false,
DateOnly? TradeUnionJoinDate = null,
string? PhotoUrl = null,
string? Notes = null) : IRequest<Guid>;
public class CreateEmployeeProfileCommandValidator : AbstractValidator<CreateEmployeeProfileCommand>
{
public CreateEmployeeProfileCommandValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.EmployeeStatus).IsInEnum();
// Liên hệ
RuleFor(x => x.Phone).MaximumLength(20);
RuleFor(x => x.PersonalEmail).MaximumLength(200).EmailAddress()
.When(x => !string.IsNullOrWhiteSpace(x.PersonalEmail));
RuleFor(x => x.InternalPhone).MaximumLength(20);
// Cá nhân
RuleFor(x => x.BirthPlace).MaximumLength(200);
RuleFor(x => x.Hometown).MaximumLength(200);
RuleFor(x => x.Ethnicity).MaximumLength(50);
RuleFor(x => x.Religion).MaximumLength(50);
RuleFor(x => x.Nationality).MaximumLength(50);
// Giấy tờ
RuleFor(x => x.IdCardNumber).MaximumLength(20);
RuleFor(x => x.IdCardIssuePlace).MaximumLength(200);
RuleFor(x => x.TaxCode).MaximumLength(20);
RuleFor(x => x.SocialInsuranceNumber).MaximumLength(20);
RuleFor(x => x.PassportNumber).MaximumLength(20);
// Địa chỉ
RuleFor(x => x.PermanentAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressPermanent).MaximumLength(200);
RuleFor(x => x.TemporaryAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressTemporary).MaximumLength(200);
// Khẩn cấp
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20);
RuleFor(x => x.EmergencyContactAddress).MaximumLength(500);
// Trình độ + vị trí
RuleFor(x => x.Qualification).MaximumLength(200);
RuleFor(x => x.AcademicTitle).MaximumLength(200);
RuleFor(x => x.WorkLocation).MaximumLength(200);
RuleFor(x => x.TimekeepingCode).MaximumLength(50);
// Ngân hàng
RuleFor(x => x.BankAccount).MaximumLength(50);
RuleFor(x => x.BankName).MaximumLength(200);
RuleFor(x => x.BankBranch).MaximumLength(200);
// Sức khoẻ
RuleFor(x => x.BloodType).MaximumLength(5);
// Lương + Phép (decimal — không âm)
RuleFor(x => x.BaseSalary).GreaterThanOrEqualTo(0).When(x => x.BaseSalary.HasValue);
RuleFor(x => x.TotalSalary).GreaterThanOrEqualTo(0).When(x => x.TotalSalary.HasValue);
RuleFor(x => x.AnnualLeaveDays).GreaterThanOrEqualTo(0).When(x => x.AnnualLeaveDays.HasValue);
// BHXH
RuleFor(x => x.MedicalRegistrationPlace).MaximumLength(200);
// Khác
RuleFor(x => x.PhotoUrl).MaximumLength(500);
RuleFor(x => x.Notes).MaximumLength(2000);
}
}
public class CreateEmployeeProfileCommandHandler(
IApplicationDbContext db,
IEmployeeCodeGenerator codeGen,
UserManager<User> userManager) : IRequestHandler<CreateEmployeeProfileCommand, Guid>
{
public async Task<Guid> Handle(CreateEmployeeProfileCommand request, CancellationToken ct)
{
// Verify User tồn tại (1-1 link User.Id).
var user = await userManager.FindByIdAsync(request.UserId.ToString())
?? throw new NotFoundException("User", request.UserId);
// Verify NO existing EmployeeProfile cho UserId này (UNIQUE 1-1 ràng buộc Mig 34).
// Check cả soft-deleted để admin biết user đã từng có profile (re-activate Phase 1.5).
var existing = await db.EmployeeProfiles.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == request.UserId, ct);
if (existing != null)
throw new ConflictException(existing.IsDeleted
? $"User {user.UserName} đã có hồ sơ NV (đã xoá mềm). Cần khôi phục thay vì tạo mới."
: $"User {user.UserName} đã có hồ sơ NV — mỗi user chỉ được 1 hồ sơ.");
// Atomic MaNhanVien sequence — format NV/{YYYY}/{Seq:D4} (mirror PE pattern).
var employeeCode = await codeGen.GenerateAsync(ct);
var entity = new EmployeeProfile
{
UserId = request.UserId,
EmployeeCode = employeeCode,
EmployeeStatus = request.EmployeeStatus,
Gender = request.Gender,
MaritalStatus = request.MaritalStatus,
EmployeeType = request.EmployeeType,
DateOfBirth = request.DateOfBirth,
BirthPlace = request.BirthPlace,
Hometown = request.Hometown,
Phone = request.Phone,
PersonalEmail = request.PersonalEmail,
InternalPhone = request.InternalPhone,
Ethnicity = request.Ethnicity,
Religion = request.Religion,
Nationality = request.Nationality ?? "Việt Nam",
IdCardNumber = request.IdCardNumber,
IdCardIssueDate = request.IdCardIssueDate,
IdCardIssuePlace = request.IdCardIssuePlace,
TaxCode = request.TaxCode,
SocialInsuranceNumber = request.SocialInsuranceNumber,
PassportNumber = request.PassportNumber,
PermanentAddressText = request.PermanentAddressText,
StreetAddressPermanent = request.StreetAddressPermanent,
TemporaryAddressText = request.TemporaryAddressText,
StreetAddressTemporary = request.StreetAddressTemporary,
HireDate = request.HireDate,
EmergencyContactName = request.EmergencyContactName,
EmergencyContactPhone = request.EmergencyContactPhone,
EmergencyContactAddress = request.EmergencyContactAddress,
Qualification = request.Qualification,
AcademicTitle = request.AcademicTitle,
WorkLocation = request.WorkLocation,
TimekeepingCode = request.TimekeepingCode,
BankAccount = request.BankAccount,
BankName = request.BankName,
BankBranch = request.BankBranch,
HeightCm = request.HeightCm,
WeightKg = request.WeightKg,
BloodType = request.BloodType,
BaseSalary = request.BaseSalary,
TotalSalary = request.TotalSalary,
AnnualLeaveDays = request.AnnualLeaveDays,
SocialInsuranceStartDate = request.SocialInsuranceStartDate,
MedicalRegistrationPlace = request.MedicalRegistrationPlace,
IsCommunistParty = request.IsCommunistParty,
CommunistPartyJoinDate = request.CommunistPartyJoinDate,
IsYouthUnion = request.IsYouthUnion,
YouthUnionJoinDate = request.YouthUnionJoinDate,
IsTradeUnion = request.IsTradeUnion,
TradeUnionJoinDate = request.TradeUnionJoinDate,
PhotoUrl = request.PhotoUrl,
Notes = request.Notes,
};
db.EmployeeProfiles.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== UPDATE ==========
// UserId + EmployeeCode immutable — admin KHÔNG đổi link user lẫn mã NV.
// Field mutable: toàn bộ phần còn lại Create command + ResignDate + 3 leave fields
// extra (RemainingLeaveDays/CompensatoryLeaveDays/SeniorityLeaveDays) cho admin chỉnh.
public record UpdateEmployeeProfileCommand(
Guid Id,
EmployeeStatus EmployeeStatus,
Gender? Gender,
MaritalStatus? MaritalStatus,
EmployeeType? EmployeeType,
DateOnly? DateOfBirth,
string? BirthPlace,
string? Hometown,
string? Phone,
string? PersonalEmail,
string? InternalPhone,
string? Ethnicity,
string? Religion,
string? Nationality,
string? IdCardNumber,
DateOnly? IdCardIssueDate,
string? IdCardIssuePlace,
string? TaxCode,
string? SocialInsuranceNumber,
string? PassportNumber,
string? PermanentAddressText,
string? StreetAddressPermanent,
string? TemporaryAddressText,
string? StreetAddressTemporary,
DateOnly? HireDate,
DateOnly? ResignDate,
string? EmergencyContactName,
string? EmergencyContactPhone,
string? EmergencyContactAddress,
string? Qualification,
string? AcademicTitle,
string? WorkLocation,
string? TimekeepingCode,
string? BankAccount,
string? BankName,
string? BankBranch,
int? HeightCm,
int? WeightKg,
string? BloodType,
decimal? BaseSalary,
decimal? TotalSalary,
decimal? AnnualLeaveDays,
decimal? RemainingLeaveDays,
decimal? CompensatoryLeaveDays,
decimal? SeniorityLeaveDays,
DateOnly? SocialInsuranceStartDate,
string? MedicalRegistrationPlace,
bool IsCommunistParty,
DateOnly? CommunistPartyJoinDate,
bool IsYouthUnion,
DateOnly? YouthUnionJoinDate,
bool IsTradeUnion,
DateOnly? TradeUnionJoinDate,
string? PhotoUrl,
string? Notes) : IRequest;
public class UpdateEmployeeProfileCommandValidator : AbstractValidator<UpdateEmployeeProfileCommand>
{
public UpdateEmployeeProfileCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.EmployeeStatus).IsInEnum();
RuleFor(x => x.Phone).MaximumLength(20);
RuleFor(x => x.PersonalEmail).MaximumLength(200).EmailAddress()
.When(x => !string.IsNullOrWhiteSpace(x.PersonalEmail));
RuleFor(x => x.InternalPhone).MaximumLength(20);
RuleFor(x => x.BirthPlace).MaximumLength(200);
RuleFor(x => x.Hometown).MaximumLength(200);
RuleFor(x => x.Ethnicity).MaximumLength(50);
RuleFor(x => x.Religion).MaximumLength(50);
RuleFor(x => x.Nationality).MaximumLength(50);
RuleFor(x => x.IdCardNumber).MaximumLength(20);
RuleFor(x => x.IdCardIssuePlace).MaximumLength(200);
RuleFor(x => x.TaxCode).MaximumLength(20);
RuleFor(x => x.SocialInsuranceNumber).MaximumLength(20);
RuleFor(x => x.PassportNumber).MaximumLength(20);
RuleFor(x => x.PermanentAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressPermanent).MaximumLength(200);
RuleFor(x => x.TemporaryAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressTemporary).MaximumLength(200);
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20);
RuleFor(x => x.EmergencyContactAddress).MaximumLength(500);
RuleFor(x => x.Qualification).MaximumLength(200);
RuleFor(x => x.AcademicTitle).MaximumLength(200);
RuleFor(x => x.WorkLocation).MaximumLength(200);
RuleFor(x => x.TimekeepingCode).MaximumLength(50);
RuleFor(x => x.BankAccount).MaximumLength(50);
RuleFor(x => x.BankName).MaximumLength(200);
RuleFor(x => x.BankBranch).MaximumLength(200);
RuleFor(x => x.BloodType).MaximumLength(5);
RuleFor(x => x.BaseSalary).GreaterThanOrEqualTo(0).When(x => x.BaseSalary.HasValue);
RuleFor(x => x.TotalSalary).GreaterThanOrEqualTo(0).When(x => x.TotalSalary.HasValue);
RuleFor(x => x.AnnualLeaveDays).GreaterThanOrEqualTo(0).When(x => x.AnnualLeaveDays.HasValue);
RuleFor(x => x.RemainingLeaveDays).GreaterThanOrEqualTo(0).When(x => x.RemainingLeaveDays.HasValue);
RuleFor(x => x.CompensatoryLeaveDays).GreaterThanOrEqualTo(0).When(x => x.CompensatoryLeaveDays.HasValue);
RuleFor(x => x.SeniorityLeaveDays).GreaterThanOrEqualTo(0).When(x => x.SeniorityLeaveDays.HasValue);
RuleFor(x => x.MedicalRegistrationPlace).MaximumLength(200);
RuleFor(x => x.PhotoUrl).MaximumLength(500);
RuleFor(x => x.Notes).MaximumLength(2000);
}
}
public class UpdateEmployeeProfileCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdateEmployeeProfileCommand>
{
public async Task Handle(UpdateEmployeeProfileCommand request, CancellationToken ct)
{
var entity = await db.EmployeeProfiles
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("EmployeeProfile", request.Id);
// UserId + EmployeeCode immutable — bỏ qua mọi attempt update.
entity.EmployeeStatus = request.EmployeeStatus;
entity.Gender = request.Gender;
entity.MaritalStatus = request.MaritalStatus;
entity.EmployeeType = request.EmployeeType;
entity.DateOfBirth = request.DateOfBirth;
entity.BirthPlace = request.BirthPlace;
entity.Hometown = request.Hometown;
entity.Phone = request.Phone;
entity.PersonalEmail = request.PersonalEmail;
entity.InternalPhone = request.InternalPhone;
entity.Ethnicity = request.Ethnicity;
entity.Religion = request.Religion;
entity.Nationality = request.Nationality ?? "Việt Nam";
entity.IdCardNumber = request.IdCardNumber;
entity.IdCardIssueDate = request.IdCardIssueDate;
entity.IdCardIssuePlace = request.IdCardIssuePlace;
entity.TaxCode = request.TaxCode;
entity.SocialInsuranceNumber = request.SocialInsuranceNumber;
entity.PassportNumber = request.PassportNumber;
entity.PermanentAddressText = request.PermanentAddressText;
entity.StreetAddressPermanent = request.StreetAddressPermanent;
entity.TemporaryAddressText = request.TemporaryAddressText;
entity.StreetAddressTemporary = request.StreetAddressTemporary;
entity.HireDate = request.HireDate;
entity.ResignDate = request.ResignDate;
entity.EmergencyContactName = request.EmergencyContactName;
entity.EmergencyContactPhone = request.EmergencyContactPhone;
entity.EmergencyContactAddress = request.EmergencyContactAddress;
entity.Qualification = request.Qualification;
entity.AcademicTitle = request.AcademicTitle;
entity.WorkLocation = request.WorkLocation;
entity.TimekeepingCode = request.TimekeepingCode;
entity.BankAccount = request.BankAccount;
entity.BankName = request.BankName;
entity.BankBranch = request.BankBranch;
entity.HeightCm = request.HeightCm;
entity.WeightKg = request.WeightKg;
entity.BloodType = request.BloodType;
entity.BaseSalary = request.BaseSalary;
entity.TotalSalary = request.TotalSalary;
entity.AnnualLeaveDays = request.AnnualLeaveDays;
entity.RemainingLeaveDays = request.RemainingLeaveDays;
entity.CompensatoryLeaveDays = request.CompensatoryLeaveDays;
entity.SeniorityLeaveDays = request.SeniorityLeaveDays;
entity.SocialInsuranceStartDate = request.SocialInsuranceStartDate;
entity.MedicalRegistrationPlace = request.MedicalRegistrationPlace;
entity.IsCommunistParty = request.IsCommunistParty;
entity.CommunistPartyJoinDate = request.CommunistPartyJoinDate;
entity.IsYouthUnion = request.IsYouthUnion;
entity.YouthUnionJoinDate = request.YouthUnionJoinDate;
entity.IsTradeUnion = request.IsTradeUnion;
entity.TradeUnionJoinDate = request.TradeUnionJoinDate;
entity.PhotoUrl = request.PhotoUrl;
entity.Notes = request.Notes;
await db.SaveChangesAsync(ct);
}
}
// ========== DELETE (soft) ==========
public record DeleteEmployeeProfileCommand(Guid Id) : IRequest;
public class DeleteEmployeeProfileCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeleteEmployeeProfileCommand>
{
public async Task Handle(DeleteEmployeeProfileCommand request, CancellationToken ct)
{
var entity = await db.EmployeeProfiles
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("EmployeeProfile", request.Id);
entity.IsDeleted = true;
entity.DeletedAt = DateTime.UtcNow;
entity.DeletedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
// ========== GET DETAIL ==========
public record GetEmployeeProfileQuery(Guid Id) : IRequest<EmployeeProfileDetailDto>;
public class GetEmployeeProfileQueryHandler(
IApplicationDbContext db) : IRequestHandler<GetEmployeeProfileQuery, EmployeeProfileDetailDto>
{
public async Task<EmployeeProfileDetailDto> Handle(GetEmployeeProfileQuery request, CancellationToken ct)
{
var entity = await db.EmployeeProfiles.AsNoTracking()
.Include(x => x.WorkHistories)
.Include(x => x.Educations)
.Include(x => x.FamilyRelations)
.Include(x => x.Skills)
.Include(x => x.Documents)
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("EmployeeProfile", request.Id);
// JOIN User + Department LEFT (Department nullable per User entity).
var userInfo = await (
from u in db.Users.AsNoTracking()
where u.Id == entity.UserId
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
select new { u.FullName, u.Email, u.DepartmentId, DepartmentName = d != null ? d.Name : null })
.FirstOrDefaultAsync(ct);
return new EmployeeProfileDetailDto(
entity.Id,
entity.EmployeeCode,
entity.UserId,
userInfo?.FullName,
userInfo?.Email,
userInfo?.DepartmentId,
userInfo?.DepartmentName,
entity.EmployeeStatus,
entity.Gender,
entity.MaritalStatus,
entity.EmployeeType,
entity.DateOfBirth,
entity.BirthPlace,
entity.Hometown,
entity.Phone,
entity.PersonalEmail,
entity.InternalPhone,
entity.Ethnicity,
entity.Religion,
entity.Nationality,
entity.IdCardNumber,
entity.IdCardIssueDate,
entity.IdCardIssuePlace,
entity.TaxCode,
entity.SocialInsuranceNumber,
entity.PassportNumber,
entity.PermanentAddressText,
entity.StreetAddressPermanent,
entity.TemporaryAddressText,
entity.StreetAddressTemporary,
entity.HireDate,
entity.ResignDate,
entity.EmergencyContactName,
entity.EmergencyContactPhone,
entity.EmergencyContactAddress,
entity.Qualification,
entity.AcademicTitle,
entity.WorkLocation,
entity.TimekeepingCode,
entity.BankAccount,
entity.BankName,
entity.BankBranch,
entity.HeightCm,
entity.WeightKg,
entity.BloodType,
entity.BaseSalary,
entity.TotalSalary,
entity.AnnualLeaveDays,
entity.RemainingLeaveDays,
entity.CompensatoryLeaveDays,
entity.SeniorityLeaveDays,
entity.SocialInsuranceStartDate,
entity.MedicalRegistrationPlace,
entity.IsCommunistParty,
entity.CommunistPartyJoinDate,
entity.IsYouthUnion,
entity.YouthUnionJoinDate,
entity.IsTradeUnion,
entity.TradeUnionJoinDate,
entity.PhotoUrl,
entity.Notes,
entity.CreatedAt,
entity.UpdatedAt,
entity.WorkHistories.Select(w => new EmployeeWorkHistoryDto(
w.Id, w.CompanyName, w.CompanyAddress, w.Industry,
w.FromDate, w.ToDate, w.JobTitle, w.JobDescription, w.ResignReason)).ToList(),
entity.Educations.Select(ed => new EmployeeEducationDto(
ed.Id, ed.SchoolName, ed.Major, ed.DegreeLevel, ed.EducationMode, ed.GradeLevel,
ed.FromDate, ed.ToDate, ed.CertificateIssueDate, ed.Notes)).ToList(),
entity.FamilyRelations.Select(f => new EmployeeFamilyRelationDto(
f.Id, f.FullName, f.Relationship,
f.BirthYear, f.Occupation, f.CurrentAddress, f.Phone)).ToList(),
entity.Skills.Select(s => new EmployeeSkillDto(
s.Id, s.Kind, s.Name, s.LanguageId, s.Level)).ToList(),
entity.Documents.Select(doc => new EmployeeDocumentDto(
doc.Id, doc.DocumentType,
doc.FileName, doc.FilePath, doc.FileSize, doc.ContentType,
doc.IssueDate, doc.ExpiryDate, doc.Notes)).ToList());
}
}
// ========== LIST (paged) ==========
// PagedRequest provides Page/PageSize/Search/SortDesc (pattern PE List).
// Spec dùng `PagedQuery` nhưng codebase chỉ có `PagedRequest` — em main confirm
// dùng PagedRequest (Pattern 12-bis mirror PE ListPurchaseEvaluationsQuery line 458).
public record ListEmployeesQuery(
EmployeeStatus? Status = null,
Guid? DepartmentId = null) : PagedRequest, IRequest<PagedResult<EmployeeProfileListItemDto>>;
public class ListEmployeesQueryHandler(
IApplicationDbContext db) : IRequestHandler<ListEmployeesQuery, PagedResult<EmployeeProfileListItemDto>>
{
public async Task<PagedResult<EmployeeProfileListItemDto>> Handle(
ListEmployeesQuery request, CancellationToken ct)
{
// JOIN Users for FullName/Email + Departments for DepartmentName (LEFT join
// Departments — User.DepartmentId nullable). Mirror PE Plan AG4 JOIN pattern.
var q = from e in db.EmployeeProfiles.AsNoTracking().Where(x => !x.IsDeleted)
join u in db.Users.AsNoTracking() on e.UserId equals u.Id
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
select new { e, u, d };
if (request.Status is not null)
q = q.Where(x => x.e.EmployeeStatus == request.Status);
if (request.DepartmentId is not null)
q = q.Where(x => x.u.DepartmentId == request.DepartmentId);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x =>
x.e.EmployeeCode.Contains(s) ||
x.u.FullName.Contains(s));
}
q = request.SortDesc
? q.OrderByDescending(x => x.e.CreatedAt)
: q.OrderBy(x => x.e.CreatedAt);
var total = await q.CountAsync(ct);
var items = await q
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new EmployeeProfileListItemDto(
x.e.Id,
x.e.EmployeeCode,
x.e.UserId,
x.u.FullName,
x.u.Email,
x.u.DepartmentId,
x.d != null ? x.d.Name : null,
x.e.EmployeeStatus,
x.e.Phone,
x.e.HireDate,
x.e.CreatedAt,
x.e.UpdatedAt))
.ToListAsync(ct);
return new PagedResult<EmployeeProfileListItemDto>(items, total, request.Page, request.PageSize);
}
}

View File

@ -76,6 +76,15 @@ public static class MenuKeys
public const string BudgetCreate = "Bg_Create";
public const string BudgetPending = "Bg_Pending";
// ============================================================
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33 2026-05-26).
// Port từ NamGroup CT_NHANSU 1675 NV. 1 root group + 1 leaf Hồ sơ NS.
// Phase 1 minimal scope — satellite CRUD endpoint defer Phase 1.5.
// Future Phase 10.4 add: Hrm_Dashboard (G-H3) + Hrm_Config* (G-H2).
// ============================================================
public const string Hrm = "Hrm"; // root group
public const string HrmHoSo = "Hrm_HoSo"; // Hồ sơ Nhân sự (list + detail + edit)
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -100,6 +109,7 @@ public static class MenuKeys
Contracts, Forms, Reports,
PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending,
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -1479,6 +1479,10 @@ public static class DbInitializer
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). 1 root + 1 leaf
// Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
(MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each