diff --git a/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs b/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs new file mode 100644 index 0000000..b502edc --- /dev/null +++ b/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs @@ -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>> 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> Get(Guid id, CancellationToken ct) + => Ok(await mediator.Send(new GetEmployeeProfileQuery(id), ct)); + + [HttpPost] + public async Task> 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 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 Delete(Guid id, CancellationToken ct) + { + await mediator.Send(new DeleteEmployeeProfileCommand(id), ct); + return NoContent(); + } +} diff --git a/src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs b/src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs new file mode 100644 index 0000000..6a1cca1 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs @@ -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 WorkHistories, + List Educations, + List FamilyRelations, + List Skills, + List 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); diff --git a/src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs b/src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs new file mode 100644 index 0000000..abd3159 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs @@ -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; + +public class CreateEmployeeProfileCommandValidator : AbstractValidator +{ + 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 userManager) : IRequestHandler +{ + public async Task 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 +{ + 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 +{ + 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 +{ + 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; + +public class GetEmployeeProfileQueryHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task 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>; + +public class ListEmployeesQueryHandler( + IApplicationDbContext db) : IRequestHandler> +{ + public async Task> 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(items, total, request.Page, request.PageSize); + } +} diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs index 14eb115..5433638 100644 --- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs +++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs @@ -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 ]; diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index a90a699..062c812 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -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