[CLAUDE] Domain+App+Api+Tests+FE-Admin+FE-User: S34 Plan 3 Phase 1.5 batch 4 item
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m48s

Phase 1.5 backlog G-H1 EmployeeProfile hardening batch (Items 6+2+1+4 of 6).

Item 6 — menuKeys FE drift sync × 2 app:
- fe-admin add: Catalogs + 4 Catalog leaves + Workflows + Budgets + Bg_List/Create/Pending (10 key)
- fe-user add: Budgets + Bg_List/Create/Pending + ApprovalWorkflowsV2 + 2 AwV2 leaf + MenuVisibility + Workflows (8 key)
- Cả 2 file giờ identical mirror BE MenuKeys.cs (28 key cumulative)

Item 2 — UpdateEmployeeProfileCommand bool→bool? safe partial update:
- 3 field IsCommunistParty/IsYouthUnion/IsTradeUnion → bool?
- Handler: HasValue check, null = giữ giá trị cũ (Reviewer minor #(b) S33 fixed)
- FE không bắt buộc gửi 3 field every PUT — tránh accidental reset

Item 1 — EmployeesController per-action policy (gotcha #44 mitigation):
- Class-level [Authorize(Policy = "Hrm_HoSo.Read")] — non-admin thiếu Read → 403
- POST [Authorize(Policy = "Hrm_HoSo.Create")]
- PUT  [Authorize(Policy = "Hrm_HoSo.Update")]
- DELETE [Authorize(Policy = "Hrm_HoSo.Delete")]

Item 4 — Test bundle Phase 1.5 (+10 [Fact], baseline 120 → 130/130 PASS):
- EmployeeCodeGeneratorTests (3 [Fact]) — atomic SERIALIZABLE NV/YYYY/NNNN
  + first call + sequential increment + year boundary preserve old year
- CreateEmployeeProfileCommandTests (4 [Fact]) — Create handler edge case
  + first profile + duplicate UserId Conflict + soft-deleted Conflict-restore
  + UserNotFound NotFoundException
- ListEmployeesQueryTests (3 [Fact]) — filter + paging logic
  + status filter + departmentId filter + search by EmployeeCode partial

Implementer Case 3 test gen caught spec mismatch (allow new after soft-delete
vs throws Conflict-restore) — chose CODE source of truth + renamed test
documenting discriminator message branch. Em main verify behavior correct
(admin UX khôi phục thay vì tạo mới — explicit flow defer Phase 1.5+).

Verify:
- dotnet build PASS (2 warn DocxRenderer baseline, 0 error)
- dotnet test 130/130 PASS (58 Domain + 72 Infra = +10)
- 4 endpoint /api/employees policy wired (gotcha #44 active mitigation)
- 4 MEMORY agent updated post-spawn (CICD Run #238 + Implementer test bundle)

Deferred Phase 1.5 next batch:
- Item 3 Satellite CRUD endpoints (WorkHistory/Education/FamilyRelation/Skill/
  Document) + FE inline edit forms — heavy ~2-3h
- Item 5 UAT smoke non-admin role verify silent 403 catch — defer post-deploy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-27 13:57:08 +07:00
parent ea440da990
commit 61e9ce5b3b
9 changed files with 521 additions and 9 deletions

View File

@ -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) ## 📅 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 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). - **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).

View File

@ -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]`. - **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. - **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. - **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.

View File

@ -5,6 +5,12 @@ export const MenuKeys = {
Suppliers: 'Suppliers', Suppliers: 'Suppliers',
Projects: 'Projects', Projects: 'Projects',
Departments: 'Departments', 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', Contracts: 'Contracts',
Forms: 'Forms', Forms: 'Forms',
Reports: 'Reports', Reports: 'Reports',
@ -13,12 +19,18 @@ export const MenuKeys = {
Roles: 'Roles', Roles: 'Roles',
Permissions: 'Permissions', Permissions: 'Permissions',
MenuVisibility: 'MenuVisibility', MenuVisibility: 'MenuVisibility',
Workflows: 'Workflows',
PurchaseEvaluations: 'PurchaseEvaluations', PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows', PeWorkflows: 'PeWorkflows',
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08) // Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08)
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc', AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', 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) // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm', Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo', HrmHoSo: 'Hrm_HoSo',

View File

@ -5,6 +5,7 @@ export const MenuKeys = {
Suppliers: 'Suppliers', Suppliers: 'Suppliers',
Projects: 'Projects', Projects: 'Projects',
Departments: 'Departments', Departments: 'Departments',
// 4 master catalogs cho Details add form autocomplete (Plan CA S29 — UI ở fe-user)
Catalogs: 'Catalogs', Catalogs: 'Catalogs',
CatalogUnits: 'CatalogUnits', CatalogUnits: 'CatalogUnits',
CatalogMaterials: 'CatalogMaterials', CatalogMaterials: 'CatalogMaterials',
@ -17,8 +18,19 @@ export const MenuKeys = {
Users: 'Users', Users: 'Users',
Roles: 'Roles', Roles: 'Roles',
Permissions: 'Permissions', Permissions: 'Permissions',
MenuVisibility: 'MenuVisibility',
Workflows: 'Workflows',
PurchaseEvaluations: 'PurchaseEvaluations', PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows', 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) // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm', Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo', HrmHoSo: 'Hrm_HoSo',

View File

@ -13,11 +13,16 @@ namespace SolutionErp.Api.Controllers;
// Satellite endpoint (WorkHistory/Education/FamilyRelation/Skill/Document // Satellite endpoint (WorkHistory/Education/FamilyRelation/Skill/Document
// CRUD) DEFER Phase 1.5. // CRUD) DEFER Phase 1.5.
// //
// Class-level [Authorize] only — em main Task 6 wire per-action policy // Phase 1.5 S34 — per-action policy wired (Reviewer recommend gotcha #44 mitigation):
// "Hrm_HoSo_View/Create/Edit/Delete" sau khi seed MenuKeys. // 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] [ApiController]
[Route("api/employees")] [Route("api/employees")]
[Authorize] [Authorize(Policy = "Hrm_HoSo.Read")]
public class EmployeesController(IMediator mediator) : ControllerBase public class EmployeesController(IMediator mediator) : ControllerBase
{ {
[HttpGet] [HttpGet]
@ -37,6 +42,7 @@ public class EmployeesController(IMediator mediator) : ControllerBase
=> Ok(await mediator.Send(new GetEmployeeProfileQuery(id), ct)); => Ok(await mediator.Send(new GetEmployeeProfileQuery(id), ct));
[HttpPost] [HttpPost]
[Authorize(Policy = "Hrm_HoSo.Create")]
public async Task<ActionResult<object>> Create( public async Task<ActionResult<object>> Create(
[FromBody] CreateEmployeeProfileCommand cmd, CancellationToken ct) [FromBody] CreateEmployeeProfileCommand cmd, CancellationToken ct)
{ {
@ -45,6 +51,7 @@ public class EmployeesController(IMediator mediator) : ControllerBase
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Policy = "Hrm_HoSo.Update")]
public async Task<IActionResult> Update( public async Task<IActionResult> Update(
Guid id, [FromBody] UpdateEmployeeProfileCommand cmd, CancellationToken ct) Guid id, [FromBody] UpdateEmployeeProfileCommand cmd, CancellationToken ct)
{ {
@ -54,6 +61,7 @@ public class EmployeesController(IMediator mediator) : ControllerBase
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Policy = "Hrm_HoSo.Delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
await mediator.Send(new DeleteEmployeeProfileCommand(id), ct); await mediator.Send(new DeleteEmployeeProfileCommand(id), ct);

View File

@ -280,11 +280,13 @@ public record UpdateEmployeeProfileCommand(
decimal? SeniorityLeaveDays, decimal? SeniorityLeaveDays,
DateOnly? SocialInsuranceStartDate, DateOnly? SocialInsuranceStartDate,
string? MedicalRegistrationPlace, 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, DateOnly? CommunistPartyJoinDate,
bool IsYouthUnion, bool? IsYouthUnion,
DateOnly? YouthUnionJoinDate, DateOnly? YouthUnionJoinDate,
bool IsTradeUnion, bool? IsTradeUnion,
DateOnly? TradeUnionJoinDate, DateOnly? TradeUnionJoinDate,
string? PhotoUrl, string? PhotoUrl,
string? Notes) : IRequest; string? Notes) : IRequest;
@ -403,11 +405,13 @@ public class UpdateEmployeeProfileCommandHandler(
entity.SeniorityLeaveDays = request.SeniorityLeaveDays; entity.SeniorityLeaveDays = request.SeniorityLeaveDays;
entity.SocialInsuranceStartDate = request.SocialInsuranceStartDate; entity.SocialInsuranceStartDate = request.SocialInsuranceStartDate;
entity.MedicalRegistrationPlace = request.MedicalRegistrationPlace; 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.CommunistPartyJoinDate = request.CommunistPartyJoinDate;
entity.IsYouthUnion = request.IsYouthUnion; if (request.IsYouthUnion.HasValue) entity.IsYouthUnion = request.IsYouthUnion.Value;
entity.YouthUnionJoinDate = request.YouthUnionJoinDate; entity.YouthUnionJoinDate = request.YouthUnionJoinDate;
entity.IsTradeUnion = request.IsTradeUnion; if (request.IsTradeUnion.HasValue) entity.IsTradeUnion = request.IsTradeUnion.Value;
entity.TradeUnionJoinDate = request.TradeUnionJoinDate; entity.TradeUnionJoinDate = request.TradeUnionJoinDate;
entity.PhotoUrl = request.PhotoUrl; entity.PhotoUrl = request.PhotoUrl;
entity.Notes = request.Notes; entity.Notes = request.Notes;

View File

@ -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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
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<TestApplicationDbContext>();
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<string>());
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<TestApplicationDbContext>();
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<string>());
// 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<ConflictException>()
.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<TestApplicationDbContext>();
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<string>());
// 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<ConflictException>()
.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<NotFoundException>()
.WithMessage($"*User*{randomUserId}*không tồn tại*");
}
}

View File

@ -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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
// 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<string>());
var user2 = await fix.CreateUserAsync("nv-active2@test.local", "Active 2",
departmentId: null, roles: Array.Empty<string>());
var user3 = await fix.CreateUserAsync("nv-resigned@test.local", "Resigned",
departmentId: null, roles: Array.Empty<string>());
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<TestApplicationDbContext>();
// 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<string>());
var userA2 = await fix.CreateUserAsync("nv-a2@test.local", "User A2",
departmentId: deptA.Id, roles: Array.Empty<string>());
var userB1 = await fix.CreateUserAsync("nv-b1@test.local", "User B1",
departmentId: deptB.Id, roles: Array.Empty<string>());
// 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<TestApplicationDbContext>();
// 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<string>());
var user2 = await fix.CreateUserAsync("nv-s2@test.local", "User Search 2",
departmentId: null, roles: Array.Empty<string>());
var user3 = await fix.CreateUserAsync("nv-s10@test.local", "User Search 10",
departmentId: null, roles: Array.Empty<string>());
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);
}
}

View File

@ -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);
}
}
}