[CLAUDE] PurchaseEvaluation: notify "cần duyệt" loại người tạo phiếu (anh Kiệt FDC UAT)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m57s

Người TẠO phiếu (drafter) không còn nhận chuông "Phiếu cần bạn duyệt" cho
phiếu mình tạo — kể cả khi được cấu hình làm approver ở một Cấp. Chỉ approver
KHÁC người tạo ở Cấp hiện tại mới nhận. Drafter vẫn nhận chuông KẾT-QUẢ
(đã duyệt / trả lại) như cũ (block :1060 giữ nguyên).

- PurchaseEvaluationWorkflowService.LogTransitionAsync: thêm filter
  `l.ApproverUserId != evaluation.DrafterUserId` (in-memory LINQ, nullable-safe).
- test-specialist: +3 regression PeApproverNotifyExcludesDrafterTests
  (drafter-excluded · co-approver-vẫn-nhận · result-notify-intact; RED-on-old
  đã chứng minh). Test 374 -> 377.
- docs/STATUS.md: re-ground canonical (S85-closeout sót: test 356->377,
  gotcha 71->73, bundle #341) + dòng S86 +3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-23 16:37:22 +07:00
parent de2d1fab64
commit ae4f106fd7
4 changed files with 330 additions and 5 deletions

File diff suppressed because one or more lines are too long

View File

@ -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 ). 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 ProInitial0/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 611 + 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 5845 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 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ữ 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 ProInitial0/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 611 + 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 5845 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 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ữ 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` (derivedcanonical 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-commandP đừ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 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` 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 `<table>` 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 đentrắ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` 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 `<table>` 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 đentrắ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 `<table>`) + 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 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ỳ.

View File

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

View File

@ -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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
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<ApprovalWorkflow> 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<Guid> 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<Guid> Recipients, NotificationType Type, string Title);
public List<SingleCall> SingleCalls { get; } = new();
public List<ManyCall> 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<Guid> 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;
}
}