From e506cd8135c347c3f2b63a2a62da458cf20e7477 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 27 May 2026 14:56:14 +0700 Subject: [PATCH] [CLAUDE] App+Api: S34 Plan 3 Item 3 BE 5 satellite CRUD scaffold (15 endpoint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.5 Item 3 BE — Implementer Case 2 cookie-cutter 5-entity scaffold mirror parent EmployeeProfile CRUD pattern (Pattern 12-ter NEW saved). New file: Application/Hrm/EmployeeSatelliteFeatures.cs (621 LOC): - 5 region (#region {EntityName}) — WorkHistory/Education/FamilyRelation/Skill/Document - Each region: Create/Update/Delete Command + Validator + Handler - Verify parent EmployeeProfile.Exists pattern AnyAsync(!IsDeleted) - Soft delete IsDeleted=true + DeletedAt=UtcNow + DeletedBy=currentUser - Validator nullable enum IsInEnum().When(HasValue) pattern Modified file: Api/Controllers/EmployeesController.cs (70 → 234 LOC, +15 endpoint): - 5 region × 3 verb (POST/PUT/DELETE) = 15 satellite endpoint - Path per satellite: - /api/employees/{id}/work-history - /api/employees/{id}/education - /api/employees/{id}/family-relations - /api/employees/{id}/skills - /api/employees/{id}/documents - Per-action policy: Hrm_HoSo.Create/Update/Delete (override class-level Read) - BadRequest guard: id != cmd.{EmployeeProfileId|Id} → "ID không khớp" Document satellite metadata-only — file upload IFileStorage body wire defer S35. Verify: - dotnet build PASS (2 warn DocxRenderer baseline, 0 error) - dotnet test 130/130 PASS (58 Domain + 72 Infra baseline preserve, no test add) - Endpoints count: ~154 → 169 (+15) - LOC delta: +785 BE (621 features + 164 controller) Pattern 12-ter NEW (Implementer MEMORY): 5× satellite CRUD scaffold cookie-cutter within-module (different from Pattern 12-bis cross-module mirror). Reusable cho future N-satellite parent (vd Contract attachments, PE quotes, Budget details). Defer S35: - FE inline edit forms 5 satellite section (~1.5h em main solo) - Test bundle satellite CRUD (~30 phút Implementer Case 3) - IFileStorage Document body upload wire (multipart form-data) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agent-memory/implementer/MEMORY.md | 21 + .../Controllers/EmployeesController.cs | 164 +++++ .../Hrm/EmployeeSatelliteFeatures.cs | 621 ++++++++++++++++++ 3 files changed, 806 insertions(+) create mode 100644 src/Backend/SolutionErp.Application/Hrm/EmployeeSatelliteFeatures.cs diff --git a/.claude/agent-memory/implementer/MEMORY.md b/.claude/agent-memory/implementer/MEMORY.md index f347173..80c59ee 100644 --- a/.claude/agent-memory/implementer/MEMORY.md +++ b/.claude/agent-memory/implementer/MEMORY.md @@ -182,6 +182,27 @@ Khi spec yêu cầu "move page X từ fe-admin → fe-user" hoặc ngược lạ - Token cost ~20k (under budget 25k). Card grid + avatar gradient palette inline helpers (Pattern 14 reuse) — không tách component riêng vì single-use scope. - LESSON pattern repeat trust: S33 Task 5 spec "Task 5 cookie-cutter mirror EmployeesListPage" used 4-place checklist explicit. S34 G-O1 Task 3 spec follow same template → execute 0 ambiguity. Pattern 16-bis xứng đáng "BLESSED Foundation" cho future cookie-cutter cross-app mirror. +### Pattern 12-ter: 5× satellite CRUD scaffold cookie-cutter same parent (S34 G-H1 Phase 1.5 Item 3) + +Khi spec yêu cầu "5 satellite entity CRUD same parent" (vd Employee → WorkHistory/Education/FamilyRelation/Skill/Document): +- **1 file Application layer** `SatelliteFeatures.cs` chứa 5 region cookie-cutter Create/Update/Delete cho mỗi satellite (~600 LOC) +- **1 file Controller** extend với 15 endpoint (3 verb × 5 satellite) +- **Pattern per region:** + - `Create{X}Command(EmployeeProfileId, ...)` → `IRequest` + Validator + Handler (verify parent exists trước → `AnyAsync` parent + throw NotFoundException → save → return Id) + - `Update{X}Command(Id, ...)` → `IRequest` + Validator + Handler (FirstOrDefaultAsync `!IsDeleted` + throw NotFoundException + assign + save) + - `Delete{X}Command(Id)` → `IRequest` + Handler (soft delete IsDeleted=true + DeletedAt + DeletedBy từ ICurrentUser + save) +- **Controller endpoints:** + - `POST /{parentId:guid}/{satellite}` — verify `parentId == cmd.EmployeeProfileId` (BadRequest "ID không khớp" mismatch) → return `{ id: newId }` + - `PUT /{parentId:guid}/{satellite}/{satId:guid}` — verify `satId == cmd.Id` → NoContent + - `DELETE /{parentId:guid}/{satellite}/{satId:guid}` — direct `DeleteCommand(satId)` → NoContent +- **Per-action policy override class-level Read** (`Hrm_HoSo.Create/Update/Delete`) +- **Verify parent exists pattern**: `AnyAsync(x => x.Id == ... && !x.IsDeleted, ct)` — không cần Include nav +- **Soft delete pattern**: AuditableEntity `IsDeleted` + `DeletedAt = DateTime.UtcNow` + `DeletedBy = currentUser.UserId` (inject ICurrentUser) + +Bài học S34 Plan 3 Phase 1.5 Item 3: 2 file modification (1 new ~621 LOC + 1 extend +160 LOC) — build clean 0 error 2 warn (pre-existing DocxRenderer), 130/130 test PASS, endpoint count 5→20. + +Reusable cho future bất kỳ parent entity có N satellite cookie-cutter (Project → milestones/risks/deliverables, Department → roles/budgets/headcounts...). Polymorphic discriminator field (vd EmployeeSkill.Kind) treat as regular required field — không cần special handling. + ### Pattern 12-bis: Cross-module entity cookie-cutter mirror (S29 Plan B Chunk C — Mig 33) Khi spec yêu cầu "mirror entity X từ PE module sang Contract module" (vd LevelOpinions / DepartmentApproval / ManualBudgetFields): diff --git a/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs b/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs index af0b98b..f0be141 100644 --- a/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs @@ -67,4 +67,168 @@ public class EmployeesController(IMediator mediator) : ControllerBase await mediator.Send(new DeleteEmployeeProfileCommand(id), ct); return NoContent(); } + + // Phase 1.5 Item 3 (S34) — 5 satellite CRUD endpoint scaffold. + // FE inline form satellite DEFER S35. File upload IFileStorage wire body DEFER. + // Per-action policy inherit class-level Read + override Create/Update/Delete. + + #region WorkHistory satellite + + [HttpPost("{id:guid}/work-history")] + [Authorize(Policy = "Hrm_HoSo.Create")] + public async Task> CreateWorkHistory( + Guid id, [FromBody] CreateEmployeeWorkHistoryCommand cmd, CancellationToken ct) + { + if (id != cmd.EmployeeProfileId) return BadRequest(new { detail = "Employee ID không khớp" }); + var newId = await mediator.Send(cmd, ct); + return Ok(new { id = newId }); + } + + [HttpPut("{id:guid}/work-history/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Update")] + public async Task UpdateWorkHistory( + Guid id, Guid satId, [FromBody] UpdateEmployeeWorkHistoryCommand cmd, CancellationToken ct) + { + if (satId != cmd.Id) return BadRequest(new { detail = "ID không khớp" }); + await mediator.Send(cmd, ct); + return NoContent(); + } + + [HttpDelete("{id:guid}/work-history/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Delete")] + public async Task DeleteWorkHistory(Guid id, Guid satId, CancellationToken ct) + { + await mediator.Send(new DeleteEmployeeWorkHistoryCommand(satId), ct); + return NoContent(); + } + + #endregion + + #region Education satellite + + [HttpPost("{id:guid}/education")] + [Authorize(Policy = "Hrm_HoSo.Create")] + public async Task> CreateEducation( + Guid id, [FromBody] CreateEmployeeEducationCommand cmd, CancellationToken ct) + { + if (id != cmd.EmployeeProfileId) return BadRequest(new { detail = "Employee ID không khớp" }); + var newId = await mediator.Send(cmd, ct); + return Ok(new { id = newId }); + } + + [HttpPut("{id:guid}/education/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Update")] + public async Task UpdateEducation( + Guid id, Guid satId, [FromBody] UpdateEmployeeEducationCommand cmd, CancellationToken ct) + { + if (satId != cmd.Id) return BadRequest(new { detail = "ID không khớp" }); + await mediator.Send(cmd, ct); + return NoContent(); + } + + [HttpDelete("{id:guid}/education/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Delete")] + public async Task DeleteEducation(Guid id, Guid satId, CancellationToken ct) + { + await mediator.Send(new DeleteEmployeeEducationCommand(satId), ct); + return NoContent(); + } + + #endregion + + #region FamilyRelation satellite + + [HttpPost("{id:guid}/family-relations")] + [Authorize(Policy = "Hrm_HoSo.Create")] + public async Task> CreateFamilyRelation( + Guid id, [FromBody] CreateEmployeeFamilyRelationCommand cmd, CancellationToken ct) + { + if (id != cmd.EmployeeProfileId) return BadRequest(new { detail = "Employee ID không khớp" }); + var newId = await mediator.Send(cmd, ct); + return Ok(new { id = newId }); + } + + [HttpPut("{id:guid}/family-relations/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Update")] + public async Task UpdateFamilyRelation( + Guid id, Guid satId, [FromBody] UpdateEmployeeFamilyRelationCommand cmd, CancellationToken ct) + { + if (satId != cmd.Id) return BadRequest(new { detail = "ID không khớp" }); + await mediator.Send(cmd, ct); + return NoContent(); + } + + [HttpDelete("{id:guid}/family-relations/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Delete")] + public async Task DeleteFamilyRelation(Guid id, Guid satId, CancellationToken ct) + { + await mediator.Send(new DeleteEmployeeFamilyRelationCommand(satId), ct); + return NoContent(); + } + + #endregion + + #region Skill satellite + + [HttpPost("{id:guid}/skills")] + [Authorize(Policy = "Hrm_HoSo.Create")] + public async Task> CreateSkill( + Guid id, [FromBody] CreateEmployeeSkillCommand cmd, CancellationToken ct) + { + if (id != cmd.EmployeeProfileId) return BadRequest(new { detail = "Employee ID không khớp" }); + var newId = await mediator.Send(cmd, ct); + return Ok(new { id = newId }); + } + + [HttpPut("{id:guid}/skills/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Update")] + public async Task UpdateSkill( + Guid id, Guid satId, [FromBody] UpdateEmployeeSkillCommand cmd, CancellationToken ct) + { + if (satId != cmd.Id) return BadRequest(new { detail = "ID không khớp" }); + await mediator.Send(cmd, ct); + return NoContent(); + } + + [HttpDelete("{id:guid}/skills/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Delete")] + public async Task DeleteSkill(Guid id, Guid satId, CancellationToken ct) + { + await mediator.Send(new DeleteEmployeeSkillCommand(satId), ct); + return NoContent(); + } + + #endregion + + #region Document satellite + + [HttpPost("{id:guid}/documents")] + [Authorize(Policy = "Hrm_HoSo.Create")] + public async Task> CreateDocument( + Guid id, [FromBody] CreateEmployeeDocumentCommand cmd, CancellationToken ct) + { + if (id != cmd.EmployeeProfileId) return BadRequest(new { detail = "Employee ID không khớp" }); + var newId = await mediator.Send(cmd, ct); + return Ok(new { id = newId }); + } + + [HttpPut("{id:guid}/documents/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Update")] + public async Task UpdateDocument( + Guid id, Guid satId, [FromBody] UpdateEmployeeDocumentCommand cmd, CancellationToken ct) + { + if (satId != cmd.Id) return BadRequest(new { detail = "ID không khớp" }); + await mediator.Send(cmd, ct); + return NoContent(); + } + + [HttpDelete("{id:guid}/documents/{satId:guid}")] + [Authorize(Policy = "Hrm_HoSo.Delete")] + public async Task DeleteDocument(Guid id, Guid satId, CancellationToken ct) + { + await mediator.Send(new DeleteEmployeeDocumentCommand(satId), ct); + return NoContent(); + } + + #endregion } diff --git a/src/Backend/SolutionErp.Application/Hrm/EmployeeSatelliteFeatures.cs b/src/Backend/SolutionErp.Application/Hrm/EmployeeSatelliteFeatures.cs new file mode 100644 index 0000000..dad12eb --- /dev/null +++ b/src/Backend/SolutionErp.Application/Hrm/EmployeeSatelliteFeatures.cs @@ -0,0 +1,621 @@ +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Hrm; + +namespace SolutionErp.Application.Hrm; + +// Plan G-H1 Phase 1.5 Item 3 (S34) — 5 satellite CRUD endpoint scaffold. +// Cookie-cutter 5× pattern: Create/Update/Delete cho WorkHistory/Education/ +// FamilyRelation/Skill/Document. Mirror parent EmployeeFeatures.cs pattern +// (Reviewer minor S33+S34 — bool? safe partial update KHÔNG áp dụng satellite, +// satellite create/update full overwrite). +// +// FE inline form satellite DEFER S35 (em main capacity). +// File upload IFileStorage wire cho Document body DEFER S35. + +#region WorkHistory + +public record CreateEmployeeWorkHistoryCommand( + Guid EmployeeProfileId, + string CompanyName, + string? CompanyAddress = null, + string? Industry = null, + DateOnly? FromDate = null, + DateOnly? ToDate = null, + string? JobTitle = null, + string? JobDescription = null, + string? ResignReason = null) : IRequest; + +public class CreateEmployeeWorkHistoryCommandValidator : AbstractValidator +{ + public CreateEmployeeWorkHistoryCommandValidator() + { + RuleFor(x => x.EmployeeProfileId).NotEmpty(); + RuleFor(x => x.CompanyName).NotEmpty().MaximumLength(200); + RuleFor(x => x.CompanyAddress).MaximumLength(500); + RuleFor(x => x.Industry).MaximumLength(100); + RuleFor(x => x.JobTitle).MaximumLength(200); + RuleFor(x => x.JobDescription).MaximumLength(2000); + RuleFor(x => x.ResignReason).MaximumLength(500); + } +} + +public class CreateEmployeeWorkHistoryCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateEmployeeWorkHistoryCommand request, CancellationToken ct) + { + var parentExists = await db.EmployeeProfiles + .AnyAsync(x => x.Id == request.EmployeeProfileId && !x.IsDeleted, ct); + if (!parentExists) + throw new NotFoundException("EmployeeProfile", request.EmployeeProfileId); + + var entity = new EmployeeWorkHistory + { + EmployeeProfileId = request.EmployeeProfileId, + CompanyName = request.CompanyName, + CompanyAddress = request.CompanyAddress, + Industry = request.Industry, + FromDate = request.FromDate, + ToDate = request.ToDate, + JobTitle = request.JobTitle, + JobDescription = request.JobDescription, + ResignReason = request.ResignReason, + }; + + db.EmployeeWorkHistories.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateEmployeeWorkHistoryCommand( + Guid Id, + string CompanyName, + string? CompanyAddress, + string? Industry, + DateOnly? FromDate, + DateOnly? ToDate, + string? JobTitle, + string? JobDescription, + string? ResignReason) : IRequest; + +public class UpdateEmployeeWorkHistoryCommandValidator : AbstractValidator +{ + public UpdateEmployeeWorkHistoryCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.CompanyName).NotEmpty().MaximumLength(200); + RuleFor(x => x.CompanyAddress).MaximumLength(500); + RuleFor(x => x.Industry).MaximumLength(100); + RuleFor(x => x.JobTitle).MaximumLength(200); + RuleFor(x => x.JobDescription).MaximumLength(2000); + RuleFor(x => x.ResignReason).MaximumLength(500); + } +} + +public class UpdateEmployeeWorkHistoryCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateEmployeeWorkHistoryCommand request, CancellationToken ct) + { + var entity = await db.EmployeeWorkHistories + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeWorkHistory", request.Id); + + entity.CompanyName = request.CompanyName; + entity.CompanyAddress = request.CompanyAddress; + entity.Industry = request.Industry; + entity.FromDate = request.FromDate; + entity.ToDate = request.ToDate; + entity.JobTitle = request.JobTitle; + entity.JobDescription = request.JobDescription; + entity.ResignReason = request.ResignReason; + + await db.SaveChangesAsync(ct); + } +} + +public record DeleteEmployeeWorkHistoryCommand(Guid Id) : IRequest; + +public class DeleteEmployeeWorkHistoryCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(DeleteEmployeeWorkHistoryCommand request, CancellationToken ct) + { + var entity = await db.EmployeeWorkHistories + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeWorkHistory", request.Id); + + entity.IsDeleted = true; + entity.DeletedAt = DateTime.UtcNow; + entity.DeletedBy = currentUser.UserId; + + await db.SaveChangesAsync(ct); + } +} + +#endregion + +#region Education + +public record CreateEmployeeEducationCommand( + Guid EmployeeProfileId, + string SchoolName, + string? Major = null, + DegreeLevel? DegreeLevel = null, + EducationMode? EducationMode = null, + GradeLevel? GradeLevel = null, + DateOnly? FromDate = null, + DateOnly? ToDate = null, + DateOnly? CertificateIssueDate = null, + string? Notes = null) : IRequest; + +public class CreateEmployeeEducationCommandValidator : AbstractValidator +{ + public CreateEmployeeEducationCommandValidator() + { + RuleFor(x => x.EmployeeProfileId).NotEmpty(); + RuleFor(x => x.SchoolName).NotEmpty().MaximumLength(200); + RuleFor(x => x.Major).MaximumLength(200); + RuleFor(x => x.DegreeLevel).IsInEnum().When(x => x.DegreeLevel.HasValue); + RuleFor(x => x.EducationMode).IsInEnum().When(x => x.EducationMode.HasValue); + RuleFor(x => x.GradeLevel).IsInEnum().When(x => x.GradeLevel.HasValue); + RuleFor(x => x.Notes).MaximumLength(1000); + } +} + +public class CreateEmployeeEducationCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateEmployeeEducationCommand request, CancellationToken ct) + { + var parentExists = await db.EmployeeProfiles + .AnyAsync(x => x.Id == request.EmployeeProfileId && !x.IsDeleted, ct); + if (!parentExists) + throw new NotFoundException("EmployeeProfile", request.EmployeeProfileId); + + var entity = new EmployeeEducation + { + EmployeeProfileId = request.EmployeeProfileId, + SchoolName = request.SchoolName, + Major = request.Major, + DegreeLevel = request.DegreeLevel, + EducationMode = request.EducationMode, + GradeLevel = request.GradeLevel, + FromDate = request.FromDate, + ToDate = request.ToDate, + CertificateIssueDate = request.CertificateIssueDate, + Notes = request.Notes, + }; + + db.EmployeeEducations.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateEmployeeEducationCommand( + Guid Id, + string SchoolName, + string? Major, + DegreeLevel? DegreeLevel, + EducationMode? EducationMode, + GradeLevel? GradeLevel, + DateOnly? FromDate, + DateOnly? ToDate, + DateOnly? CertificateIssueDate, + string? Notes) : IRequest; + +public class UpdateEmployeeEducationCommandValidator : AbstractValidator +{ + public UpdateEmployeeEducationCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.SchoolName).NotEmpty().MaximumLength(200); + RuleFor(x => x.Major).MaximumLength(200); + RuleFor(x => x.DegreeLevel).IsInEnum().When(x => x.DegreeLevel.HasValue); + RuleFor(x => x.EducationMode).IsInEnum().When(x => x.EducationMode.HasValue); + RuleFor(x => x.GradeLevel).IsInEnum().When(x => x.GradeLevel.HasValue); + RuleFor(x => x.Notes).MaximumLength(1000); + } +} + +public class UpdateEmployeeEducationCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateEmployeeEducationCommand request, CancellationToken ct) + { + var entity = await db.EmployeeEducations + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeEducation", request.Id); + + entity.SchoolName = request.SchoolName; + entity.Major = request.Major; + entity.DegreeLevel = request.DegreeLevel; + entity.EducationMode = request.EducationMode; + entity.GradeLevel = request.GradeLevel; + entity.FromDate = request.FromDate; + entity.ToDate = request.ToDate; + entity.CertificateIssueDate = request.CertificateIssueDate; + entity.Notes = request.Notes; + + await db.SaveChangesAsync(ct); + } +} + +public record DeleteEmployeeEducationCommand(Guid Id) : IRequest; + +public class DeleteEmployeeEducationCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(DeleteEmployeeEducationCommand request, CancellationToken ct) + { + var entity = await db.EmployeeEducations + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeEducation", request.Id); + + entity.IsDeleted = true; + entity.DeletedAt = DateTime.UtcNow; + entity.DeletedBy = currentUser.UserId; + + await db.SaveChangesAsync(ct); + } +} + +#endregion + +#region FamilyRelation + +public record CreateEmployeeFamilyRelationCommand( + Guid EmployeeProfileId, + string FullName, + FamilyRelationKind Relationship, + int? BirthYear = null, + string? Occupation = null, + string? CurrentAddress = null, + string? Phone = null) : IRequest; + +public class CreateEmployeeFamilyRelationCommandValidator : AbstractValidator +{ + public CreateEmployeeFamilyRelationCommandValidator() + { + RuleFor(x => x.EmployeeProfileId).NotEmpty(); + RuleFor(x => x.FullName).NotEmpty().MaximumLength(200); + RuleFor(x => x.Relationship).IsInEnum(); + RuleFor(x => x.BirthYear).InclusiveBetween(1900, 2026).When(x => x.BirthYear.HasValue); + RuleFor(x => x.Occupation).MaximumLength(200); + RuleFor(x => x.CurrentAddress).MaximumLength(500); + RuleFor(x => x.Phone).MaximumLength(20); + } +} + +public class CreateEmployeeFamilyRelationCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateEmployeeFamilyRelationCommand request, CancellationToken ct) + { + var parentExists = await db.EmployeeProfiles + .AnyAsync(x => x.Id == request.EmployeeProfileId && !x.IsDeleted, ct); + if (!parentExists) + throw new NotFoundException("EmployeeProfile", request.EmployeeProfileId); + + var entity = new EmployeeFamilyRelation + { + EmployeeProfileId = request.EmployeeProfileId, + FullName = request.FullName, + Relationship = request.Relationship, + BirthYear = request.BirthYear, + Occupation = request.Occupation, + CurrentAddress = request.CurrentAddress, + Phone = request.Phone, + }; + + db.EmployeeFamilyRelations.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateEmployeeFamilyRelationCommand( + Guid Id, + string FullName, + FamilyRelationKind Relationship, + int? BirthYear, + string? Occupation, + string? CurrentAddress, + string? Phone) : IRequest; + +public class UpdateEmployeeFamilyRelationCommandValidator : AbstractValidator +{ + public UpdateEmployeeFamilyRelationCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.FullName).NotEmpty().MaximumLength(200); + RuleFor(x => x.Relationship).IsInEnum(); + RuleFor(x => x.BirthYear).InclusiveBetween(1900, 2026).When(x => x.BirthYear.HasValue); + RuleFor(x => x.Occupation).MaximumLength(200); + RuleFor(x => x.CurrentAddress).MaximumLength(500); + RuleFor(x => x.Phone).MaximumLength(20); + } +} + +public class UpdateEmployeeFamilyRelationCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateEmployeeFamilyRelationCommand request, CancellationToken ct) + { + var entity = await db.EmployeeFamilyRelations + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeFamilyRelation", request.Id); + + entity.FullName = request.FullName; + entity.Relationship = request.Relationship; + entity.BirthYear = request.BirthYear; + entity.Occupation = request.Occupation; + entity.CurrentAddress = request.CurrentAddress; + entity.Phone = request.Phone; + + await db.SaveChangesAsync(ct); + } +} + +public record DeleteEmployeeFamilyRelationCommand(Guid Id) : IRequest; + +public class DeleteEmployeeFamilyRelationCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(DeleteEmployeeFamilyRelationCommand request, CancellationToken ct) + { + var entity = await db.EmployeeFamilyRelations + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeFamilyRelation", request.Id); + + entity.IsDeleted = true; + entity.DeletedAt = DateTime.UtcNow; + entity.DeletedBy = currentUser.UserId; + + await db.SaveChangesAsync(ct); + } +} + +#endregion + +#region Skill + +public record CreateEmployeeSkillCommand( + Guid EmployeeProfileId, + SkillKind Kind, + string Name, + string? LanguageId = null, + string? Level = null) : IRequest; + +public class CreateEmployeeSkillCommandValidator : AbstractValidator +{ + public CreateEmployeeSkillCommandValidator() + { + RuleFor(x => x.EmployeeProfileId).NotEmpty(); + RuleFor(x => x.Kind).IsInEnum(); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.LanguageId).MaximumLength(10); + RuleFor(x => x.Level).MaximumLength(200); + } +} + +public class CreateEmployeeSkillCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateEmployeeSkillCommand request, CancellationToken ct) + { + var parentExists = await db.EmployeeProfiles + .AnyAsync(x => x.Id == request.EmployeeProfileId && !x.IsDeleted, ct); + if (!parentExists) + throw new NotFoundException("EmployeeProfile", request.EmployeeProfileId); + + var entity = new EmployeeSkill + { + EmployeeProfileId = request.EmployeeProfileId, + Kind = request.Kind, + Name = request.Name, + LanguageId = request.LanguageId, + Level = request.Level, + }; + + db.EmployeeSkills.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateEmployeeSkillCommand( + Guid Id, + SkillKind Kind, + string Name, + string? LanguageId, + string? Level) : IRequest; + +public class UpdateEmployeeSkillCommandValidator : AbstractValidator +{ + public UpdateEmployeeSkillCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Kind).IsInEnum(); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.LanguageId).MaximumLength(10); + RuleFor(x => x.Level).MaximumLength(200); + } +} + +public class UpdateEmployeeSkillCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateEmployeeSkillCommand request, CancellationToken ct) + { + var entity = await db.EmployeeSkills + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeSkill", request.Id); + + entity.Kind = request.Kind; + entity.Name = request.Name; + entity.LanguageId = request.LanguageId; + entity.Level = request.Level; + + await db.SaveChangesAsync(ct); + } +} + +public record DeleteEmployeeSkillCommand(Guid Id) : IRequest; + +public class DeleteEmployeeSkillCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(DeleteEmployeeSkillCommand request, CancellationToken ct) + { + var entity = await db.EmployeeSkills + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeSkill", request.Id); + + entity.IsDeleted = true; + entity.DeletedAt = DateTime.UtcNow; + entity.DeletedBy = currentUser.UserId; + + await db.SaveChangesAsync(ct); + } +} + +#endregion + +#region Document + +// Phase 1.5 S34 — metadata-only CRUD. File upload body (multipart) DEFER S35 +// khi wire IFileStorage. Tạm thời admin paste sẵn FilePath/FileSize/ContentType +// từ chỗ khác (UI form input chấp nhận thủ công). +public record CreateEmployeeDocumentCommand( + Guid EmployeeProfileId, + EmployeeDocumentType DocumentType, + string FileName, + string FilePath, + long FileSize, + string ContentType, + DateOnly? IssueDate = null, + DateOnly? ExpiryDate = null, + string? Notes = null) : IRequest; + +public class CreateEmployeeDocumentCommandValidator : AbstractValidator +{ + public CreateEmployeeDocumentCommandValidator() + { + RuleFor(x => x.EmployeeProfileId).NotEmpty(); + RuleFor(x => x.DocumentType).IsInEnum(); + RuleFor(x => x.FileName).NotEmpty().MaximumLength(500); + RuleFor(x => x.FilePath).NotEmpty().MaximumLength(1000); + RuleFor(x => x.FileSize).GreaterThanOrEqualTo(0); + RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100); + RuleFor(x => x.Notes).MaximumLength(1000); + } +} + +public class CreateEmployeeDocumentCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateEmployeeDocumentCommand request, CancellationToken ct) + { + var parentExists = await db.EmployeeProfiles + .AnyAsync(x => x.Id == request.EmployeeProfileId && !x.IsDeleted, ct); + if (!parentExists) + throw new NotFoundException("EmployeeProfile", request.EmployeeProfileId); + + var entity = new EmployeeDocument + { + EmployeeProfileId = request.EmployeeProfileId, + DocumentType = request.DocumentType, + FileName = request.FileName, + FilePath = request.FilePath, + FileSize = request.FileSize, + ContentType = request.ContentType, + IssueDate = request.IssueDate, + ExpiryDate = request.ExpiryDate, + Notes = request.Notes, + }; + + db.EmployeeDocuments.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateEmployeeDocumentCommand( + Guid Id, + EmployeeDocumentType DocumentType, + string FileName, + string FilePath, + long FileSize, + string ContentType, + DateOnly? IssueDate, + DateOnly? ExpiryDate, + string? Notes) : IRequest; + +public class UpdateEmployeeDocumentCommandValidator : AbstractValidator +{ + public UpdateEmployeeDocumentCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.DocumentType).IsInEnum(); + RuleFor(x => x.FileName).NotEmpty().MaximumLength(500); + RuleFor(x => x.FilePath).NotEmpty().MaximumLength(1000); + RuleFor(x => x.FileSize).GreaterThanOrEqualTo(0); + RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100); + RuleFor(x => x.Notes).MaximumLength(1000); + } +} + +public class UpdateEmployeeDocumentCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateEmployeeDocumentCommand request, CancellationToken ct) + { + var entity = await db.EmployeeDocuments + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeDocument", request.Id); + + entity.DocumentType = request.DocumentType; + entity.FileName = request.FileName; + entity.FilePath = request.FilePath; + entity.FileSize = request.FileSize; + entity.ContentType = request.ContentType; + entity.IssueDate = request.IssueDate; + entity.ExpiryDate = request.ExpiryDate; + entity.Notes = request.Notes; + + await db.SaveChangesAsync(ct); + } +} + +public record DeleteEmployeeDocumentCommand(Guid Id) : IRequest; + +public class DeleteEmployeeDocumentCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(DeleteEmployeeDocumentCommand request, CancellationToken ct) + { + var entity = await db.EmployeeDocuments + .FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("EmployeeDocument", request.Id); + + entity.IsDeleted = true; + entity.DeletedAt = DateTime.UtcNow; + entity.DeletedBy = currentUser.UserId; + + await db.SaveChangesAsync(ct); + } +} + +#endregion