diff --git a/.claude/agent-memory/test-specialist/MEMORY.md b/.claude/agent-memory/test-specialist/MEMORY.md index 1b7625f..7d12458 100644 --- a/.claude/agent-memory/test-specialist/MEMORY.md +++ b/.claude/agent-memory/test-specialist/MEMORY.md @@ -15,7 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ - ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix - ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority -## 📊 Baseline 374 tests = 374 PASS (45 Domain + 329 Infra) ← S85b +3 PE UpdateDraft WF-preserve ROOT-FIX bug-class S42 (test-after BUG-FIX disc): em main fix `UpdatePurchaseEvaluationDraftCommandHandler` (PurchaseEvaluationFeatures.cs:285) ABSOLUTE-SET→NULL-SAFE `if(request.ApprovalWorkflowId is not null) entity.ApprovalWorkflowId=...` (form Sửa header KHÔNG gửi WF → trước = CLEAR WF → kết submit "chưa chọn quy trình" whack-a-mole). NEW `PeUpdateDraftWorkflowPreserveTests.cs` 3: (1) PRESERVE phiếu Nháp WF=W + UpdateDraft ApprovalWorkflowId=null (field text có giá trị) → entity GIỮ W (reproduce: trước-fix null, sau-fix W) + sanity TenGoiThau/DiaDiem vẫn update / (2) CHANGE WF=W1 + gửi W2 (≠null) → đổi W2 (null-safe chỉ chặn null KHÔNG chặn đổi) / (3) bonus mirror BudgetPeriodAmount null-safe S61 (cùng pattern bug-class S42). ⚠️GOTCHA: `PurchaseEvaluation.ApprovalWorkflowId` FK Restrict thật → PE seed PHẢI ref WF row thật (random Guid → SQLite Error 19 FK fail; lần đầu RED). Fix: `SeedWorkflowAsync(db, code)` ApplicableType=DuyetNcc khớp PE Type (CHANGE case validate block line 253-266 cũng cần WF thật); PRESERVE case request-null short-circuit validate NHƯNG seed vẫn cần FK hợp lệ. IdentityFixture (Changelog Add). No prod bug — code đúng spec sau fix. Tag [s85b, pe-updatedraft, wf-preserve, root-fix-bug-class-s42, null-safe-guard, absolute-set→null-safe, approvalworkflowid-fk-restrict-seed-real, mirror-budget-nullsafe, test-after-bugfix]. Prev 371 (S85 +5 PE urgent DRAFTER-self-unset) ← S85 +5 PE urgent DRAFTER-self-unset (test-after authz, mirror PeUrgentToggle): em main S85 restructure handler `PurchaseEvaluationUrgentFeatures.cs` thêm nhánh UNSET cho NGƯỜI KHAI (`isDrafter = currentUser.UserId == entity.DrafterUserId`) → drafter GỠ clear CẢ 2 cờ (giống Admin), SET vẫn role-only (drafter KHÔNG tự gắn). EXTEND `PeUrgentToggleAuthzTests.cs` 11→16: FakeCurrentUser.UserId→`init` (settable) + SeedPeAsync +`drafterUserId` opt param. NEW 5: (10) Drafter role-thường UNSET→clear cả 2 no-Forbidden (pre-seed ByPro để prove CLEAR) / (11) Finance(non-drafter non-TP) UNSET→Forbidden`*GỠ*`+no-mutate cả 2 / (12) Drafter SET→Forbidden`*đánh dấu*` (drafter chỉ gỡ, không gắn) / (13) Drafter UNSET chỉ-ByCcm-bật→clear cả 2 (drafter-clear độc lập cờ-đang-bật) / (14) Drafter+Procurement-no-DeptManager UNSET→clear CẢ 2 qua nhánh isDrafter (đối chứng test6 TP-PRO chỉ clear cờ-vai — isDrafter ưu tiên/độc lập isProTp). 11 test S77c cũ vẫn xanh (không động nhánh drafter). No prod bug — code đúng spec S85. Prev baseline 366 (task-stated, S78-S84 thêm test MEMORY chưa ghi). Tag [s85, pe-urgent, drafter-self-unset, clear-both, set-role-only, isdrafter-priority-over-isProTp, no-mutate-on-forbidden, test-after] ← S77c CORRECTION urgent-toggle SYMMETRIC→ASYMMETRIC (S77b symmetric tests RED vs prod): anh chốt FINAL gate BẤT-ĐỐI-XỨNG theo `request.IsUrgent` — em main đã sửa prod `PurchaseEvaluationUrgentFeatures.cs:49-55` `isPro = IsUrgent ? hasPro : (hasPro && isDeptManager)` + `isCcm = IsUrgent ? hasCcm : (hasCcm && isDeptManager)`; Forbidden-msg nhánh-theo-IsUrgent (SET `*đánh dấu phiếu gấp*` / UNSET `*GỠ cờ gấp*`). REWRITE `PeUrgentToggleAuthzTests.cs` 12→11: **SET = role-chức-năng-đủ** (plain Procurement SET→OK ByPro / plain CostControl SET→OK ByCcm / Admin SET→both) — bỏ hết 2b/2c/2d DeptManager-alone-restriction của S77b (không còn áp cho SET); **UNSET = role AND DeptManager** (plain-PRO UNSET→Forbidden`*GỠ*`+no-mutate flag-still-true pre-seed urgent / plain-CCM UNSET→Forbidden / PRO+DM UNSET→clear-ByPro CCM-preserved / CCM+DM UNSET→clear-ByCcm PRO-preserved / Admin UNSET→both-clear); 9a Drafter-SET→Forbidden`*đánh dấu*` / 9b Finance-SET→Forbidden / 9c UnknownPe→NotFound TRƯỚC authz. No prod touch — prod ĐÃ asymmetric, test theo CODE (S34). Prev S77b 354 (symmetric, superseded) ← S77 +7 PE Mig 57 suggested-price NOTE (test-after, mirror S74 §4b CcmNote): `UpdatePeSuggestedPricePro/CcmCommand` +`Note` (string?) absolute-set→`Pro/CcmSuggestedPriceNote` (overwrite always incl null=clear); rides SAME role-gate as price (PRO/Admin · CCM/Admin) fail-closed TRƯỚC side-effect; validator `Note.MaximumLength(1000)`. Handler ctor `(db, ICurrentUser)` 2-dep + record arity UNCHANGED on existing args (Note=null default → 5 existing `new UpdatePeSuggestedPrice*Command(...)` call-sites still compile, KHÔNG cần vá). NEW 7 → `PeSuggestedPriceSetterAuthzTests.cs`: PRO-note+Min/Max persist (note KHÔNG clobber price) / Admin-PRO-note / CCM-note+price persist / null-clear PRO (pre-seed note→null=cleared) / null-clear CCM / non-priv→Forbidden+note&price unchanged (no partial-write) / PRO↔CCM note independent + validator 1001-char invalid·1000 valid. No prod bug — code đúng spec (note absolute-set + rides price gate, intentional). Prev 344 ← S76 +5 PE Mig 56 PRO-column-split (spec-change+compile-fix arity): UpdatePeBudgetProCommand record 3→4 param `(PeId, ProInitialAmount, ProAdjustmentAmount, ProNote)`; handler set ProInitial+ProAdjust absolute-set (KHÔNG còn ProEstimateAmount). Vá 5 PRO call-site `new UpdatePeBudgetProCommand(...)` thêm arg ProAdjust + rename assert `.ProEstimateAmount`→`.ProInitialAmount` (handler giờ set ProInitial). Capability `FullAmount`: hasCcm?CCM:proFull(ProInitial+ProAdjust); FullIsEstimate=!hasCcm&&hasPro; DTO +2 field cuối ProInitial/ProAdjust. NEW 5: PRO set both incl ProAdjust ÂM persist / validator ProInitial<0 invalid + ProAdjust ÂM valid (mirror CCM no-sign-constraint) / full=proFull(100+50=150)+DTO-surface / full=proFull ÂM-adjust(100-30=70 no-clamp). Full-fallback seed `ProEstimateAmount=500`→`ProInitialAmount=500` (capability đọc ProInitial). **Handler ctor `(db, ICurrentUser)` 2-dep UNCHANGED**. ProEstimateAmount=LEGACY (Mig 56 backfill→ProInitial, FE bỏ). No prod bug. Prev 339 ← S74 +5 PE Mig 55 CcmNote (compile-fix arity + test-after): UpdatePeBudgetCcmCommand record +`string? CcmNote` (4 param) → fix 3 existing `new UpdatePeBudgetCcmCommand(...)` thêm `null` arg4 (PeWorkItemBudgetTests.cs line 388/407/421); NEW 5 test mirror ProNote: CostControl-set / Admin-set / null=clear-absolute-set (pre-seed prove not vốn-dĩ-null) / Procurement→Forbidden no-mutate (fail-closed role-gate TRƯỚC EnsureTrackedAsync+side-effect, AsNoTracking re-read) / Initial+Adjustment+CcmNote all-persist-together. **Handler ctor `(db, ICurrentUser)` 2-dep UNCHANGED** — chỉ COMMAND record +CcmNote. No prod bug. Prev 334 ← S72 +28 PE Mig 54 (spec-change+test-after): PeCcmThresholdFinalizeTests 6→11 (AUTO→OPT-IN finalizeByCcmDelegation) + NEW PeApprovedPriceFinalizeTests 10 (giá chốt) + NEW PeSuggestedPriceSetterAuthzTests 13 (2 setter role-gate). Prev 306 ← S69b +14 PE 2 feature anh Kiệt FDC (test-before-merge SECURITY/FINANCIAL): `PeCcmThresholdFinalizeTests.cs` (5, Services ns, value-threshold CCM-finalize ApproveV2Async) + `PeUrgentToggleAuthzTests.cs` (9, Application ns, urgent-toggle role authz). Prev 292 ← S69 +6 Office golive permission-seed (`OfficeModulePermissionSeedTests.cs`, test-after, mirror HrmProfilePermissionSeedTests S67). Prev 286 ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠️spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget −14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60). +## 📊 Baseline 377 tests = 377 PASS (45 Domain + 332 Infra) ← S86 +3 PE drafter-notify-exclusion REGRESSION (bug-fix test-alongside, anh Kiệt FDC UAT Zalo): em main fix `PurchaseEvaluationWorkflowService.LogTransitionAsync` (~:1083-1135) khối notify "Phiếu cần bạn duyệt" (`:1117` NotifyManyAsync) +filter `&& l.ApproverUserId != evaluation.DrafterUserId` → NGƯỜI TẠO phiếu KHÔNG nhận chuông cần-duyệt phiếu mình tạo (kể cả khi config làm approver 1 Cấp). NEW `PeApproverNotifyExcludesDrafterTests.cs` (Services ns) 3: (1)⭐CORE 1-Step-2-Level: Cấp1=approverL1(actor) Cấp2={drafter,otherL2}; actorL1(≠drafter) Approve→advance Cấp2→assert NeedApproval recipients EXCLUDE drafter INCLUDE otherL2 / (2) Cấp2=CHỈ drafter→advance→recipients EMPTY (lọc xong rỗng→NotifyMany ko gọi) / (3) SANITY terminal DaDuyet: drafter VẪN nhận chuông KẾT-QUẢ "đã duyệt" (NotifyAsync block :1060 untouched) + ko NeedApproval (chống over-broad misfix). ⚠️**KEY: actor-exclusion (`!= actorUserId`) KHÔNG che bug** vì actor=approver-Cấp1, drafter=Cấp2 → identity riêng. **RED-on-old-code EMPIRICALLY PROVEN** (git stash prod file→rebuild→Test1+2 FAIL "recipients contains drafter", Test3 PASS; stash pop restore). Spy=`CapturingNotificationService` (records NotifyAsync/NotifyManyAsync recipients+title, KHÁC NoOpNotificationService nuốt) — DI ctor nhận INotificationService trực tiếp, ko cần WebApplicationFactory. Harness mirror PeCcmThresholdFinalizeTests (IdentityFixture + SQLite + SeedWorkflowAsync). Test3 terminal cần `approvedPriceAmount`+source (Mig 54 ApplyApprovedPriceOnFinalize buộc human chọn giá). ⚠️PROD-FIX uncommitted working-tree (em main) — `git diff -- file` empty/grep false-neg là artifact em main edit ĐỒNG-THỜI (gotcha #68-like + Bash-grep-VN-text), authoritative=Read-tool. No prod touch (chỉ verify qua stash, restore exact). Tag [s86, pe-drafter-notify-exclusion, notify-recipients-spy, capturing-notification-fake, actor-exclusion-not-cover-bug, red-proven-via-stash, mirror-ccm-harness, test-alongside-bugfix]. Prev 374 ← S85b +3 PE UpdateDraft WF-preserve ROOT-FIX bug-class S42 (test-after BUG-FIX disc): em main fix `UpdatePurchaseEvaluationDraftCommandHandler` (PurchaseEvaluationFeatures.cs:285) ABSOLUTE-SET→NULL-SAFE `if(request.ApprovalWorkflowId is not null) entity.ApprovalWorkflowId=...` (form Sửa header KHÔNG gửi WF → trước = CLEAR WF → kết submit "chưa chọn quy trình" whack-a-mole). NEW `PeUpdateDraftWorkflowPreserveTests.cs` 3: (1) PRESERVE phiếu Nháp WF=W + UpdateDraft ApprovalWorkflowId=null (field text có giá trị) → entity GIỮ W (reproduce: trước-fix null, sau-fix W) + sanity TenGoiThau/DiaDiem vẫn update / (2) CHANGE WF=W1 + gửi W2 (≠null) → đổi W2 (null-safe chỉ chặn null KHÔNG chặn đổi) / (3) bonus mirror BudgetPeriodAmount null-safe S61 (cùng pattern bug-class S42). ⚠️GOTCHA: `PurchaseEvaluation.ApprovalWorkflowId` FK Restrict thật → PE seed PHẢI ref WF row thật (random Guid → SQLite Error 19 FK fail; lần đầu RED). Fix: `SeedWorkflowAsync(db, code)` ApplicableType=DuyetNcc khớp PE Type (CHANGE case validate block line 253-266 cũng cần WF thật); PRESERVE case request-null short-circuit validate NHƯNG seed vẫn cần FK hợp lệ. IdentityFixture (Changelog Add). No prod bug — code đúng spec sau fix. Tag [s85b, pe-updatedraft, wf-preserve, root-fix-bug-class-s42, null-safe-guard, absolute-set→null-safe, approvalworkflowid-fk-restrict-seed-real, mirror-budget-nullsafe, test-after-bugfix]. Prev 371 (S85 +5 PE urgent DRAFTER-self-unset) ← S85 +5 PE urgent DRAFTER-self-unset (test-after authz, mirror PeUrgentToggle): em main S85 restructure handler `PurchaseEvaluationUrgentFeatures.cs` thêm nhánh UNSET cho NGƯỜI KHAI (`isDrafter = currentUser.UserId == entity.DrafterUserId`) → drafter GỠ clear CẢ 2 cờ (giống Admin), SET vẫn role-only (drafter KHÔNG tự gắn). EXTEND `PeUrgentToggleAuthzTests.cs` 11→16: FakeCurrentUser.UserId→`init` (settable) + SeedPeAsync +`drafterUserId` opt param. NEW 5: (10) Drafter role-thường UNSET→clear cả 2 no-Forbidden (pre-seed ByPro để prove CLEAR) / (11) Finance(non-drafter non-TP) UNSET→Forbidden`*GỠ*`+no-mutate cả 2 / (12) Drafter SET→Forbidden`*đánh dấu*` (drafter chỉ gỡ, không gắn) / (13) Drafter UNSET chỉ-ByCcm-bật→clear cả 2 (drafter-clear độc lập cờ-đang-bật) / (14) Drafter+Procurement-no-DeptManager UNSET→clear CẢ 2 qua nhánh isDrafter (đối chứng test6 TP-PRO chỉ clear cờ-vai — isDrafter ưu tiên/độc lập isProTp). 11 test S77c cũ vẫn xanh (không động nhánh drafter). No prod bug — code đúng spec S85. Prev baseline 366 (task-stated, S78-S84 thêm test MEMORY chưa ghi). Tag [s85, pe-urgent, drafter-self-unset, clear-both, set-role-only, isdrafter-priority-over-isProTp, no-mutate-on-forbidden, test-after] ← S77c CORRECTION urgent-toggle SYMMETRIC→ASYMMETRIC (S77b symmetric tests RED vs prod): anh chốt FINAL gate BẤT-ĐỐI-XỨNG theo `request.IsUrgent` — em main đã sửa prod `PurchaseEvaluationUrgentFeatures.cs:49-55` `isPro = IsUrgent ? hasPro : (hasPro && isDeptManager)` + `isCcm = IsUrgent ? hasCcm : (hasCcm && isDeptManager)`; Forbidden-msg nhánh-theo-IsUrgent (SET `*đánh dấu phiếu gấp*` / UNSET `*GỠ cờ gấp*`). REWRITE `PeUrgentToggleAuthzTests.cs` 12→11: **SET = role-chức-năng-đủ** (plain Procurement SET→OK ByPro / plain CostControl SET→OK ByCcm / Admin SET→both) — bỏ hết 2b/2c/2d DeptManager-alone-restriction của S77b (không còn áp cho SET); **UNSET = role AND DeptManager** (plain-PRO UNSET→Forbidden`*GỠ*`+no-mutate flag-still-true pre-seed urgent / plain-CCM UNSET→Forbidden / PRO+DM UNSET→clear-ByPro CCM-preserved / CCM+DM UNSET→clear-ByCcm PRO-preserved / Admin UNSET→both-clear); 9a Drafter-SET→Forbidden`*đánh dấu*` / 9b Finance-SET→Forbidden / 9c UnknownPe→NotFound TRƯỚC authz. No prod touch — prod ĐÃ asymmetric, test theo CODE (S34). Prev S77b 354 (symmetric, superseded) ← S77 +7 PE Mig 57 suggested-price NOTE (test-after, mirror S74 §4b CcmNote): `UpdatePeSuggestedPricePro/CcmCommand` +`Note` (string?) absolute-set→`Pro/CcmSuggestedPriceNote` (overwrite always incl null=clear); rides SAME role-gate as price (PRO/Admin · CCM/Admin) fail-closed TRƯỚC side-effect; validator `Note.MaximumLength(1000)`. Handler ctor `(db, ICurrentUser)` 2-dep + record arity UNCHANGED on existing args (Note=null default → 5 existing `new UpdatePeSuggestedPrice*Command(...)` call-sites still compile, KHÔNG cần vá). NEW 7 → `PeSuggestedPriceSetterAuthzTests.cs`: PRO-note+Min/Max persist (note KHÔNG clobber price) / Admin-PRO-note / CCM-note+price persist / null-clear PRO (pre-seed note→null=cleared) / null-clear CCM / non-priv→Forbidden+note&price unchanged (no partial-write) / PRO↔CCM note independent + validator 1001-char invalid·1000 valid. No prod bug — code đúng spec (note absolute-set + rides price gate, intentional). Prev 344 ← S76 +5 PE Mig 56 PRO-column-split (spec-change+compile-fix arity): UpdatePeBudgetProCommand record 3→4 param `(PeId, ProInitialAmount, ProAdjustmentAmount, ProNote)`; handler set ProInitial+ProAdjust absolute-set (KHÔNG còn ProEstimateAmount). Vá 5 PRO call-site `new UpdatePeBudgetProCommand(...)` thêm arg ProAdjust + rename assert `.ProEstimateAmount`→`.ProInitialAmount` (handler giờ set ProInitial). Capability `FullAmount`: hasCcm?CCM:proFull(ProInitial+ProAdjust); FullIsEstimate=!hasCcm&&hasPro; DTO +2 field cuối ProInitial/ProAdjust. NEW 5: PRO set both incl ProAdjust ÂM persist / validator ProInitial<0 invalid + ProAdjust ÂM valid (mirror CCM no-sign-constraint) / full=proFull(100+50=150)+DTO-surface / full=proFull ÂM-adjust(100-30=70 no-clamp). Full-fallback seed `ProEstimateAmount=500`→`ProInitialAmount=500` (capability đọc ProInitial). **Handler ctor `(db, ICurrentUser)` 2-dep UNCHANGED**. ProEstimateAmount=LEGACY (Mig 56 backfill→ProInitial, FE bỏ). No prod bug. Prev 339 ← S74 +5 PE Mig 55 CcmNote (compile-fix arity + test-after): UpdatePeBudgetCcmCommand record +`string? CcmNote` (4 param) → fix 3 existing `new UpdatePeBudgetCcmCommand(...)` thêm `null` arg4 (PeWorkItemBudgetTests.cs line 388/407/421); NEW 5 test mirror ProNote: CostControl-set / Admin-set / null=clear-absolute-set (pre-seed prove not vốn-dĩ-null) / Procurement→Forbidden no-mutate (fail-closed role-gate TRƯỚC EnsureTrackedAsync+side-effect, AsNoTracking re-read) / Initial+Adjustment+CcmNote all-persist-together. **Handler ctor `(db, ICurrentUser)` 2-dep UNCHANGED** — chỉ COMMAND record +CcmNote. No prod bug. Prev 334 ← S72 +28 PE Mig 54 (spec-change+test-after): PeCcmThresholdFinalizeTests 6→11 (AUTO→OPT-IN finalizeByCcmDelegation) + NEW PeApprovedPriceFinalizeTests 10 (giá chốt) + NEW PeSuggestedPriceSetterAuthzTests 13 (2 setter role-gate). Prev 306 ← S69b +14 PE 2 feature anh Kiệt FDC (test-before-merge SECURITY/FINANCIAL): `PeCcmThresholdFinalizeTests.cs` (5, Services ns, value-threshold CCM-finalize ApproveV2Async) + `PeUrgentToggleAuthzTests.cs` (9, Application ns, urgent-toggle role authz). Prev 292 ← S69 +6 Office golive permission-seed (`OfficeModulePermissionSeedTests.cs`, test-after, mirror HrmProfilePermissionSeedTests S67). Prev 286 ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠️spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget −14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60). > Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture (đếm User.DepartmentId active). Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build) diff --git a/docs/STATUS.md b/docs/STATUS.md index 543b97c..3a50e4b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -17,14 +17,14 @@ | API endpoints | **~253** | +1 S54 `GET /it-tickets/assignable-staff` (capability endpoint); +3 S52 (attendances/report + report/excel + it-tickets/{id}/assign) | | FE pages | **68** | unchanged S54 (ItTicketsPage reassign = in-place 2 app); +1 S52 AttendanceReportPage | | Menu keys | **54** | +1 S69 `Off_Dashboard` (landing Văn phòng số). Prev re-ground S61 (`MenuKeys.cs` const — Mig 50 gỡ 4 `Bg_*` Budget menu cũ). Prev 57 (S58) | -| Tests | **356 PASS** | 45 Domain + 311 Infra · 0 fail / 0 skip · **S83 +2** (PE require-workflow: `PeWorkItemGuardTests` ApprovalWorkflowId `NotEmpty` ×2 validator + submit-guard rewrite test 7/13 `PeSubmitGuardAndBypass` V1-submit-deprecated, no-count-change) · **S77 +10** (+7 `PeSuggestedPriceSetterAuthzTests` ghi chú PRO/CCM Mig 57 absolute-set/role-gate/null-clear/max-1000 · +3 `PeUrgentToggleAuthzTests` rewrite cờ-gấp **BẤT ĐỐI XỨNG** SET=function role / UNSET=function+DeptManager, plain-PRO-unset Forbidden) · **S76 +5** (`PeWorkItemBudgetTests` PRO split: set ProInitial+ProAdjust gồm âm · validator ProInitial≥0/ProAdjust-cho-âm · full=proFull khi CCM empty · full=CCM khi CCM present) · **S74 +5** (`PeWorkItemBudgetTests` §4b CcmNote: set CCM/Admin · null-clear absolute-set · non-priv Forbidden+no-mutate · all-3-persist) · **S73 +28** (PE opt-in finalize spec 6→11 + 10 giá-chốt `PeApprovedPriceFinalizeTests` + 13 setter-authz `PeSuggestedPriceSetterAuthzTests`) · **S69 +20** (Office-golive OfficeModulePermissionSeed 6 + PE PeCcmThresholdFinalize 5 + PeUrgentToggleAuthz 9) · **S67 +23 HRM test-after** (DepartmentTreeTests 8 cycle-guard/rollup + PeHoSoLinkTests 9 absolute-set + HrmProfilePermissionSeedTests 6 reflection-seed) → 286 · S61 +22 `PeWorkItemBudgetTests` −14 `BudgetPolicyTests` −1 → 263 · S60 +14 `PeSubmitGuardAndBypassTests` +2 spec → 256 (Domain 58→45 do drop Budget module tests) | -| Gotchas | **71** | +1 S78 **#71** (thêm enum value vào entity DÙNG-CHUNG → pollute UI/guard phân-loại theo PROXY-predicate `supplierId===null` thay vì enum tường minh; file-khi-duyệt purpose=5 lẫn "Bảng so sánh" + false-pass submit-guard → grep mọi field-proxy predicate loại value mới + build-verify TỪNG app; em-main grep no-leak bắt trước deploy). +1 S76 **#70** (FE absolute-set echo field anh-em từ server-snapshot + `invalidate()` fire-and-forget → cửa-sổ stale-echo mất-dữ-liệu khi lưu 2 ô liên-tiếp; guard `useIsFetching` gate nút Lưu tới khi refetch land; reviewer workflow bắt, impl self-review sót). +1 S72 **#69** FE bundle-hash non-deterministic + `deploy.yml` rebuild-FE-unconditional → bundle rotate kể cả commit governance (SPA-fallback-200 trap; cicd Run #312). +2 S69: **#67** Tailwind v4 accent palette thiếu stop (teal/violet/amberx/greenx chỉ 50/100/500/600/700; dùng -300 → teal/violet rơi DEFAULT Tailwind sai-hệ, amberx/greenx drop hẳn — build PASS, phải soi dist CSS) · **#68** IDE TS diagnostic giữa background-agent/workflow = snapshot dở-dang (TS server bắn mỗi save), chỉ tin build SẠCH sau-cùng (2× session này: re-skin + PE-FE đều false-alarm). Prev +1 S68 **#66** Tailwind v4 rule element thô `h1-h4{color:#0b1220}` viết NGOÀI `@layer` thắng utility `text-white` → heading render đen; fix ĐIỂM `text-white!` (important, grep dist confirm), KHÔNG move `@layer` (load-bearing ~30+ heading toàn app). Prev +1 S65 **#65** build csproj con (vd `SolutionErp.Api.csproj`) ≠ `dotnet build SolutionErp.slnx` (gồm tests) → miss test-compile khi đổi chữ ký record command (CreateDepartmentCommand +ParentId) → CI CS7036 FAIL-gated Run #291 (deploy chặn, prod nguyên — test-gate làm đúng việc). Fix: build full slnx trước push BE signature-change. Prev +2 S61: **#63** EF scaffold tự sinh `RenameColumn` SAI-semantics khi drop+add cùng type (test xanh không bắt — SQLite EnsureCreated không replay migration) · **#64** `dotnet ef database update` áp Design-DB 0-rows ≠ Dev-DB → data-migrate `Sql()` chạy thật lần đầu trên prod. Prev +2 S59 (#61 sqlcmd `-f 65001` · #62 rename natural-key UPDATE trước deploy) | +| Tests | **377 PASS** | 45 Domain + 332 Infra · 0 fail / 0 skip · **S86 +3** (`PeApproverNotifyExcludesDrafterTests` — notify "cần duyệt" loại NGƯỜI TẠO phiếu: drafter-excluded · co-approver-vẫn-nhận · drafter-vẫn-nhận-chuông-kết-quả) · **S85 +18** (PE CV PRO C5 hủy/đổi NCC winner +10 · urgent drafter-self-unset +5 · workflow-preserve bug-class S42 +3 `PeUpdateDraftWorkflowPreserveTests`) · **S83 +2** (PE require-workflow: `PeWorkItemGuardTests` ApprovalWorkflowId `NotEmpty` ×2 validator + submit-guard rewrite test 7/13 `PeSubmitGuardAndBypass` V1-submit-deprecated, no-count-change) · **S77 +10** (+7 `PeSuggestedPriceSetterAuthzTests` ghi chú PRO/CCM Mig 57 absolute-set/role-gate/null-clear/max-1000 · +3 `PeUrgentToggleAuthzTests` rewrite cờ-gấp **BẤT ĐỐI XỨNG** SET=function role / UNSET=function+DeptManager, plain-PRO-unset Forbidden) · **S76 +5** (`PeWorkItemBudgetTests` PRO split: set ProInitial+ProAdjust gồm âm · validator ProInitial≥0/ProAdjust-cho-âm · full=proFull khi CCM empty · full=CCM khi CCM present) · **S74 +5** (`PeWorkItemBudgetTests` §4b CcmNote: set CCM/Admin · null-clear absolute-set · non-priv Forbidden+no-mutate · all-3-persist) · **S73 +28** (PE opt-in finalize spec 6→11 + 10 giá-chốt `PeApprovedPriceFinalizeTests` + 13 setter-authz `PeSuggestedPriceSetterAuthzTests`) · **S69 +20** (Office-golive OfficeModulePermissionSeed 6 + PE PeCcmThresholdFinalize 5 + PeUrgentToggleAuthz 9) · **S67 +23 HRM test-after** (DepartmentTreeTests 8 cycle-guard/rollup + PeHoSoLinkTests 9 absolute-set + HrmProfilePermissionSeedTests 6 reflection-seed) → 286 · S61 +22 `PeWorkItemBudgetTests` −14 `BudgetPolicyTests` −1 → 263 · S60 +14 `PeSubmitGuardAndBypassTests` +2 spec → 256 (Domain 58→45 do drop Budget module tests) | +| Gotchas | **73** | +2 S85 (**#72** lệch giờ +7h — DateTime lưu UTC nhưng serialize thiếu 'Z' → FE `new Date()`=local → `UtcDateTimeJsonConverter` global ép UTC-with-Z · **#73** UpdateDraft absolute-set CLEAR field SÓT khỏi pattern null-safe = bug-class S42 → guard `if (request.X is not null)`). +1 S78 **#71** (thêm enum value vào entity DÙNG-CHUNG → pollute UI/guard phân-loại theo PROXY-predicate `supplierId===null` thay vì enum tường minh; file-khi-duyệt purpose=5 lẫn "Bảng so sánh" + false-pass submit-guard → grep mọi field-proxy predicate loại value mới + build-verify TỪNG app; em-main grep no-leak bắt trước deploy). +1 S76 **#70** (FE absolute-set echo field anh-em từ server-snapshot + `invalidate()` fire-and-forget → cửa-sổ stale-echo mất-dữ-liệu khi lưu 2 ô liên-tiếp; guard `useIsFetching` gate nút Lưu tới khi refetch land; reviewer workflow bắt, impl self-review sót). +1 S72 **#69** FE bundle-hash non-deterministic + `deploy.yml` rebuild-FE-unconditional → bundle rotate kể cả commit governance (SPA-fallback-200 trap; cicd Run #312). +2 S69: **#67** Tailwind v4 accent palette thiếu stop (teal/violet/amberx/greenx chỉ 50/100/500/600/700; dùng -300 → teal/violet rơi DEFAULT Tailwind sai-hệ, amberx/greenx drop hẳn — build PASS, phải soi dist CSS) · **#68** IDE TS diagnostic giữa background-agent/workflow = snapshot dở-dang (TS server bắn mỗi save), chỉ tin build SẠCH sau-cùng (2× session này: re-skin + PE-FE đều false-alarm). Prev +1 S68 **#66** Tailwind v4 rule element thô `h1-h4{color:#0b1220}` viết NGOÀI `@layer` thắng utility `text-white` → heading render đen; fix ĐIỂM `text-white!` (important, grep dist confirm), KHÔNG move `@layer` (load-bearing ~30+ heading toàn app). Prev +1 S65 **#65** build csproj con (vd `SolutionErp.Api.csproj`) ≠ `dotnet build SolutionErp.slnx` (gồm tests) → miss test-compile khi đổi chữ ký record command (CreateDepartmentCommand +ParentId) → CI CS7036 FAIL-gated Run #291 (deploy chặn, prod nguyên — test-gate làm đúng việc). Fix: build full slnx trước push BE signature-change. Prev +2 S61: **#63** EF scaffold tự sinh `RenameColumn` SAI-semantics khi drop+add cùng type (test xanh không bắt — SQLite EnsureCreated không replay migration) · **#64** `dotnet ef database update` áp Design-DB 0-rows ≠ Dev-DB → data-migrate `Sql()` chạy thật lần đầu trên prod. Prev +2 S59 (#61 sqlcmd `-f 65001` · #62 rename natural-key UPDATE trước deploy) | | User memory | **31** | +S84 `feedback_derived_flush_execution` (derived→canonical flush = em-main execution KHÔNG gate user-confirm; monitor INFORM-only ≠ cần-anh-duyệt; chỉ ĐỔI-con-số/policy = quyền anh). +S79 `feedback_canonical_spec_over_broadcast` (adap pull canonical-command/§P đừng phỏng-theo broadcast + report-before-stamp dogfood + residual own-diary OK). +S75 `feedback_harness11_engine` (engine governance tự-bảo-trì + gotcha #30-via-Edit + multi-workflow-verify + git-add-specific lesson). re-ground S71 (H1 disk-count: 25 `feedback_*` + 3 `project_*`; STATUS "21" stale từ S64). +S71 `feedback_harness10_run_trace` (run-trace + 3 lesson). Prev +1 S64 `feedback_harness7_outward_writing_quality`; re-grounded S54 base 19 | | Skills | 6 | 3 domain + 3 ops | | Sub-agents | **11** | **all-inherit top-tier (Harness-8 S66 06-16 — thay thế two-tier H4):** em main Fable 5 (1M) Max (fallback Opus 4.8 1M, Fable suspended H5) · **cả 11 sub `model: inherit`** (7 demoted `claude-opus-4-8` flip S66; SE không có helper/gopher rẻ → cả 11 lên top-tier) · effort Max. 9 product/quality + 2 monitor INFORM-only. Nấc H8 = **executed-file VERIFIED-pending-restart**. Prev two-tier H4 runtime-verified S57bis (spawn-test 2 chiều). | | RAG chunks | **2429** | re-verify S80 (`list_projects` — alive, +6 vs S63's 2423). Stale `last_indexed 05-29` (S42-S62 via store_memory stopgap; full re-index = AI_INFRA op cần VOYAGE_API_KEY). | -**Bundle hash live (prod):** admin **`CsJetgZH`** · user **`BVS0ApIm`** (S78 — **Run #330** `7886fd0` PE attach-file khi duyệt; bundle ROTATE từ S77 `BqKD3Y23`/`Cn-i349D` dù NO migration enum-only — gotcha #69 non-determ rebuild; cicd ship-proof byte-stable + fake-hash control). **Prev S77** — admin **`BqKD3Y23`** · user **`Cn-i349D`** (S77 — **Run #329** `e823694` banner Trả-lại; cuối chuỗi 10 deploy #320→#329 cùng ngày, cicd PASS each: #320 pill `DsSg6RRz`-era → #321 focus → #322 revert → #323 Mig57+notes `CPm4LTqm`/`BWJUAqEI` → #324 số-âm-đỏ `C6fx-0ea`/`N3sW4Div` → #325 indent `BhnNMucS`/`B1VebpXc` → #326 cờ-gấp-asymmetric `DwXqn37C`/`COXMCv7E` → #327 decouple `B0gboSAg`/`DbDg7pM-` → #328 notify (BE-only, FE frozen) → #329 banner **`BqKD3Y23`**/**`Cn-i349D`**). **Prev S76** — admin **`jOqxW4-p`** · user **`DbsznVvR`** (S76 — **Run #319** `21d1f4e` bảng lưới `` ngân sách viền-ô; chuỗi S76 cùng ngày: #318 `e33481e` feature ma-trận-3-cột + badge quyền-NS `BhFDF9IJ`/`BAkuRl3C` → #319 **`jOqxW4-p`**/**`DbsznVvR`**, cicd PASS each, smoke 3×200, Mig 56 applied prod #318). **Prev S74** — admin **`BYF5vIMJ`** · user **`CB-tiRxd`** (S74 — **Run #315** `8655ebf` Mig 55 CcmNote, cicd PASS ~4m54s: Mig 55 applied prod CcmNote nvarchar(1000) nullable · sys.tables 88 · smoke 4×200 · 0 regression). **Prev S73** — admin **`Bv3jUCNo`** · user **`BWlMBQz6`** (Run #314 `6aa4dcb` FE empty-candidates guard fix; chuỗi 2 deploy: #313 `1d86abc` feature `OlNyG9OD`/`DSzSLVtL` → #314 **`Bv3jUCNo`**/**`BWlMBQz6`**, cicd PASS each). **Prev S72** — admin **`fc_xkNpJ`** · user **`DP-tBcg0`** (Run #312 governance commit `18fced6`; bundle ROTATE do **non-deterministic hash + deploy rebuild-FE-unconditional** (gotcha #69) — 0 FE source change, prod state/DB GIỮ NGUYÊN). **Prev S69** — admin **`BgNCjwsG`** · user **`CBvh0vtf`** (Run #308 PE cờ gấp + ngưỡng CCM; chuỗi 4 deploy session: #305 Văn phòng số foundation → #306 re-skin 10 page → #307 Office golive (BE-only) `Wt54PHYl`/`B99fMU6X` → #308 **`BgNCjwsG`**/**`CBvh0vtf`**). **Prev S68** — admin **`CNUv1jxY`** · user **`CpOskeS1`** (S68 — Run #304 `37752eb` fix tên đen→trắng; chuỗi #303 `6983609` `D532XZKG`/`CuFaBoWt` → #304 **`CNUv1jxY`**/**`CpOskeS1`**, cicd PASS each). **Prev S67** — admin `CcrZqfht`/user `DniDFUB_` (S67 — 6 deploy Run #297→#302; admin rotate chuỗi #298`xkSz9BfE`→#300`PxiZQkaw`→#301`I1fpLeYw`→#302**`CcrZqfht`** · user #297`BumgrwCJ`→#300`B36hGoKd`→#301`DrQYkzh0`→#302**`DniDFUB_`**; #299 tests BE-only cả 2 frozen, cicd PASS each). **Prev S65** — admin `BDwV5d0X`/user `DbVv6rsf` (Run #293-295). Prev S62 admin `0xKYGhhf`/user `C81ZdG9G` (Run #286). **Prev S59:** admin `B1DtNT9C` · user `D6uF3Mln` (Run **#282** `792c030` FINAL — bỏ nút Thêm hạng mục; ships kèm `80b64dd` gỡ Điều khoản TT, #281 cancelled-benign ancestor-verified). Chuỗi S59 cùng ngày: #280 `69997da` (`BKy_8OO9`/`XcZ6PRyA`, ships kèm `f21c55d` table-fixed #279-cancelled) · #278 `9c330d2` self-approve+quick-add-NCC (`BSh2fG2X`/`D22KfpPc`, authz probe 4/4) · #277 `faed59f` SearchableSelect (`ex7Tc92G`/`DzUeSk96`) · #276 `c869d26` rename 71 PMH (`BBA0KSWu`/`DzdTI18G`) · #275 `bbd1554` dọn demo WorkItems (FROZEN BE-only) · #274 `0eafcd3` tree 4 tầng (`DuU7OTym`/`DWyeTzf3`) · #273 `56882ac` wipe + tree v1 (`R9uGRxvw`/`DikfX1RD`). Prev S58: Run #386 `3ebaf84` admin `DMm9rtNA`/user `BUkOMn_Y` (chi tiết session log S58). +**Bundle hash live (prod):** admin **`aKuNip19`** · user **`BUsL0HeQ`** (S85 — **Run #341** `49ac086` lệch-giờ TZ serialize UTC-with-Z; FE frozen #339+#341 BE-only. Chuỗi ~10 deploy #334→#341 buổi UAT anh Kiệt CV PRO: #334 `5a61254` batch CV PRO 6-fix → #335-#338 6 vòng UAT → #339 `1263c0d` workflow-preserve bug-class S42 → #341 `49ac086` TZ; #342 `d4f6021` docs+cicd BE-frozen, bundle giữ-nguyên gotcha #69-REFINE). **Prev S78** — admin **`CsJetgZH`** · user **`BVS0ApIm`** (S78 — **Run #330** `7886fd0` PE attach-file khi duyệt; bundle ROTATE từ S77 `BqKD3Y23`/`Cn-i349D` dù NO migration enum-only — gotcha #69 non-determ rebuild; cicd ship-proof byte-stable + fake-hash control). **Prev S77** — admin **`BqKD3Y23`** · user **`Cn-i349D`** (S77 — **Run #329** `e823694` banner Trả-lại; cuối chuỗi 10 deploy #320→#329 cùng ngày, cicd PASS each: #320 pill `DsSg6RRz`-era → #321 focus → #322 revert → #323 Mig57+notes `CPm4LTqm`/`BWJUAqEI` → #324 số-âm-đỏ `C6fx-0ea`/`N3sW4Div` → #325 indent `BhnNMucS`/`B1VebpXc` → #326 cờ-gấp-asymmetric `DwXqn37C`/`COXMCv7E` → #327 decouple `B0gboSAg`/`DbDg7pM-` → #328 notify (BE-only, FE frozen) → #329 banner **`BqKD3Y23`**/**`Cn-i349D`**). **Prev S76** — admin **`jOqxW4-p`** · user **`DbsznVvR`** (S76 — **Run #319** `21d1f4e` bảng lưới `
` ngân sách viền-ô; chuỗi S76 cùng ngày: #318 `e33481e` feature ma-trận-3-cột + badge quyền-NS `BhFDF9IJ`/`BAkuRl3C` → #319 **`jOqxW4-p`**/**`DbsznVvR`**, cicd PASS each, smoke 3×200, Mig 56 applied prod #318). **Prev S74** — admin **`BYF5vIMJ`** · user **`CB-tiRxd`** (S74 — **Run #315** `8655ebf` Mig 55 CcmNote, cicd PASS ~4m54s: Mig 55 applied prod CcmNote nvarchar(1000) nullable · sys.tables 88 · smoke 4×200 · 0 regression). **Prev S73** — admin **`Bv3jUCNo`** · user **`BWlMBQz6`** (Run #314 `6aa4dcb` FE empty-candidates guard fix; chuỗi 2 deploy: #313 `1d86abc` feature `OlNyG9OD`/`DSzSLVtL` → #314 **`Bv3jUCNo`**/**`BWlMBQz6`**, cicd PASS each). **Prev S72** — admin **`fc_xkNpJ`** · user **`DP-tBcg0`** (Run #312 governance commit `18fced6`; bundle ROTATE do **non-deterministic hash + deploy rebuild-FE-unconditional** (gotcha #69) — 0 FE source change, prod state/DB GIỮ NGUYÊN). **Prev S69** — admin **`BgNCjwsG`** · user **`CBvh0vtf`** (Run #308 PE cờ gấp + ngưỡng CCM; chuỗi 4 deploy session: #305 Văn phòng số foundation → #306 re-skin 10 page → #307 Office golive (BE-only) `Wt54PHYl`/`B99fMU6X` → #308 **`BgNCjwsG`**/**`CBvh0vtf`**). **Prev S68** — admin **`CNUv1jxY`** · user **`CpOskeS1`** (S68 — Run #304 `37752eb` fix tên đen→trắng; chuỗi #303 `6983609` `D532XZKG`/`CuFaBoWt` → #304 **`CNUv1jxY`**/**`CpOskeS1`**, cicd PASS each). **Prev S67** — admin `CcrZqfht`/user `DniDFUB_` (S67 — 6 deploy Run #297→#302; admin rotate chuỗi #298`xkSz9BfE`→#300`PxiZQkaw`→#301`I1fpLeYw`→#302**`CcrZqfht`** · user #297`BumgrwCJ`→#300`B36hGoKd`→#301`DrQYkzh0`→#302**`DniDFUB_`**; #299 tests BE-only cả 2 frozen, cicd PASS each). **Prev S65** — admin `BDwV5d0X`/user `DbVv6rsf` (Run #293-295). Prev S62 admin `0xKYGhhf`/user `C81ZdG9G` (Run #286). **Prev S59:** admin `B1DtNT9C` · user `D6uF3Mln` (Run **#282** `792c030` FINAL — bỏ nút Thêm hạng mục; ships kèm `80b64dd` gỡ Điều khoản TT, #281 cancelled-benign ancestor-verified). Chuỗi S59 cùng ngày: #280 `69997da` (`BKy_8OO9`/`XcZ6PRyA`, ships kèm `f21c55d` table-fixed #279-cancelled) · #278 `9c330d2` self-approve+quick-add-NCC (`BSh2fG2X`/`D22KfpPc`, authz probe 4/4) · #277 `faed59f` SearchableSelect (`ex7Tc92G`/`DzUeSk96`) · #276 `c869d26` rename 71 PMH (`BBA0KSWu`/`DzdTI18G`) · #275 `bbd1554` dọn demo WorkItems (FROZEN BE-only) · #274 `0eafcd3` tree 4 tầng (`DuU7OTym`/`DWyeTzf3`) · #273 `56882ac` wipe + tree v1 (`R9uGRxvw`/`DikfX1RD`). Prev S58: Run #386 `3ebaf84` admin `DMm9rtNA`/user `BUkOMn_Y` (chi tiết session log S58). **Phase:** ✅ Phase 10 COMPLETE · ✅ **Phase 11 product backlog ĐÓNG TRỌN** · ✅ **Văn phòng số (E-Office) golive S69** (foundation PURO + re-skin 10 page + public read+create 16-key allow-list mọi role) prod-verified · ✅ **PE cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng (Mig 53 S69)** prod-verified · ✅ **PE ngân sách MA TRẬN 3 cột Dự án/PRO/CCM (bảng lưới `
`) + badge quyền-NS theo role trong Workflow Designer & flow Duyệt NCC (Mig 56 S76)** prod-verified · ✅ **PE UX batch S77 (10 deploy)** — cờ GẤP pill mọi danh sách+inbox · focus/inline+overlay decouple+"Xem mở rộng" · ô ghi chú giá đề xuất PRO/CCM (Mig 57) · số phân cách + số-âm-đỏ-ngoặc + indent mục con · cờ gấp GẮN=NV/GỠ=Trưởng phòng · chuông báo người duyệt · banner Trả-lại — prod-verified · ✅ **PE đính kèm file khi DUYỆT (S78, Run #330)** — người duyệt tải file lên lúc duyệt thay vì Trả lại; migration-free reuse-enum `ApprovalAttachment=5` — prod-verified · 🚫 Phase 9 Ops blocked (anh main coordinate — anh dừng). > ⚠️ **Count drift fixed S40:** endpoints ~223→**211**, FE pages 53→**65**, menu keys 85→**~53**. Tables **84 confirmed correct** (DbSet 77 + Identity 7). 3 số "khó fake" (mig/gotcha/git) luôn đúng. Cause: số "incremented mỗi session" over/under-count optimistic — re-ground định kỳ. diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 1f8e46d..4d7b9f2 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -1105,7 +1105,13 @@ public class PurchaseEvaluationWorkflowService( var approverIds = stepsOrdered[notifyCsi].Levels .Where(l => l.Order == notifyLvl && l.ApproverUserId != Guid.Empty - && l.ApproverUserId != actorUserId) + && l.ApproverUserId != actorUserId + // UAT S86 (anh Kiệt Zalo 16:05): người TẠO phiếu KHÔNG nhận + // chuông "cần bạn duyệt" cho phiếu mình tạo — kể cả khi được + // cấu hình làm approver 1 Cấp (họ đã thấy phiếu ở "Phiếu của + // tôi"). Chỉ approver-KHÁC-người-tạo nhận. Drafter VẪN nhận + // chuông KẾT-QUẢ (đã duyệt / trả lại) ở block trên (:1060). + && l.ApproverUserId != evaluation.DrafterUserId) .Select(l => l.ApproverUserId) .Distinct() .ToList(); diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverNotifyExcludesDrafterTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverNotifyExcludesDrafterTests.cs new file mode 100644 index 0000000..464054a --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverNotifyExcludesDrafterTests.cs @@ -0,0 +1,319 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Notifications; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE) +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Notifications; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// ===== BUG-FIX regression S86 (anh Kiệt FDC UAT realtime, Zalo 16:05) ===== +// Bug: ở Phiếu Duyệt NCC, NGƯỜI TẠO phiếu (drafter) nhận chuông "Phiếu cần bạn +// duyệt" cho chính phiếu mình tạo. Anh: "Các nhân viên ko cần thấy thông báo nhé, +// chỉ thông báo đúng người cần duyệt thôi." +// +// Root cause + fix (em-main, PurchaseEvaluationWorkflowService.LogTransitionAsync +// ~:1083-1135, khối notify "Phiếu cần bạn duyệt" :1117 — NotifyManyAsync): +// approverIds resolve từ workflow.Steps[CurrentWorkflowStepIndex] +// .Levels.Where(Order == CurrentApprovalLevelOrder), lọc +// ApproverUserId != Guid.Empty && != actorUserId. +// THIẾU lọc drafter → khi drafter cũng là approver 1 Cấp (hoặc phiếu advance TỚI +// Cấp đó, actor != drafter) → drafter nhận chuông cần-duyệt phiếu mình tạo. +// Fix: thêm `&& l.ApproverUserId != evaluation.DrafterUserId` vào filter. +// Block notify KẾT-QUẢ cho drafter (:1060 — "đã duyệt"/"trả lại") GIỮ NGUYÊN. +// +// Test theo CODE (S34): capturing INotificationService fake bắt recipients của +// NotifyManyAsync (khác NoOpNotificationService trong cùng folder — cái đó NUỐT). +// Notify path là best-effort try/catch trong service NHƯNG vì DI nhận +// INotificationService trực tiếp ở ctor → fake-capture là cách spy chuẩn, +// KHÔNG cần WebApplicationFactory. +// +// Harness mirror PeCcmThresholdFinalizeTests.cs cùng folder (IdentityFixture + +// SQLite + SeedWorkflowAsync). Dựng PE ở ChoDuyet (qua submit guard) + pin pointer +// Step/Level → drive 1 Approve để phiếu ADVANCE tới Cấp có drafter. +// +// ⭐ RED-on-old-code: nếu gỡ điều-kiện `!= DrafterUserId` → Test 1 + Test 2 FAIL +// (recipients sẽ chứa drafterId). actor-exclusion (!= actorUserId) KHÔNG che bug +// vì actor = approver Cấp-1 (KHÁC drafter); drafter đứng Cấp-2 → identity riêng. +public class PeApproverNotifyExcludesDrafterTests +{ + // Tiêu đề khối notify cần-duyệt (prod :1123). Lọc đúng notification cần assert + // (tránh nhầm với notify kết-quả drafter dùng tiêu đề khác). + private const string NeedApprovalTitlePrefix = "Phiếu cần bạn duyệt"; + + private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, + TestApplicationDbContext db, CapturingNotificationService notify) CreateService() + { + var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var clock = new FixedDateTime(new DateTime(2026, 6, 23, 0, 0, 0, DateTimeKind.Utc)); + var notify = new CapturingNotificationService(); + var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um); + return (svc, fix, db, notify); + } + + // PE đứng ở ChoDuyet tại pointer (stepIdx, levelOrder), pin V2 + drafter cụ thể. + private static PurchaseEvaluation BuildPeAtApprovalSlot( + Guid approvalWorkflowId, Guid drafterUserId, int stepIdx, int levelOrder, + string code = "PE-S86-001") + { + return new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = code, + TenGoiThau = "Test S86 — drafter KHÔNG nhận chuông cần-duyệt", + ProjectId = Guid.NewGuid(), + DrafterUserId = drafterUserId, + ApprovalWorkflowId = approvalWorkflowId, + CurrentWorkflowStepIndex = stepIdx, + CurrentApprovalLevelOrder = levelOrder, + SlaDeadline = new DateTime(2026, 6, 30, 0, 0, 0, DateTimeKind.Utc), + }; + } + + // Seed workflow V2: stepLevelApprovers[s][lvl] = mảng NV (OR-of-N) cho Cấp lvl+1 + // (Order 1-based) của Bước s (Order s+1). Cho phép nhiều NV/cấp để dựng case + // drafter + approver-khác cùng 1 Cấp. Return ApprovalWorkflow đã persist. + private static async Task SeedWorkflowAsync( + TestApplicationDbContext db, Guid[][][] stepLevelApprovers) + { + var wf = new ApprovalWorkflow + { + Code = "QT-S86-V2", + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + Name = "QT test S86 notify", + IsActive = true, + IsUserSelectable = true, + }; + for (int s = 0; s < stepLevelApprovers.Length; s++) + { + var step = new ApprovalWorkflowStep + { + ApprovalWorkflowId = wf.Id, + Order = s + 1, + Name = $"Bước {s + 1}", + }; + for (int lvl = 0; lvl < stepLevelApprovers[s].Length; lvl++) + { + foreach (var approverId in stepLevelApprovers[s][lvl]) + { + step.Levels.Add(new ApprovalWorkflowLevel + { + ApprovalWorkflowStepId = step.Id, + Order = lvl + 1, + Name = $"Cấp {lvl + 1}", + ApproverUserId = approverId, + }); + } + } + wf.Steps.Add(step); + } + db.ApprovalWorkflows.Add(wf); + await db.SaveChangesAsync(CancellationToken.None); + return wf; + } + + // approvedPrice* chỉ cần khi approve cấp cuối → terminal DaDuyet + // (ApplyApprovedPriceOnFinalize bắt human chọn 1 giá chốt, Mig 54). Advance + // trung gian (Test 1/2) KHÔNG cần → mặc định null. + private static Task ApproveAsync( + PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId, string[] roles, + decimal? approvedPriceAmount = null, string? approvedPriceSource = null) => + svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place (advance pointer) + actorUserId: actorUserId, + actorRoles: roles, + decision: ApprovalDecision.Approve, + comment: null, + approvedPriceAmount: approvedPriceAmount, + approvedPriceSource: approvedPriceSource, + ct: CancellationToken.None); + + // Gom recipients của MỌI call NotifyManyAsync có tiêu đề "Phiếu cần bạn duyệt". + private static List NeedApprovalRecipients(CapturingNotificationService notify) => + notify.ManyCalls + .Where(c => c.Title.StartsWith(NeedApprovalTitlePrefix, StringComparison.Ordinal)) + .SelectMany(c => c.Recipients) + .ToList(); + + // ===================================================================== + // 1. ⭐ CORE REGRESSION — phiếu ADVANCE tới Cấp mà drafter là 1 approver + // (cùng 1 approver-KHÁC). Actor = approver Cấp 1 (KHÔNG phải drafter). + // Approve → pointer sang Cấp 2 → notify Cấp-2 approvers. + // Assert: recipients KHÔNG chứa drafter NHƯNG CÓ approver-khác. + // RED nếu gỡ `!= DrafterUserId`: drafter lọt vào recipients. + // ===================================================================== + [Fact] + public async Task ApproveV2_AdvancesToLevelWhereDrafterIsApprover_NeedApprovalNotify_ExcludesDrafter_IncludesOther() + { + var (svc, fix, db, notify) = CreateService(); + using (fix) + { + // Cấp 1 (đang đứng): approverL1 — chính là actor sẽ duyệt (KHÁC drafter). + // Cấp 2 (advance tới): { drafter, otherApproverL2 }. + var drafter = (await fix.CreateUserAsync("drafter1@s86.test", "Người tạo", null, new[] { AppRoles.Procurement })).Id; + var approverL1 = (await fix.CreateUserAsync("apprL1@s86.test", "Approver Cấp 1", null, new[] { AppRoles.ProjectManager })).Id; + var otherApproverL2 = (await fix.CreateUserAsync("otherL2@s86.test", "Approver Cấp 2 khác", null, new[] { AppRoles.CostControl })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] // Bước 1 + { + new[] { approverL1 }, // Cấp 1 = approverL1 (actor) + new[] { drafter, otherApproverL2 }, // Cấp 2 = drafter + người khác + }, + }); + + // PE đứng Bước 1 (stepIdx 0) Cấp 1 — đến lượt approverL1. Drafter = chủ phiếu. + var pe = BuildPeAtApprovalSlot(wf.Id, drafter, stepIdx: 0, levelOrder: 1); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + // approverL1 (KHÔNG phải drafter) duyệt → advance Cấp 1→2. + await ApproveAsync(svc, pe, approverL1, new[] { AppRoles.ProjectManager }); + + // Sanity: phiếu đã advance tới Cấp 2 (pointer đúng nơi notify resolve). + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentWorkflowStepIndex.Should().Be(0); + pe.CurrentApprovalLevelOrder.Should().Be(2, "advance sang Cấp 2 cùng Bước 1"); + + var recipients = NeedApprovalRecipients(notify); + recipients.Should().NotBeEmpty( + "Cấp 2 có 1 approver-khác (otherApproverL2) → vẫn phát chuông cần-duyệt"); + recipients.Should().NotContain(drafter, + "⭐ S86: NGƯỜI TẠO phiếu KHÔNG nhận chuông 'cần bạn duyệt' dù được cấu hình làm approver Cấp 2 " + + "(RED nếu gỡ điều-kiện `!= DrafterUserId`)"); + recipients.Should().Contain(otherApproverL2, + "approver-khác (không phải người tạo) VẪN nhận chuông cần-duyệt — fix chỉ lọc drafter"); + } + } + + // ===================================================================== + // 2. Drafter là approver DUY NHẤT ở Cấp advance tới → KHÔNG phát chuông + // cần-duyệt nào cả (sau khi lọc drafter, approverIds rỗng → NotifyManyAsync + // không được gọi cho khối này). RED nếu gỡ lọc: 1 call chứa drafter. + // ===================================================================== + [Fact] + public async Task ApproveV2_AdvancesToLevelWhereDrafterIsSoleApprover_EmitsNoNeedApprovalNotify() + { + var (svc, fix, db, notify) = CreateService(); + using (fix) + { + var drafter = (await fix.CreateUserAsync("drafter2@s86.test", "Người tạo", null, new[] { AppRoles.Procurement })).Id; + var approverL1 = (await fix.CreateUserAsync("apprL1b@s86.test", "Approver Cấp 1", null, new[] { AppRoles.ProjectManager })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] // Bước 1 + { + new[] { approverL1 }, // Cấp 1 = approverL1 (actor) + new[] { drafter }, // Cấp 2 = CHỈ drafter + }, + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, drafter, stepIdx: 0, levelOrder: 1, code: "PE-S86-002"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveAsync(svc, pe, approverL1, new[] { AppRoles.ProjectManager }); + + pe.CurrentApprovalLevelOrder.Should().Be(2, "advance sang Cấp 2 (chỉ drafter)"); + + var recipients = NeedApprovalRecipients(notify); + recipients.Should().BeEmpty( + "Cấp 2 chỉ có drafter → sau khi lọc drafter, danh sách rỗng → KHÔNG phát chuông cần-duyệt " + + "(RED nếu gỡ `!= DrafterUserId`: drafter lọt vào)"); + recipients.Should().NotContain(drafter); + } + } + + // ===================================================================== + // 3. SANITY — fix KHÔNG over-broad: drafter VẪN nhận chuông KẾT-QUẢ khi phiếu + // về terminal DaDuyet (block :1060 NotifyAsync giữ nguyên). Bảo vệ chống + // misfix kiểu "drafter không bao giờ được notify". Actor = approver cấp cuối + // (KHÁC drafter) để qua nhánh `drafterId != actorUserId`. + // ===================================================================== + [Fact] + public async Task ApproveV2_TerminalDaDuyet_DrafterStillReceivesResultNotification() + { + var (svc, fix, db, notify) = CreateService(); + using (fix) + { + var drafter = (await fix.CreateUserAsync("drafter3@s86.test", "Người tạo", null, new[] { AppRoles.Procurement })).Id; + var approver = (await fix.CreateUserAsync("apprFinal@s86.test", "Approver cuối", null, new[] { AppRoles.Director })).Id; + + // Workflow 1 Bước, 1 Cấp = approver (slot cuối) → duyệt = terminal DaDuyet. + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { new[] { approver } }, // Bước 1 Cấp 1 = approver + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, drafter, stepIdx: 0, levelOrder: 1, code: "PE-S86-003"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + // approve slot cuối → terminal DaDuyet ⇒ Mig 54 buộc chọn 1 giá chốt. + await ApproveAsync(svc, pe, approver, new[] { AppRoles.Director }, + approvedPriceAmount: 500_000_000m, approvedPriceSource: "Ncc"); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "approve slot cuối → terminal"); + + // Drafter NHẬN chuông KẾT-QUẢ "đã duyệt" (NotifyAsync single — block :1060). + notify.SingleCalls.Should().Contain( + c => c.UserId == drafter && c.Title.Contains("đã duyệt"), + "drafter VẪN được báo kết-quả phiếu đã duyệt — fix S86 chỉ chặn chuông CẦN-DUYỆT, " + + "KHÔNG chặn chuông kết-quả"); + + // Đồng thời KHÔNG có chuông cần-duyệt nào (terminal, không còn cấp chờ). + NeedApprovalRecipients(notify).Should().BeEmpty( + "phiếu terminal → không advance tới cấp nào → không phát chuông cần-duyệt"); + } + } +} + +// ===== Capturing fake — bắt mọi NotifyAsync / NotifyManyAsync call ===== +// Khác NoOpNotificationService (nuốt im lặng) trong cùng folder: ghi lại recipients +// + title để assert. Service nhận INotificationService trực tiếp ở ctor → spy chuẩn. +internal sealed class CapturingNotificationService : INotificationService +{ + public sealed record SingleCall(Guid UserId, NotificationType Type, string Title); + public sealed record ManyCall(IReadOnlyList Recipients, NotificationType Type, string Title); + + public List SingleCalls { get; } = new(); + public List ManyCalls { get; } = new(); + + public Task NotifyAsync( + Guid userId, + NotificationType type, + string title, + string? description = null, + string? href = null, + Guid? refId = null, + CancellationToken ct = default) + { + SingleCalls.Add(new SingleCall(userId, type, title)); + return Task.CompletedTask; + } + + public Task NotifyManyAsync( + IEnumerable userIds, + NotificationType type, + string title, + string? description = null, + string? href = null, + Guid? refId = null, + CancellationToken ct = default) + { + ManyCalls.Add(new ManyCall(userIds.ToList(), type, title)); + return Task.CompletedTask; + } +}