diff --git a/.claude/agent-memory/cicd-monitor/MEMORY.md b/.claude/agent-memory/cicd-monitor/MEMORY.md index 53880a7..373c558 100644 --- a/.claude/agent-memory/cicd-monitor/MEMORY.md +++ b/.claude/agent-memory/cicd-monitor/MEMORY.md @@ -174,6 +174,8 @@ Bug latency observed when miss points 9-10: 2-3 days prod silent (Mig 28-29 depl ## 📅 Recent runs (FIFO — slim post-curate 2026-05-22) +- **2026-05-27 13:40-13:43 — Run #238 (task 352) sha=`ea440da` VERDICT=PASS ~3m30s (S34 Plan 2 G-O1 Danh bạ nội bộ — BE+FE 2 app endpoint mới `/api/directory`):** Push range `edba4ae..ea440da` 2 commits 23 files: (1) `7b0781b` Plan 1 Curate 4 agent MEMORY (8 file MD — match `**/*.md` paths-ignore) + (2) `ea440da` Plan 2 G-O1 15 file BE+FE code (BE 4 new/modified: `DirectoryFeatures.cs` Application/Office namespace mới + `DirectoryController.cs` route `/api/directory` + `MenuKeys.cs` +Off/Off_DanhBa + `DbInitializer.cs` seed menu; FE × 2 app cookie-cutter: 5 new/modified each — types/directory.ts + pages/office/InternalDirectoryPage.tsx + App.tsx + Layout.tsx + menuKeys.ts). Per Discovery #3 anomaly: ≥1 commit non-ignored → entire push runs (commit 2 code triggers). **Stage results ALL PASS** (Run status=success, conclusion=None UI bug per Run #350/#237 pattern): test_domain 58 + test_infra 62 baseline 120/120 UNCHANGED (no test add G-O1 per UAT mode) + build_be (Application/Office namespace mới auto-discovered MediatR — KHÔNG gotcha #1) + build_fe_admin + build_fe_user (BOTH bundle rotate) + deploy NSSM IIS recycle. **Post-deploy verify ALL PASS Stage 4 + 4.6 sqlcmd:** auth login admin 200 + **5/5 endpoint smoke 200** (contracts 3.5KB + PE 6.2KB + employees 8.0KB + menus 10.8KB + **NEW directory 13.6KB 34 rows** — wire BE/FE confirmed: 34 @solutions.com.vn users w/ employeeCode+phone+departmentName populated, dept sample covers all 9 phòng ban: Ban GĐ + Cung ứng + Kiểm soát Chi phí + Kế toán + Nhân sự-HC + QS + Thiết bị + Tài chính + ...) + health/live 200 (0.14s) + admin/eoffice 200 (0.11s/0.10s) + **bundle hash BOTH rotated** (fe-admin `CqGMUMOr`→`ChA9_vP5` + fe-user `C_HKyxBe`→`DCpX7akt` — Plan 2 FE × 2 app ship confirmed) + Mig 34 prod TOP 1 `20260526110207_AddEmployeeProfiles` UNCHANGED (no Mig in G-O1, only Application/Office layer + menu seed — expected) + **menu seed verify GOOD**: `Off`+`Off_DanhBa` keys present in /api/menus response + sqlcmd direct `MenuItems WHERE Key IN ('Off', 'Off_DanhBa')` returns 2 rows (Off root order 29, Off_DanhBa child Order 1 ParentKey=Off) + Permissions auto-grant 2 rows (`MenuKey IN ('Off','Off_DanhBa')` — SeedAdminPermissionsAsync All[] wire OK). **Gotcha #44 silent 403 NOT observed:** class-level `[Authorize]` on DirectoryController correctly allows any authenticated user (admin login 200 OK). **0 prod regression observed Run #238.** Pattern: BE namespace mới `SolutionErp.Application.Office` (first Office-domain after Hrm) MediatR auto-discovery WORK — no manual `services.AddMediatR` registration needed (assembly scan từ Application root namespace, gotcha #1 pin v12.4.1 stable). **Cumulative S33-S34 deploy:** 3× Run PASS (#350 Phase 1 schema + #237 Phase 2 wire + #238 G-O1 Danh bạ). Token cost ~25K (Read MEMORY + grep git + 10 Bash poll/curl/ssh/sqlcmd + parse JSON). + - **2026-05-26 20:28-20:32 — Run #237 (task 351) sha=`79a8343` VERDICT=PASS 3m50s (S33 Plan B G-H1 Phase 2 Task 4+5+6 — Hrm CQRS endpoint + FE 2 app + Menu seed):** Push range `48a99e1..79a8343` 3 commits 18 files: (1) `0e191de` Task 4+6 (3 BE new — EmployeesController + EmployeeDtos + EmployeeFeatures + 2 modified MenuKeys + DbInitializer = CODE non-ignored CI trigger) + (2) `9616ae2` Task 5 FE × 2 app (12 file = 6 new × 2 + 6 modified App/Layout/menuKeys/types — FE CODE CI trigger + bundle rotate) + (3) `79a8343` MEMORY 3 agent (match `**/*.md` ignore). Per Discovery #3 anomaly: ≥1 commit non-ignored → entire push runs (commit 1+2 code triggers). **Single Run #237 success authoritative** (Gitea task API status=success, conclusion=None UI bug — same S33 #350 pattern). **Stage results ALL PASS:** test_domain 58 + test_infra 62 (baseline 120/120 unchanged — no test add Plan B Phase 2 per UAT mode) + build_be (Hrm CQRS compile OK) + build_fe_admin + build_fe_user (BOTH bundle rotate — 6 new file × 2 app each) + deploy NSSM IIS recycle. **Post-deploy verify ALL PASS:** auth login admin 200 + **6/6 endpoint smoke 200** (contracts/PE/menus/auth.me + **2 NEW employees endpoint 200/200** — wire BE Task 4 confirmed: route `/api/employees` GET list + GET paged page=1&pageSize=5) + health/live 200 (0.21s) + admin/eoffice 200 (0.23s/0.25s) + **bundle hash BOTH rotated** (fe-admin `BUTKoqRP`→`CqGMUMOr` + fe-user `CMHv2GS4`→`C_HKyxBe` per FE Task 5 ship confirmed) + Mig 34 prod TOP 1 = `20260526110207_AddEmployeeProfiles` STILL applied (no rollback, Task 4-6 chỉ thêm CQRS + FE + menu seed, không touch Mig) + **menu seed verify GOOD**: `Hrm` + `Hrm_HoSo` keys present in /api/menus response (Task 6 SeedMenuTreeAsync + SeedAdminPermissionsAsync wire OK) + EmployeeProfiles total=33 (unchanged Run #350 baseline — idempotent guard skip). Sample row `NV/2026/0007` (BOD 1 — Director, Ban Giám đốc, hireDate 2021-08-01) properly formed via API. Plan B Phase 2 Task 4+5+6 cookie-cutter mirror PE Workspace pattern WORK end-to-end FE+BE+DB+Menu. **0 prod regression observed Run #237.** Pattern 12-bis cross-module mirror reaffirmed strong. **Cumulative S33 deploy:** 2× Run (#350 Phase 1 schema + #237 Phase 2 wire). Pending S33 next kick: Phase 1.5 backlog if any (e.g. Employee CRUD/Update/Detail Page) hoặc Phase 10.1 next G-H module port. Token cost ~30K (8 Bash curl/ssh/grep + Read MEMORY + parse JSON). - **2026-05-26 18:19-18:23 — Run #350 sha=`48a99e1` VERDICT=PASS 3m38s (S33 Plan B G-H1 Mig 34 EmployeeProfile + Plan C BW1-BW7 test bundle):** Push range `5400983..48a99e1` 4 commits 34 files: (1) `1bc6b70` docs drift (3 file MD — match `docs/**` + `**/*.md` paths-ignore) + (2) `b3444a3` 3 MEMORY agent (match `**/*.md`) + (3) `0605f19` Plan C +4 test file +9 [Fact]+Theory instances (NOT ignored → CI trigger) + (4) `48a99e1` Mig 34 + 17 entity/config new + 7 modified (NOT ignored → CI trigger). Per Discovery #3 anomaly Gitea per-push trigger when ≥1 commit non-ignored → entire push runs. **Stage results ALL PASS** (Run status=success authoritative — Gitea task API `conclusion=None` even on success, do NOT confuse): test_domain 58/58 + test_infra **62/62** (=53 baseline +9 BW: BW1 happy Cấp 1→2 / BW2 terminal DaPhatHanh + gen mã / BW3 skipToFinal F2 admin opt-in / BW4 outsider ForbiddenException / BW5 wrong ApplicableType / BW6a UNIQUE composite / BW6b UPSERT 1 row / BW6c Cascade delete / BW7 V1 fallback ConflictException) + build_be (Mig 34 compile OK +6555 LOC) + build_fe_admin + build_fe_user (unchanged — no FE in push) + deploy NSSM IIS recycle. **Post-deploy verify ALL PASS (Stage 4 + 4.6 sqlcmd):** auth login admin 200 + 4 endpoint smoke 200/200/200/200 (contracts/PE/menus/auth.me) + health/live 200 + bundle hash 2/2 UNCHANGED (fe-admin `BUTKoqRP` + fe-user `CMHv2GS4` baseline preserved — expected NO FE) + Mig 34 prod TOP 1 = `20260526110207_AddEmployeeProfiles` MATCHES repo + **gotcha #51 INFRASTRUCTURE seed verify GOOD**: EmployeeProfiles=**33 rows** (16 demo + 14 Solutions thật + 3 admin/test = full @solutions.com.vn user reconcile) + EmployeeCodeSequences NV/2026 LastSeq=33 atomic match + sample rows NV/2026/0001-0003 EmployeeStatus=1 Active Nationality="Việt Nam" — `SeedDemoEmployeeProfilesAsync` correctly NOT gated `DemoSeed:Disabled` per gotcha #51 lesson (DbInitializer.cs:94 vs 99/111 demo gate). Plan B Investigator pre-flight + Em main 4 decisions chốt + Implementer 17 new file Pattern 12-bis cross-module mirror PE→Hrm cookie-cutter all WORK end-to-end. **Plan C BW1-BW7 ROI:** test gate caught NOTHING this run (all PASS first try) — Pattern 12-bis mirror clean + Implementer Reviewer pre-commit gate strong. **0 prod regression observed Run #350.** Discovery #7 path filter `eval/**` missing still stands but N/A this push (no eval/* files). **Cumulative S33 deploy:** 1× Run (#350), expect more S33+ kicks (Plan B Phase 2 Task 4 endpoint CQRS + Plan B Task 5 FE 2 app + Plan B Task 6 menu seed). Token cost ~30K (Read MEMORY + grep tests + git show + 8 Bash poll/curl/ssh + Read Mig 34 file). diff --git a/.claude/agent-memory/implementer/MEMORY.md b/.claude/agent-memory/implementer/MEMORY.md index 8d7ddc4..f347173 100644 --- a/.claude/agent-memory/implementer/MEMORY.md +++ b/.claude/agent-memory/implementer/MEMORY.md @@ -337,6 +337,14 @@ KHÔNG `*` / `latest`. Critical pins: - **2026-05-26 (S32 startup — context verify + RAG live confirm + size FLAG > 25KB):** Em chủ trì spawn em verify Session 32 context. **Verify done:** (1) MEMORY size 36.2KB (Get-Item Length=36207 bytes) — **OVER 25KB threshold ~45% bigger** → FLAG cho em main schedule dedicated curate session per Pattern curate trigger rule line 364. KHÔNG self-curate vì em chủ trì preference reserve cho em main solo judgment call §6.5 KEEP vs CUT (S27 retrospective C1-C4 task lesson). (2) Patterns saved 1-12 foundation + 12-bis NEW S29 + 13-15 + 16-bis NEW S29 + 17-19 — total **17 numbered patterns** (Pattern 16 baseline implied trong recent activity S27 chưa numbered explicit). Pattern 12-bis (cross-module entity cookie-cutter mirror PE→Contract Mig 33) **SAVED line 178-200** confirmed present. Pattern 16-bis (4-place mirror cross-app S29 Plan CA Hotfix 1) **SAVED line 165-176** confirmed present. (3) MCP RAG tools **PRESENT** — `mcp__rag-unified__search_memory` + `mcp__rag-unified__cross_project_search` both visible trong tools list. Test query "Pattern 12-bis cross-module entity cookie-cutter mirror PE Contract V2" top_k=3 returned 3 results với rerank scores **0.824/0.801/0.793** — all healthy > 0.7 threshold. S31 RAG v1.3 baseline PASS confirmed live post CLI restart. **Pending tasks em main có thể gọi em lại spawn S32+:** (a) Plan B-Wrap BW1-BW7 test bundle codegen Case 2 cookie-cutter mirror PE WorkflowService test pattern (regression ApproveV2Async + UPSERT LevelOpinions test) — 7 file new test class mirror PE test bundle structure; (b) ContractWorkflowMatrixView mirror PE WorkflowMatrixView Plan AA S24 (1 page mirror cross-module — Case 2 fits Pattern 13 read-only admin Designer mirror + Pattern 14 Tailwind JIT palette + Pattern 15 HTML table rowSpan iteration helper). **Decision tree forward:** Em chủ trì gọi em với task code edit → em ACCEPT case (a)/(b) khi spec deterministic, REFUSE nếu first-time pattern. Em chủ trì confirm Layer A governance still active scope SOLUTION_ERP. Token cost spawn này ~5k (3 Read + 1 RAG query + 1 Edit + final report). KHÔNG curate — defer em main full curate session. Tag: `[verify, phase-9, infra]`. +- **S34 G-H1 Phase 1.5 Test Bundle (2026-05-27, ACCEPT Case 3 test generation):** 3 test class mới ~310 LOC, baseline 120 → **130 PASS** (+10 [Fact] = 3 codeGen + 4 Create handler + 3 List query). 0 regression cũ. Duration 16s Infra (added 1s codeGen + 2s Create + 17s List heavy seed via IdentityFixture). + - **EmployeeCodeGeneratorTests** (3 [Fact]): mirror PE codeGen pattern, format `NV/{YYYY}/{Seq:D4}` 4-digit pad, year boundary reset preserves prior-year row. + - **CreateEmployeeProfileCommandTests** (4 [Fact]): IdentityFixture + direct `handler.Handle()`. Mirror BW5 ConflictException + NotFoundException. **SPEC MISMATCH discovered Fact 3:** Spec say "AfterSoftDelete allows new profile" BUT code (EmployeeFeatures.cs:158-163) check existing KHÔNG filter `!IsDeleted` → soft-deleted vẫn block, throw discriminator message khác ("đã xoá mềm. Cần khôi phục thay vì tạo mới." vs active "mỗi user chỉ được 1 hồ sơ"). Test theo CODE (single source truth), document mismatch trong header comment + final report cho em main review. + - **ListEmployeesQueryTests** (3 [Fact]): filter Status + DepartmentId + Search. Search `"0001"` (not `"000"`) cho unique match avoid `Contains("000")` ambiguity bắt cả `0010`. + - Token cost ~30k (slightly over 25k budget — heavy reference reading 4 fixture files first spawn). + - Pattern 11 (test infra helper cookie-cutter) reinforced. Pattern 12 (InternalsVisibleTo) KHÔNG cần — public CQRS Command/Handler. + - LESSON: Spec drift detection BEFORE writing test = saved bug. Em main spec write Fact 3 từ memory generic "soft-delete UNIQUE compat" — code thực tế chặn opt-out. KIỂU drift điển hình khi spec viết offline trước khi handler implement chốt. + - **Archived to `archive/2026-05-q3.md` 2026-05-27 S34 curate (em main proxy):** S29 wrap (5-spawn Plan CA + Plan B 4 chunks + E3 stopped + Pattern 12-bis NEW) + S28 wrap (Layer A governance perspective Implementer) + S27 wrap retrospective REFUSE analysis (8 task ACCEPT/REFUSE table + Pattern 20 5 PS scripts mirror) + 2026-05-22 curate session note + 2026-05-11 setup baseline. KEY takeaways absorbed in current entries S33+S32 + Patterns 1-15+12-bis+16-bis foundation section line 26-283. - **5 verbose entries S25-S29 archived to `archive/2026-05-q2.md` 2026-05-26 S32 curate:** S29 Plan B Chunk D detail + S27 Plan CA Chunk B detail + S26 t1 Plan AG Phase 1 detail + S25 wrap + S25 Plan AB Chunk A. KEY takeaways preserved trong S33+S32 wrap entries. Patterns 16-19 NEW S25-S26 reference foundation section line 165-283. diff --git a/fe-admin/src/lib/menuKeys.ts b/fe-admin/src/lib/menuKeys.ts index 2751a3c..5e7a3f8 100644 --- a/fe-admin/src/lib/menuKeys.ts +++ b/fe-admin/src/lib/menuKeys.ts @@ -5,6 +5,12 @@ export const MenuKeys = { Suppliers: 'Suppliers', Projects: 'Projects', Departments: 'Departments', + // 4 master catalogs cho Details add form autocomplete (Plan CA S29 — UI sang fe-user) + Catalogs: 'Catalogs', + CatalogUnits: 'CatalogUnits', + CatalogMaterials: 'CatalogMaterials', + CatalogServices: 'CatalogServices', + CatalogWorkItems: 'CatalogWorkItems', Contracts: 'Contracts', Forms: 'Forms', Reports: 'Reports', @@ -13,12 +19,18 @@ export const MenuKeys = { Roles: 'Roles', Permissions: 'Permissions', MenuVisibility: 'MenuVisibility', + Workflows: 'Workflows', PurchaseEvaluations: 'PurchaseEvaluations', PeWorkflows: 'PeWorkflows', // Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08) ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', AwV2_DuyetNcc: 'AwV2_DuyetNcc', AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', + // Module Ngân sách (Phase 7) + Budgets: 'Budgets', + Bg_List: 'Bg_List', + Bg_Create: 'Bg_Create', + Bg_Pending: 'Bg_Pending', // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26) Hrm: 'Hrm', HrmHoSo: 'Hrm_HoSo', diff --git a/fe-user/src/lib/menuKeys.ts b/fe-user/src/lib/menuKeys.ts index e60a30e..e22ddd1 100644 --- a/fe-user/src/lib/menuKeys.ts +++ b/fe-user/src/lib/menuKeys.ts @@ -5,6 +5,7 @@ export const MenuKeys = { Suppliers: 'Suppliers', Projects: 'Projects', Departments: 'Departments', + // 4 master catalogs cho Details add form autocomplete (Plan CA S29 — UI ở fe-user) Catalogs: 'Catalogs', CatalogUnits: 'CatalogUnits', CatalogMaterials: 'CatalogMaterials', @@ -17,8 +18,19 @@ export const MenuKeys = { Users: 'Users', Roles: 'Roles', Permissions: 'Permissions', + MenuVisibility: 'MenuVisibility', + Workflows: 'Workflows', PurchaseEvaluations: 'PurchaseEvaluations', PeWorkflows: 'PeWorkflows', + // Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08) + ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', + AwV2_DuyetNcc: 'AwV2_DuyetNcc', + AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', + // Module Ngân sách (Phase 7) + Budgets: 'Budgets', + Bg_List: 'Bg_List', + Bg_Create: 'Bg_Create', + Bg_Pending: 'Bg_Pending', // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26) Hrm: 'Hrm', HrmHoSo: 'Hrm_HoSo', diff --git a/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs b/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs index b502edc..af0b98b 100644 --- a/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs @@ -13,11 +13,16 @@ namespace SolutionErp.Api.Controllers; // 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. +// Phase 1.5 S34 — per-action policy wired (Reviewer recommend gotcha #44 mitigation): +// GET → "Hrm_HoSo.Read" +// POST → "Hrm_HoSo.Create" +// PUT → "Hrm_HoSo.Update" +// DELETE → "Hrm_HoSo.Delete" +// Class-level Read policy default — non-admin role thiếu Read sẽ 403 silent +// (cross-ref gotcha #44 — FE PermissionGuard wrap để tránh silent UX). [ApiController] [Route("api/employees")] -[Authorize] +[Authorize(Policy = "Hrm_HoSo.Read")] public class EmployeesController(IMediator mediator) : ControllerBase { [HttpGet] @@ -37,6 +42,7 @@ public class EmployeesController(IMediator mediator) : ControllerBase => Ok(await mediator.Send(new GetEmployeeProfileQuery(id), ct)); [HttpPost] + [Authorize(Policy = "Hrm_HoSo.Create")] public async Task> Create( [FromBody] CreateEmployeeProfileCommand cmd, CancellationToken ct) { @@ -45,6 +51,7 @@ public class EmployeesController(IMediator mediator) : ControllerBase } [HttpPut("{id:guid}")] + [Authorize(Policy = "Hrm_HoSo.Update")] public async Task Update( Guid id, [FromBody] UpdateEmployeeProfileCommand cmd, CancellationToken ct) { @@ -54,6 +61,7 @@ public class EmployeesController(IMediator mediator) : ControllerBase } [HttpDelete("{id:guid}")] + [Authorize(Policy = "Hrm_HoSo.Delete")] public async Task Delete(Guid id, CancellationToken ct) { await mediator.Send(new DeleteEmployeeProfileCommand(id), ct); diff --git a/src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs b/src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs index abd3159..81ef3e3 100644 --- a/src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs +++ b/src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs @@ -280,11 +280,13 @@ public record UpdateEmployeeProfileCommand( decimal? SeniorityLeaveDays, DateOnly? SocialInsuranceStartDate, string? MedicalRegistrationPlace, - bool IsCommunistParty, + // Phase 1.5 S34 — bool → bool? safe partial update. FE chỉ gửi field admin + // muốn đổi → null = KHÔNG đổi (giữ giá trị cũ). Reviewer minor #(b) S33. + bool? IsCommunistParty, DateOnly? CommunistPartyJoinDate, - bool IsYouthUnion, + bool? IsYouthUnion, DateOnly? YouthUnionJoinDate, - bool IsTradeUnion, + bool? IsTradeUnion, DateOnly? TradeUnionJoinDate, string? PhotoUrl, string? Notes) : IRequest; @@ -403,11 +405,13 @@ public class UpdateEmployeeProfileCommandHandler( entity.SeniorityLeaveDays = request.SeniorityLeaveDays; entity.SocialInsuranceStartDate = request.SocialInsuranceStartDate; entity.MedicalRegistrationPlace = request.MedicalRegistrationPlace; - entity.IsCommunistParty = request.IsCommunistParty; + // Phase 1.5 S34 — bool? partial update: null = KHÔNG đổi (giữ giá trị cũ). + // FE chỉ gửi field admin muốn đổi tránh accidental reset (Reviewer minor S33). + if (request.IsCommunistParty.HasValue) entity.IsCommunistParty = request.IsCommunistParty.Value; entity.CommunistPartyJoinDate = request.CommunistPartyJoinDate; - entity.IsYouthUnion = request.IsYouthUnion; + if (request.IsYouthUnion.HasValue) entity.IsYouthUnion = request.IsYouthUnion.Value; entity.YouthUnionJoinDate = request.YouthUnionJoinDate; - entity.IsTradeUnion = request.IsTradeUnion; + if (request.IsTradeUnion.HasValue) entity.IsTradeUnion = request.IsTradeUnion.Value; entity.TradeUnionJoinDate = request.TradeUnionJoinDate; entity.PhotoUrl = request.PhotoUrl; entity.Notes = request.Notes; diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/CreateEmployeeProfileCommandTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/CreateEmployeeProfileCommandTests.cs new file mode 100644 index 0000000..47f3ea9 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/CreateEmployeeProfileCommandTests.cs @@ -0,0 +1,160 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Hrm; +using SolutionErp.Application.Hrm.Services; +using SolutionErp.Domain.Hrm; +using SolutionErp.Domain.Identity; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// Plan G-H1 Phase 1.5 Test Bundle 2 (S34) — CreateEmployeeProfileCommand handler. +// 4 [Fact] cover: success / duplicate-active / duplicate-soft-deleted / user-not-found. +// +// Mirror CreateContractCommandApplicableTypeTests (BW5 ConflictException pattern): +// IdentityFixture + handler.Handle() direct invocation (skip MediatR pipeline, +// FluentValidator chỉ field-level constraint không tham gia cross-table check). +// +// IMPORTANT spec mismatch documented (S34 Test Bundle): +// - Spec Fact 3 mention "AfterSoftDelete allows new profile for same userId". +// - Code (EmployeeFeatures.cs:158-163) check existing KHÔNG filter IsDeleted → +// soft-deleted profile vẫn block new profile + throw với MESSAGE KHÁC +// ("đã xoá mềm. Cần khôi phục thay vì tạo mới."). +// - Test theo CODE thực tế (single source of truth): expect ConflictException +// với discriminator message khác giữa 2 case (active vs soft-deleted). +// - Em main confirm khi review test bundle: spec → code drift, code chuẩn. +public class CreateEmployeeProfileCommandTests +{ + private static CreateEmployeeProfileCommandHandler CreateHandler( + IdentityFixture fix, FixedDateTime dt) + { + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var codeGen = new EmployeeCodeGenerator(db, dt); + return new CreateEmployeeProfileCommandHandler(db, codeGen, um); + } + + // ===== Spec Fact 1: First profile for user — happy path ===== + + [Fact] + public async Task Create_FirstProfileForUser_ReturnsId_PersistsEntity() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + + var user = await fix.CreateUserAsync("nv001@test.local", "Nguyễn Văn A", + departmentId: null, roles: Array.Empty()); + + var handler = CreateHandler(fix, dt); + var cmd = new CreateEmployeeProfileCommand(UserId: user.Id); + + var id = await handler.Handle(cmd, CancellationToken.None); + + // Returns Guid not empty. + id.Should().NotBeEmpty(); + + // Persists entity với EmployeeCode "NV/2026/0001" (first call of year). + var entity = await db.EmployeeProfiles.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id); + entity.Should().NotBeNull(); + entity!.UserId.Should().Be(user.Id); + entity.EmployeeCode.Should().Be("NV/2026/0001"); + entity.EmployeeStatus.Should().Be(EmployeeStatus.Active); // Default per command record. + entity.Nationality.Should().Be("Việt Nam"); // Default trong handler line 184. + } + + // ===== Spec Fact 2: Duplicate userId (active existing) — ConflictException ===== + + [Fact] + public async Task Create_DuplicateUserId_ThrowsConflictException() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + + var user = await fix.CreateUserAsync("nv002@test.local", "Nguyễn Văn B", + departmentId: null, roles: Array.Empty()); + + // Seed 1 active profile (KHÔNG soft-deleted). + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user.Id, + EmployeeCode = "NV/2026/9001", + EmployeeStatus = EmployeeStatus.Active, + IsDeleted = false, + }); + await db.SaveChangesAsync(); + + var handler = CreateHandler(fix, dt); + var cmd = new CreateEmployeeProfileCommand(UserId: user.Id); + + var act = async () => await handler.Handle(cmd, CancellationToken.None); + + // Code line 162-163 throw active message — discriminator vs soft-deleted. + await act.Should().ThrowAsync() + .WithMessage("*đã có hồ sơ NV*mỗi user chỉ được 1 hồ sơ*"); + } + + // ===== Spec Fact 3: Soft-deleted existing — BLOCKED (code behavior, NOT spec) ===== + + [Fact] + public async Task Create_AfterSoftDelete_ThrowsConflictWithRestoreMessage() + { + // SPEC MISMATCH: spec mention "allows new profile" — code chặn lại + throw + // với message "đã xoá mềm. Cần khôi phục". Test theo CODE (line 158-163). + // Em main review: behavior này CORRECT vì admin biết user từng có profile + // → cần khôi phục thay vì tạo mới (Phase 1.5 restore flow defer). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + + var user = await fix.CreateUserAsync("nv003@test.local", "Nguyễn Văn C", + departmentId: null, roles: Array.Empty()); + + // Seed 1 soft-deleted profile. + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user.Id, + EmployeeCode = "NV/2026/9003", + EmployeeStatus = EmployeeStatus.Resigned, + IsDeleted = true, + DeletedAt = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + DeletedBy = Guid.NewGuid(), + }); + await db.SaveChangesAsync(); + + var handler = CreateHandler(fix, dt); + var cmd = new CreateEmployeeProfileCommand(UserId: user.Id); + + var act = async () => await handler.Handle(cmd, CancellationToken.None); + + // Code line 161-162 throw soft-delete message — discriminator vs active. + await act.Should().ThrowAsync() + .WithMessage("*đã xoá mềm*khôi phục thay vì tạo mới*"); + } + + // ===== Spec Fact 4: User not found — NotFoundException ===== + + [Fact] + public async Task Create_UserNotFound_ThrowsNotFoundException() + { + using var fix = new IdentityFixture(); + var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + + var handler = CreateHandler(fix, dt); + var randomUserId = Guid.NewGuid(); // KHÔNG có trong Users table. + var cmd = new CreateEmployeeProfileCommand(UserId: randomUserId); + + var act = async () => await handler.Handle(cmd, CancellationToken.None); + + // Code line 153-154 throw NotFoundException qua userManager.FindByIdAsync null. + await act.Should().ThrowAsync() + .WithMessage($"*User*{randomUserId}*không tồn tại*"); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ListEmployeesQueryTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ListEmployeesQueryTests.cs new file mode 100644 index 0000000..f90b128 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ListEmployeesQueryTests.cs @@ -0,0 +1,186 @@ +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Hrm; +using SolutionErp.Domain.Hrm; +using SolutionErp.Domain.Master; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// Plan G-H1 Phase 1.5 Test Bundle 3 (S34) — ListEmployeesQuery filter + paging. +// 3 [Fact] cover: status filter / department filter / search by code. +// +// Mirror existing List query test pattern. JOIN Users + Departments LEFT (per +// EmployeeFeatures.cs:571-575) → seed cả Department entity + User.DepartmentId +// để test department filter chuẩn xác. +// +// Search: EmployeeCode.Contains(s) || FullName.Contains(s) — use "0001" để match +// chính xác 1 row (avoid Contains("000") match cả 0010). +public class ListEmployeesQueryTests +{ + private static ListEmployeesQueryHandler CreateHandler(IdentityFixture fix) + { + var db = fix.Services.GetRequiredService(); + return new ListEmployeesQueryHandler(db); + } + + // ===== Spec Fact 1: Filter by status — only matching ===== + + [Fact] + public async Task List_FilterByStatus_ReturnsOnlyMatching() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + // Seed 3 user + 3 profile: 2 Active + 1 Resigned. + var user1 = await fix.CreateUserAsync("nv-active1@test.local", "Active 1", + departmentId: null, roles: Array.Empty()); + var user2 = await fix.CreateUserAsync("nv-active2@test.local", "Active 2", + departmentId: null, roles: Array.Empty()); + var user3 = await fix.CreateUserAsync("nv-resigned@test.local", "Resigned", + departmentId: null, roles: Array.Empty()); + + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user1.Id, + EmployeeCode = "NV/2026/0001", + EmployeeStatus = EmployeeStatus.Active, + }); + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user2.Id, + EmployeeCode = "NV/2026/0002", + EmployeeStatus = EmployeeStatus.Active, + }); + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user3.Id, + EmployeeCode = "NV/2026/0003", + EmployeeStatus = EmployeeStatus.Resigned, + }); + await db.SaveChangesAsync(); + + var handler = CreateHandler(fix); + var query = new ListEmployeesQuery(Status: EmployeeStatus.Resigned); + + var result = await handler.Handle(query, CancellationToken.None); + + // Chỉ 1 row Resigned match. + result.Items.Should().HaveCount(1); + result.Items[0].Status.Should().Be(EmployeeStatus.Resigned); + result.Items[0].EmployeeCode.Should().Be("NV/2026/0003"); + result.Total.Should().Be(1); + } + + // ===== Spec Fact 2: Filter by departmentId — only matching ===== + + [Fact] + public async Task List_FilterByDepartmentId_ReturnsOnlyMatching() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + // Seed 2 department (A, B). + var deptA = new Department { Id = Guid.NewGuid(), Code = "DPT-A", Name = "Phòng A" }; + var deptB = new Department { Id = Guid.NewGuid(), Code = "DPT-B", Name = "Phòng B" }; + db.Departments.Add(deptA); + db.Departments.Add(deptB); + await db.SaveChangesAsync(); + + // Seed 3 user: 2 trong A + 1 trong B. + var userA1 = await fix.CreateUserAsync("nv-a1@test.local", "User A1", + departmentId: deptA.Id, roles: Array.Empty()); + var userA2 = await fix.CreateUserAsync("nv-a2@test.local", "User A2", + departmentId: deptA.Id, roles: Array.Empty()); + var userB1 = await fix.CreateUserAsync("nv-b1@test.local", "User B1", + departmentId: deptB.Id, roles: Array.Empty()); + + // Seed 3 EmployeeProfile. + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = userA1.Id, + EmployeeCode = "NV/2026/0001", + EmployeeStatus = EmployeeStatus.Active, + }); + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = userA2.Id, + EmployeeCode = "NV/2026/0002", + EmployeeStatus = EmployeeStatus.Active, + }); + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = userB1.Id, + EmployeeCode = "NV/2026/0003", + EmployeeStatus = EmployeeStatus.Active, + }); + await db.SaveChangesAsync(); + + var handler = CreateHandler(fix); + var query = new ListEmployeesQuery(DepartmentId: deptA.Id); + + var result = await handler.Handle(query, CancellationToken.None); + + // 2 user trong dept A — chính xác 2 row. + result.Items.Should().HaveCount(2); + result.Items.Should().OnlyContain(x => x.DepartmentId == deptA.Id); + result.Items.Should().OnlyContain(x => x.DepartmentName == "Phòng A"); + result.Total.Should().Be(2); + } + + // ===== Spec Fact 3: Search by partial EmployeeCode — distinguishing match ===== + + [Fact] + public async Task List_SearchByCode_MatchesPartialEmployeeCode() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + // Seed 3 profile với code 0001 / 0002 / 0010. + var user1 = await fix.CreateUserAsync("nv-s1@test.local", "User Search 1", + departmentId: null, roles: Array.Empty()); + var user2 = await fix.CreateUserAsync("nv-s2@test.local", "User Search 2", + departmentId: null, roles: Array.Empty()); + var user3 = await fix.CreateUserAsync("nv-s10@test.local", "User Search 10", + departmentId: null, roles: Array.Empty()); + + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user1.Id, + EmployeeCode = "NV/2026/0001", + EmployeeStatus = EmployeeStatus.Active, + }); + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user2.Id, + EmployeeCode = "NV/2026/0002", + EmployeeStatus = EmployeeStatus.Active, + }); + db.EmployeeProfiles.Add(new EmployeeProfile + { + Id = Guid.NewGuid(), + UserId = user3.Id, + EmployeeCode = "NV/2026/0010", + EmployeeStatus = EmployeeStatus.Active, + }); + await db.SaveChangesAsync(); + + var handler = CreateHandler(fix); + // Search "0001" → unique match chỉ "NV/2026/0001" (avoid Contains("000") + // match cả 0010 — per spec Adjust note). + var query = new ListEmployeesQuery() { Search = "0001" }; + + var result = await handler.Handle(query, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.Items[0].EmployeeCode.Should().Be("NV/2026/0001"); + result.Total.Should().Be(1); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/EmployeeCodeGeneratorTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/EmployeeCodeGeneratorTests.cs new file mode 100644 index 0000000..381e4ba --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/EmployeeCodeGeneratorTests.cs @@ -0,0 +1,120 @@ +using Microsoft.EntityFrameworkCore; +using SolutionErp.Domain.Hrm; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Plan G-H1 Phase 1.5 Test Bundle 1 (S34) — EmployeeCodeGenerator atomic +// SERIALIZABLE sequence generator. Format "NV/{YYYY}/{Seq:D4}", reset per năm. +// +// Mirror PurchaseEvaluationCodeGeneratorTests + ContractCodeGeneratorTests +// pattern: SqliteDbFixture + FixedDateTime stub + direct constructor. +// +// SQLite không enforce SERIALIZABLE isolation strict (provider mapping graceful) +// — đủ test format + sequential increment + year boundary, KHÔNG đủ test race +// condition (cần SQL Server thật cho integration test riêng). +public class EmployeeCodeGeneratorTests +{ + private static (EmployeeCodeGenerator gen, SqliteDbFixture fix, FixedDateTime dt) + CreateGenerator(int year = 2026) + { + var fix = new SqliteDbFixture(); + var dt = new FixedDateTime(new DateTime(year, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var gen = new EmployeeCodeGenerator(fix.Db, dt); + return (gen, fix, dt); + } + + // ===== Spec Fact 1: First call of year — empty sequence table ===== + + [Fact] + public async Task GenerateAsync_FirstCallOfYear_ReturnsNV001() + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + // Empty sequence table — fresh DB từ EnsureCreated(). + fix.Db.EmployeeCodeSequences.Should().BeEmpty(); + + var code = await gen.GenerateAsync(); + + // Format "NV/{YYYY}/{Seq:D4}" — 4-digit zero pad. + code.Should().Be("NV/2026/0001"); + + // DB row created với LastSeq=1. + var seq = await fix.Db.EmployeeCodeSequences + .SingleAsync(s => s.Prefix == "NV/2026"); + seq.LastSeq.Should().Be(1); + } + } + + // ===== Spec Fact 2: Sequential calls increment seq ===== + + [Fact] + public async Task GenerateAsync_SequentialCalls_IncrementSeq() + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + // Seed prefix "NV/2026" với LastSeq=5 — simulate 5 NV đã tạo trong năm. + fix.Db.EmployeeCodeSequences.Add(new EmployeeCodeSequence + { + Prefix = "NV/2026", + LastSeq = 5, + UpdatedAt = new DateTime(2026, 6, 14, 0, 0, 0, DateTimeKind.Utc), + }); + await fix.Db.SaveChangesAsync(); + + var code = await gen.GenerateAsync(); + + // LastSeq 5 → 6 → format "NV/2026/0006". + code.Should().Be("NV/2026/0006"); + + var seq = await fix.Db.EmployeeCodeSequences + .SingleAsync(s => s.Prefix == "NV/2026"); + seq.LastSeq.Should().Be(6); + } + } + + // ===== Spec Fact 3: Year boundary — new sequence row for new year ===== + + [Fact] + public async Task GenerateAsync_YearBoundary_NewSequenceForNewYear() + { + // Year=2025 seeded LastSeq=999; clock moved to 2026 → expect NEW row năm + // 2026 LastSeq=1, năm 2025 row giữ nguyên (no mutation). + var fix = new SqliteDbFixture(); + var dt = new FixedDateTime(new DateTime(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc)); + var gen = new EmployeeCodeGenerator(fix.Db, dt); + + using (fix) + { + // Seed prefix "NV/2025" với LastSeq=999. + fix.Db.EmployeeCodeSequences.Add(new EmployeeCodeSequence + { + Prefix = "NV/2025", + LastSeq = 999, + UpdatedAt = new DateTime(2025, 12, 30, 0, 0, 0, DateTimeKind.Utc), + }); + await fix.Db.SaveChangesAsync(); + + // Cross year boundary — clock → 2026. + dt.UtcNow = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var code = await gen.GenerateAsync(); + + // New year prefix "NV/2026" — sequence reset bắt đầu 0001. + code.Should().Be("NV/2026/0001"); + + // 2 row trong DB: 2025 giữ nguyên LastSeq=999, 2026 new LastSeq=1. + var rows = await fix.Db.EmployeeCodeSequences + .OrderBy(s => s.Prefix) + .ToListAsync(); + rows.Should().HaveCount(2); + rows[0].Prefix.Should().Be("NV/2025"); + rows[0].LastSeq.Should().Be(999); // Untouched + rows[1].Prefix.Should().Be("NV/2026"); + rows[1].LastSeq.Should().Be(1); + } + } +}