[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:
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs
Normal file
151
src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs
Normal 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);
|
||||||
610
src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs
Normal file
610
src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -76,6 +76,15 @@ public static class MenuKeys
|
|||||||
public const string BudgetCreate = "Bg_Create";
|
public const string BudgetCreate = "Bg_Create";
|
||||||
public const string BudgetPending = "Bg_Pending";
|
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 =
|
public static readonly string[] PurchaseEvaluationTypeCodes =
|
||||||
["DuyetNcc", "DuyetNccPhuongAn"];
|
["DuyetNcc", "DuyetNccPhuongAn"];
|
||||||
|
|
||||||
@ -100,6 +109,7 @@ public static class MenuKeys
|
|||||||
Contracts, Forms, Reports,
|
Contracts, Forms, Reports,
|
||||||
PurchaseEvaluations,
|
PurchaseEvaluations,
|
||||||
Budgets, BudgetList, BudgetCreate, BudgetPending,
|
Budgets, BudgetList, BudgetCreate, BudgetPending,
|
||||||
|
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
|
||||||
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1479,6 +1479,10 @@ public static class DbInitializer
|
|||||||
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
|
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
|
||||||
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
|
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
|
||||||
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
|
(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
|
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||||
|
|||||||
Reference in New Issue
Block a user