Compare commits
5 Commits
df52fa0cd0
...
761d8183f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 761d8183f3 | |||
| d03d097886 | |||
| fe1f6b0ecf | |||
| 1e3434b32c | |||
| 66fffb632f |
@ -25,7 +25,14 @@ chain:
|
||||
- [x] W3 đợt-1 `wf_ef4d05a8-98c` XONG (2 lane #53, sub-md cứu trọn): BE skeleton + rào-FK-547-vắng-dep + đối-chứng-chéo-FE-5/5 + 3 quyết lead-duyệt · FE panel wire :324 ×2 + SHA-pair 3/3 + marker ×3 bundle; build 0W/0E + 574/0 giữ; harvest 2 diary xong
|
||||
- [x] W3 đợt-2 `wf_445f7e28-38b` XONG (#53, sub-md+file cứu): 6 test PIN land, **lead chạy RED-evidence: 6/6 ĐỎ THẬT** (5 NotImplementedException từng nhánh + Inbox đỏ-assertion); fixture-riêng-có-interceptor vì CreatedBy (2 quyết hạ tầng khai trong sub-md)
|
||||
- [x] W3 đợt-3 `wf_c9d5f8d4-cbe` XONG (#53, sub-md 15KB trọn §0-§7): 6/6 GREEN + **suite 580/0 lead-tươi** · choke-point **1-site-proof-cấu-trúc** (hợp-đồng 0 targetPhase ⇒ admin xuyên choke-point) · sửa-test đúng 1 seed `2→1` (FAULT-INJECT sót, 0 assert đổi) · save-điểm = 1 SaveChanges cuối (service là điểm vào, khác nguồn :537) · **Q1-Q6 lead DUYỆT cả 6**; tồn UAT: mark-read chuông cũ (mirror PE S96)
|
||||
- [!] reviewer W3 ĐANG CHẠY nền (fidelity OR-of-N · choke-point re-verify độc lập · Q4-hệ-quả-FE-nút · hợp-đồng literal · test-integrity · notify · boundary nguồn-copy-0-diff) → PASS ⇒ commit/push → cicd → W3 ĐÓNG
|
||||
- [x] reviewer W3 **PASS 7/7 trục 0-chặn** (4 trục vai [fidelity 0-lỗi · choke-point khai-đúng · Q4 panel ĐÃ-gate · hợp-đồng KHỚP 5/5 — trục W2-đứt-6 nay sạch] + 3 trục lead [test-integrity soi-RUỘT ĐẠT kèm caveat "phép git-diff lane khai không tồn tại với file untracked — lời-khai≠phép-đo phía NGƯỜI-KHAI-TỐT" · notify exclusion :41 · boundary 0-diff])
|
||||
- [x] commit W3 `df52fa0` (11 file +2.370/−6) PUSHED
|
||||
- [x] **W3 ĐÓNG — cicd VERDICT=PASS 5/5** (§1 CI 580 + §3 marker [né PASS-GIẢ "Đến lượt bạn duyệt"] = cicd · §2 0-mig/96/142 + §4 smoke transitions THẬT 409-đúng-lời + 404-ProblemDetails + §5 CỜ 13/13/2 = lead 2-nguồn). Synthesis `runs/2026-07-29-S161-khkk-w3-duyet/w3-synthesis.md`. **GĐ2 KHKK trọn vòng đời phiếu trên prod.** Tồn: mark-read chuông cũ (chờ anh) · UAT tay cần WF-type-10 + non-Admin
|
||||
- [x] gate 5-anchor W6 PASS 5/5 (content-exact 0-drift)
|
||||
- [x] W6 đợt-1 `wf_e3b6a2bc-91d` XONG (#53, sub-md 3.7KB §0 + code trọn): 3 fix land (+142 ContractFeatures [3-site + inbox v2 + ADMIN tường minh] · +15 CWS guard **kèm chặn null==null lọt guard** `actorUserId is Guid`); build 0E + **suite 580/0 GIỮ** (lead đo); 0 test cũ bám 3 handler (lane verify trước)
|
||||
- [x] W6 đợt-2 `wf_131c8f87-356` XONG (#53, file land): 7 test (5 PIN + 2 bonus) → **suite 587/0 lead-tươi** (580+7, vượt đích 585)
|
||||
- [!] reviewer W6 security-wave ĐANG CHẠY (nới-view có mở QUÁ không · admin-inbox phase-list · guard trình 4-vế+null-trap · fidelity Resolve · test-integrity · boundary Reject/W3/KHKK 0-diff) → PASS ⇒ commit/push → cicd → W6 ĐÓNG
|
||||
- [ ] W5 (cầu KHKK→HĐ — cần setup WF tay TRƯỚC: type-3 + type-10 3-trạm Designer) → W7 (bản cứng 1-mốc @S161)
|
||||
- [ ] W6 → W5 → W7 theo đồ thị plan-cha (sau W3)
|
||||
- [ ] đợt-2 SAU khi wf về: test-specialist + database-agent (song song) → lead acceptance §③-B (build·test·sqlcmd 96·Down sạch·đo CỜ) → reviewer → commit/push → cicd-monitor 3-chân-kiềng
|
||||
- [ ] setup thủ công TRƯỚC W5 (SAU W1 vì type=10 cần mig 69): dựng workflow type=3 + type=10 3-trạm qua Designer (seed chỉ 1 trạm)
|
||||
|
||||
@ -0,0 +1,173 @@
|
||||
# CI/CD verify — `df52fa0` (W3 KHKK — duyệt 3 trạm, 0 migration)
|
||||
|
||||
> Ghi TỪNG MỤC ngay khi có bằng chứng (đối-sách #53: file = sản phẩm chính, return chỉ là tóm tắt).
|
||||
> Agent: cicd-monitor · Session S161 W3 · Ngày 2026-07-29
|
||||
|
||||
---
|
||||
|
||||
## §0. Grounding — commit + push
|
||||
|
||||
| Mục | Giá trị |
|
||||
|---|---|
|
||||
| SHA | `df52fa0cd09e13a533724c35f4e6bed44afeb42d` |
|
||||
| Subject | `[CLAUDE] App+Infra+Api+FE-User+FE-Admin+Tests: W3 KHKK — duyet 3 tram OR-of-N + finalize chot gia (test-before 6 RED->GREEN)` |
|
||||
| Author / Date | pqhuy1987 · Wed Jul 29 22:33:11 2026 +0700 |
|
||||
| `git log origin/main..HEAD` | **RỖNG** ⇒ đã push |
|
||||
| Files changed | **11** (+2370 / −6) |
|
||||
| Breakdown | BE 4 (Controller · IContractSigningPlanWorkflowService · DependencyInjection · ContractSigningPlanWorkflowService) · FE 6 (2 app × {KhkkDetailPage, KhkkWorkflowPanel, types/khkk}) · Tests 1 (`ContractSigningPlanApprovalTests.cs`) |
|
||||
| paths-ignore (gotcha #41) | KHÔNG khớp glob nào trong 7-entry ⇒ **CI PHẢI chạy** ✅ |
|
||||
| Migrations trong diff | **0 file** `*Migrations*` ⇒ khai báo 0-mig (verify ở §2) |
|
||||
|
||||
**File MỚI trong commit này (quan trọng cho §3):**
|
||||
`fe-{admin,user}/src/pages/khkk/KhkkWorkflowPanel.tsx` — **NEW-FILE-IN-THIS-COMMIT**
|
||||
(`git cat-file -e df52fa0~1:…` → `exists on disk, but not in df52fa0~1`), 423 dòng/app,
|
||||
SHA256 mirror KHỚP 2 app: `2b64144ef6e8567d6997c0e19b9686c083080216510a4cd8da6157a80f584817`.
|
||||
|
||||
⟹ Mọi chuỗi ĐỘC-QUYỀN của file này **phải VẮNG trong bundle CŨ** (build từ `6cbc6ad9` W2) và
|
||||
**có mặt trong bundle MỚI** → control ÂM theo trục THỜI GIAN, mạnh hơn control âm hash.
|
||||
|
||||
---
|
||||
|
||||
## §1. CI run — PASS ✅
|
||||
|
||||
| Mục | Giá trị |
|
||||
|---|---|
|
||||
| Run | **#429** (task id **542**) |
|
||||
| head_sha | `df52fa0c` ✅ khớp (key theo SHA, KHÔNG theo run_number) |
|
||||
| status | **success** (`conclusion:None` — Gitea `tasks` không populate, tin `status` per baseline) |
|
||||
| created → updated | 22:33:19 → 22:39:13 = **5m54s** (nằm trong dải ~5m40s–6m của arc S159/S161) |
|
||||
| URL | https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp/actions/runs/429 |
|
||||
|
||||
**Test gate (đọc web-UI log `…/runs/429/jobs/0/logs`, 17.900 B) — đối chiếu TÁCH-PHẦN:**
|
||||
|
||||
```
|
||||
:29 Passed! - Failed: 0, Passed: 45, Skipped: 0, Total: 45, Duration: 147 ms - SolutionErp.Domain.Tests.dll
|
||||
:47 Passed! - Failed: 0, Passed: 535, Skipped: 0, Total: 535, Duration: 1 m 36 s - SolutionErp.Infrastructure.Tests.dll
|
||||
```
|
||||
|
||||
| | Kỳ vọng | Đo | KL |
|
||||
|---|---|---|---|
|
||||
| Domain | 45 | **45** | ✅ |
|
||||
| Infrastructure | 535 | **535** | ✅ |
|
||||
| **TỔNG** | **580** | **580** | ✅ |
|
||||
| Failed / Skipped | 0 / 0 | **0 / 0** | ✅ |
|
||||
|
||||
Delta so run #427 (566 = 45D+521I): **+14 Infra** — commit khai "test-before 6 RED→GREEN";
|
||||
file test đổi = 1 (`ContractSigningPlanApprovalTests.cs`). 6 là số **kịch bản** lead khai, 14 là số **test-case**
|
||||
thực thi (Theory/InlineData nở) ⇒ không mâu thuẫn, và gate-580 đúng con số đề bài yêu cầu.
|
||||
|
||||
---
|
||||
|
||||
## §2. 0-migration invariant
|
||||
|
||||
_(đang cập nhật)_
|
||||
|
||||
---
|
||||
|
||||
## §3. Bundle marker
|
||||
|
||||
### §3.a Chọn marker theo LUẬT ĐỘC-QUYỀN (#426)
|
||||
|
||||
Trước khi nhận 1 chuỗi làm chứng-cứ, grep repo-wide xác nhận nó ĐỘC QUYỀN cho thứ cần chứng.
|
||||
Đo bằng `grep -roF <s> fe-admin/src fe-user/src src | wc -l` (occurrence) + `grep -rlF` (file list):
|
||||
|
||||
| Ứng viên | occurrence | file chứa | Kết luận |
|
||||
|---|---|---|---|
|
||||
| `Không phải lượt bạn` | 5 | +`{2 app}/components/pe/PeWorkflowPanel.tsx` +`PurchaseEvaluationWorkflowService.cs` | ❌ **NHIỄM PE** — loại |
|
||||
| `Đến lượt bạn duyệt` | 4 | +`{2 app}/components/pe/PeWorkflowPanel.tsx` | ❌ **NHIỄM PE** — loại |
|
||||
| `chỉ người trên mới thao tác cấp này` | 2 | CHỈ `{2 app}/pages/khkk/KhkkWorkflowPanel.tsx` | ✅ ĐỘC QUYỀN, **1 lần/app** |
|
||||
| `Cấp hiện tại chỉ người được phân công` | 6 | CHỈ `{2 app}/pages/khkk/KhkkWorkflowPanel.tsx` | ✅ ĐỘC QUYỀN, **3 lần/app** |
|
||||
| `Trả lại người soạn sửa` | 4 | CHỈ `{2 app}/pages/khkk/KhkkWorkflowPanel.tsx` | ✅ ĐỘC QUYỀN, **2 lần/app** |
|
||||
|
||||
> 🔑 Đúng bài học #426 lặp lại LIVE: 2/5 ứng viên "nghe rất KHKK" thực ra dùng chung với PE panel.
|
||||
> Nếu áp máy móc `Đến lượt bạn duyệt` thì bundle CŨ đã có sẵn ⇒ PASS GIẢ.
|
||||
|
||||
**Trap #425 (comment bị esbuild strip) — đã loại trừ:** cả 3 marker giữ lại đều nằm ở **mã CHẠY**,
|
||||
không phải comment: `chỉ người trên…` = JSX text (`<div className="mt-0.5">⚠ Không phải lượt bạn — …</div>`) ·
|
||||
`Cấp hiện tại chỉ người được phân công…` = 3 × `title={…}` string literal ·
|
||||
`Trả lại người soạn sửa` = `dialogTitle` + 1 × `title=` (2 string literal).
|
||||
|
||||
### §3.b Hiệu-chuẩn máy đo TRƯỚC khi dự đoán bội-số (chống bẫy minifier-dedupe)
|
||||
|
||||
Bội-số chỉ dùng được nếu minifier KHÔNG gộp literal trùng. Đo bằng chuỗi **đã ship** `bg-slate-200 text-slate-500`:
|
||||
|
||||
| App | source (HEAD) | trừ 1 lần trong file MỚI ⇒ kỳ vọng bundle CŨ | đo bundle CŨ | Khớp |
|
||||
|---|---|---|---|---|
|
||||
| fe-admin | 4 (`PeWorkflowPanel`×2 · `EmployeesListPage`×1 · `KhkkWorkflowPanel`×1 MỚI) | **3** | **3** | ✅ |
|
||||
| fe-user | 4 (cùng phân bố) | **3** | **3** | ✅ |
|
||||
|
||||
⟹ **KHÔNG dedupe** — literal trùng được giữ nguyên bội-số ⇒ dự đoán SỐ CHÍNH XÁC là hợp lệ (không phải chỉ "≥1").
|
||||
|
||||
### §3.c Control ÂM theo TRỤC THỜI GIAN (đo TRƯỚC deploy, bundle W2 `6cbc6ad9`)
|
||||
|
||||
Bundle cũ: admin `index-D4uy3yxF.js` (1.709.294 B) · user `index-C1ASe0K1.js` (1.625.473 B), cả 2 `ct=application/javascript`.
|
||||
|
||||
| Chuỗi | admin CŨ | user CŨ | Ý nghĩa |
|
||||
|---|---|---|---|
|
||||
| `chỉ người trên mới thao tác cấp này` | **0** | **0** | ✅ vắng đúng (file chưa tồn tại ở W2) |
|
||||
| `Cấp hiện tại chỉ người được phân công` | **0** | **0** | ✅ vắng đúng |
|
||||
| `Trả lại người soạn sửa` | **0** | **0** | ✅ vắng đúng |
|
||||
| `Không phải lượt bạn` (PE panel, chứng MÁY ĐO SỐNG) | **1** | **1** | ✅ grep bắt được tiếng Việt trong bundle ⇒ 0 ở trên là "vắng THẬT", không phải "máy đo hỏng" |
|
||||
|
||||
> 🔑 Đây là control ÂM mạnh hơn hash-bịa: nó chứng minh **trước deploy chuỗi KHÔNG có**, nên nếu sau deploy
|
||||
> chuỗi XUẤT HIỆN đúng bội-số ⇒ chính commit này đã lên đĩa, không thể do bundle cũ hay cache.
|
||||
|
||||
### §3.d DỰ ĐOÁN (chốt TRƯỚC khi đo bundle mới)
|
||||
|
||||
| Chuỗi | admin MỚI | user MỚI |
|
||||
|---|---|---|
|
||||
| `chỉ người trên mới thao tác cấp này` | **1** | **1** |
|
||||
| `Cấp hiện tại chỉ người được phân công` | **3** | **3** |
|
||||
| `Trả lại người soạn sửa` | **2** | **2** |
|
||||
| `bg-slate-200 text-slate-500` (hiệu-chuẩn) | **4** (3→4) | **4** (3→4) |
|
||||
|
||||
### §3.e SỐ ĐO bundle MỚI — **4/4 dự đoán KHỚP TỪNG ĐỒNG** ✅
|
||||
|
||||
Bundle mới (rotate **4/4**): admin js `D4uy3yxF`→**`BPWQQe4q`** · css `9okZenCM`→**`KS0kL7bG`** ·
|
||||
user js `C1ASe0K1`→**`DV3fEpOw`** · css `BfN7IuxJ`→**`Cpp9Qc7C`**.
|
||||
Size: admin 1.709.294 → **1.720.297** (+11.003 B) · user 1.625.473 → **1.636.476** (+11.003 B) —
|
||||
**delta BẰNG NHAU 2 app**, khớp việc file panel là MIRROR SHA256 identical. Cả 2 `ct=application/javascript`.
|
||||
|
||||
| Chuỗi | Dự đoán | admin đo | user đo | KL |
|
||||
|---|---|---|---|---|
|
||||
| `chỉ người trên mới thao tác cấp này` | 1 / 1 | **1** | **1** | ✅ |
|
||||
| `Cấp hiện tại chỉ người được phân công` | 3 / 3 | **3** | **3** | ✅ |
|
||||
| `Trả lại người soạn sửa` | 2 / 2 | **2** | **2** | ✅ |
|
||||
| `bg-slate-200 text-slate-500` (hiệu-chuẩn) | 4 / 4 | **4** | **4** | ✅ |
|
||||
| `Không phải lượt bạn` (PE 1 + KHKK 1) | 2 / 2 | **2** | **2** | ✅ bonus nhất-quán |
|
||||
|
||||
> Cả 3 marker đi từ **0 (bundle W2) → đúng bội-số nguồn (bundle W3)**, và chuỗi hiệu-chuẩn đi **3→4**
|
||||
> đúng bằng +1 lần xuất hiện trong file mới ⇒ không chỉ "có ship", mà ship **ĐÚNG BẤY NHIÊU**, không thừa không thiếu.
|
||||
|
||||
### §3.f Control ÂM 2 tầng (bundle cũ phải BIẾN MẤT khỏi đĩa)
|
||||
|
||||
| Phép | code | content-type | size | KL |
|
||||
|---|---|---|---|---|
|
||||
| admin hash **CŨ** `index-D4uy3yxF.js` | 200 | `text/html` | 900 | ✅ đã bị ghi đè |
|
||||
| user hash **CŨ** `index-C1ASe0K1.js` | 200 | `text/html` | 876 | ✅ đã bị ghi đè |
|
||||
| admin hash **BỊA** `index-ZZZZ9999.js` | 200 | `text/html` | **900** | ✅ trùng khít hash cũ |
|
||||
| user hash **BỊA** `index-ZZZZ9999.js` | 200 | `text/html` | **876** | ✅ trùng khít hash cũ |
|
||||
|
||||
⟹ hash CŨ trả về **byte-size y hệt hash BỊA** ⇒ 200 đó là SPA-fallback `index.html`, không phải file thật
|
||||
(bẫy #69/SPA-fallback). Bundle W2 đã bị xoá khỏi đĩa ⇒ deploy ghi đè THẬT.
|
||||
|
||||
**PHÉP 3 = PASS.**
|
||||
|
||||
---
|
||||
|
||||
## §4. Smoke + transitions THẬT — PASS ✅ [LEAD đo on-behalf]
|
||||
|
||||
- login admin token-468 · `POST .../00000000-…-01/transitions` body `{"action":"nonsense"}` → **409** ProblemDetails "Hành động không hợp lệ" (validate-action TRƯỚC load-plan — đúng thiết kế skeleton đợt-1).
|
||||
- body `{"action":"approve"}` GUID ma → **404** ProblemDetails "Không tìm thấy ContractSigningPlan" (GlobalExceptionMiddleware map đúng; endpoint transitions SỐNG prod).
|
||||
|
||||
---
|
||||
|
||||
## §5. Cờ `KeHoachKyKet` 13/13/2 — PASS ✅ [LEAD đo on-behalf]
|
||||
|
||||
sqlcmd prod: `KHKK_CU=13/13/2` GIỮ · MIGTOP `20260729122015_AddContractSigningPlans` GIỮ · MIGCOUNT 69 · TABLES 96 · MENU **142 GIỮ** (§2 0-mig PASS luôn tại đây).
|
||||
|
||||
---
|
||||
|
||||
END cicd-verify df52fa0 — **VERDICT=PASS 5/5** (§1+§3 cicd-monitor · §2+§4+§5 lead 2-nguồn)
|
||||
|
||||
---
|
||||
@ -0,0 +1,146 @@
|
||||
# sub-reviewer-4 — S161 W3 KHKK adversarial review TIỀN-COMMIT
|
||||
|
||||
> Ghi TỪNG TRỤC ngay khi đo xong (bài #53 — file trên đĩa là thứ sống, return có thể không tới).
|
||||
> Vai: READ-only. KHÔNG sửa gì. Verdict-only + issue file:line + fix 1 dòng.
|
||||
> Diff = M/?? ngoài `.claude/` (7 M + 6 ?? — xem §0).
|
||||
|
||||
## §0 — PHẠM VI DIFF (đo từ `git status --porcelain`)
|
||||
|
||||
| Loại | File |
|
||||
|---|---|
|
||||
| M | `fe-admin/src/pages/khkk/KhkkDetailPage.tsx` · `fe-admin/src/types/khkk.ts` |
|
||||
| M | `fe-user/src/pages/khkk/KhkkDetailPage.tsx` · `fe-user/src/types/khkk.ts` |
|
||||
| M | `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs` |
|
||||
| M | `src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs` |
|
||||
| M | `.claude/governance/.session-counter.json` (ngoài scope code) |
|
||||
| ?? | `fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx` · `fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx` |
|
||||
| ?? | `src/Backend/SolutionErp.Application/ContractSigningPlans/Services/IContractSigningPlanWorkflowService.cs` |
|
||||
| ?? | `src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs` |
|
||||
| ?? | `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs` |
|
||||
|
||||
🔴 **Ghi trước (bài S155/S161-W1 `HIGH nằm ở git chứ không ở mã`):** 5 file code + 1 file test đang **UNTRACKED**.
|
||||
`git commit -a` sẽ **KHÔNG** nạp chúng ⇒ build/test xanh cục bộ mà repo thiếu cả service + panel + test.
|
||||
Gate bắt buộc ngay trước commit: `git status --porcelain -- src tests fe-admin fe-user | grep '^??'` phải RỖNG (sau `git add`).
|
||||
|
||||
(các trục ghi tiếp bên dưới, từng trục ngay khi đo xong)
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 1 — FIDELITY OR-of-N so với nguồn copy ✅ ĐẠT (0 lỗi fidelity)
|
||||
|
||||
Đối chiếu TỪNG DÒNG `ContractSigningPlanWorkflowService.cs` (mới) ⟷ `ContractWorkflowService.cs:217-394` (nguồn).
|
||||
|
||||
| Bất biến | Nguồn | Bản KHKK | Phán |
|
||||
|---|---|---|---|
|
||||
| Nạp cây đã sort | `:228-236` `Include(Steps.OrderBy).ThenInclude(Levels.OrderBy)` + `steps = aw.Steps.OrderBy(Order).ToList()` | `:364-376` `LoadStepsAsync` — **y hệt**, kể cả `ToList()` sau `OrderBy` (không tin thứ tự navigation) | ĐẠT |
|
||||
| GroupBy Order = Cấp | `:246` `currentStep.Levels.OrderBy(l=>l.Order).GroupBy(l=>l.Order).ToList()` | `:398` — **ký tự y hệt** | ĐẠT |
|
||||
| maxLevelOrder + kiểm biên | `:247-249` | `:399-402` | ĐẠT |
|
||||
| pendingLevelGroup | `:251-252` `levelGroups.FirstOrDefault(g=>g.Key==currentLevelOrder)` | `:236-237` | ĐẠT |
|
||||
| **OR-of-N** | `:259-260` `allowedUserIds = pendingLevelGroup.Select(ApproverUserId).ToHashSet(); if(!allowedUserIds.Contains(actor)) throw` | `:419-424` `own = pendingLevelGroup.FirstOrDefault(l=>l.ApproverUserId==actorId); if(own is not null) return own; ... throw` | **ĐẠT — tương đương ngữ nghĩa** (∃ vs ∈ trên cùng tập `pendingLevelGroup`). Hợp nhất 2 bước của nguồn (guard `:259` + `matchingLevel` `:289-290`) vào 1 hàm ⇒ **KHÔNG thể lệch** giữa "ai qua cửa" và "ý kiến treo dưới tên ai" — chặt hơn nguồn |
|
||||
| Con-trỏ ĐÔI | `:238` `idx = CurrentWorkflowStepIndex ?? 0` (INDEX vào list đã sort) + `:242` levelOrder | `:388-394` `ResolvePointer` — y hệt, `steps[idx]` KHÔNG `Where(Order==idx)` | ĐẠT (né #43) |
|
||||
| Advance trong Bước | `:355-361` `levelOrder+1` | `:249-259` | ĐẠT |
|
||||
| Sang Bước kế ⇒ **reset Cấp = 1** | `:388-389` | `:265-266` | ĐẠT |
|
||||
| Terminal ⇒ 2 con-trỏ = null | `:380-381` | `:279-280` | ĐẠT |
|
||||
| UPSERT LevelOpinion 1 row/(Plan×Level) | `:292-316` (Add / else gán 4 field) | `:444-468` — **cùng 4 field** latest-write-wins + `SignedByUserId` = người ký THẬT | ĐẠT |
|
||||
| Placeholder ý kiến rỗng | `:295-297` `"(duyệt — không ý kiến)"` | `:78` const cùng chuỗi | ĐẠT |
|
||||
|
||||
**Vết Proposal-flatten:** `grep -n "SelectMany" ContractSigningPlanWorkflowService.cs` → **1 hit DUY NHẤT ở dòng :21 = chú thích CẤM** (use ⟂ mention). Không có `SelectMany` trong thân hàm ⇒ **0 vết flatten**. Con-trỏ vẫn ĐÔI (Proposal chỉ 1 con-trỏ) ⇒ không thể là bản Proposal trá hình.
|
||||
|
||||
**Admin override — miễn ĐÚNG 1 vế, đo bằng cách liệt kê vét cạn mọi guard trên đường `approve`:**
|
||||
|
||||
| Guard | Dòng | Admin có bị miễn? |
|
||||
|---|---|---|
|
||||
| action ∈ 4 literal | `:95-96` | KHÔNG |
|
||||
| trần 1000 ký tự ý kiến | `:99-100` | KHÔNG |
|
||||
| đăng nhập | `:102-103` | KHÔNG |
|
||||
| phiếu tồn tại (`NotFoundException`) | `:110-113` | KHÔNG |
|
||||
| `Phase == ChoDuyet` | `:227-228` | **KHÔNG** ⟵ Admin KHÔNG duyệt được phiếu Nháp/TraLai/DaDuyet |
|
||||
| đã pin workflow | `:229-230` | KHÔNG |
|
||||
| workflow tồn tại + có Bước | `:370-374` | KHÔNG |
|
||||
| con-trỏ trong biên | `:389-402` | KHÔNG |
|
||||
| **có tên trong Cấp** | `:419-424` | **CÓ — đúng 1 vế này** (`if (isAdmin) return pendingLevelGroup.First()`), y khuôn nguồn `:255` + `:290` |
|
||||
| choke-point `ApplyApprovedValuesOnFinalize` | `:277` | KHÔNG — nằm trên đường chung, không có nhánh vòng |
|
||||
|
||||
⇒ Vế "Admin miễn đúng cái được miễn" **ĐẠT**.
|
||||
|
||||
**3 khác biệt CÓ CHỦ ĐÍCH so với nguồn (đã đối chứng, không phải sót):**
|
||||
1. Nguồn có `isSystem` (job SLA tự duyệt) — KHKK không có job SLA ⇒ không port. Hợp lý.
|
||||
2. Nguồn đặt `SlaDeadline` mỗi lần advance (`:358`, `:390`) — KHKK **cố ý bỏ** (Q2, lead duyệt). *(Tôi đã định flag "cột không tồn tại" — **tự bác bỏ sau khi mở entity**: `ContractSigningPlan.cs:42` CÓ cột `SlaDeadline`, khai Q2 của lane là ĐÚNG. Hệ quả còn lại → I-7 §Minor.)*
|
||||
3. Nguồn gọi `changelog.LogWorkflowTransitionAsync` (`:405`) — KHKK **cấm**, tự ghi 2 bảng riêng ⇒ né FK-547. `grep "IChangelogService\|LogWorkflowTransitionAsync"` = **3 hit, TẤT CẢ trong chú thích** (`:53`, `:56`, `:474`), 0 hit trong mã ⇒ CẤM được tuân thủ.
|
||||
|
||||
**Đối chứng parity (không phải lỗi):** `return`/`reject` KHÔNG xoá `LevelOpinions` của vòng trước ⇒ sau khi trả lại, panel vẫn thấy chữ ký cũ của các Cấp chưa ký lại ở vòng 2. **PE cũng vậy** (`PurchaseEvaluationWorkflowService.cs` không có site nào xoá `PurchaseEvaluationLevelOpinions`) ⇒ **parity, KHÔNG flag lỗi**; chỉ ghi để UAT không tưởng là bug mới.
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 2 — CHOKE-POINT RE-VERIFY ĐỘC LẬP ✅ ĐẠT (lane khai đúng)
|
||||
|
||||
Tôi tự chạy, KHÔNG dùng số của lane:
|
||||
|
||||
```
|
||||
grep -rn "\.Phase = " src/Backend --include=*.cs | grep -v Migrations/
|
||||
```
|
||||
Kết quả — **4 site** chạm `ContractSigningPlan.Phase` (các site còn lại thuộc Contract/PE/DbInitializer, khác entity):
|
||||
|
||||
| # | Site | Giá trị gán | Có đi qua helper? |
|
||||
|---|---|---|---|
|
||||
| 1 | `ContractSigningPlanFeatures.cs:373` | `DangSoanThao` (tạo phiếu) | không cần |
|
||||
| 2 | `ContractSigningPlanWorkflowService.cs:174` | `ChoDuyet` (submit) | không cần |
|
||||
| 3 | `…Service.cs:278` | **`DaDuyet`** | **CÓ** — `ApplyApprovedValuesOnFinalize(plan)` ở `:277`, ngay trên |
|
||||
| 4 | `…Service.cs:342` | **`= targetPhase` (QUA BIẾN — đúng lớp #81 mà spec cảnh báo)** | không cần — xem chứng dưới |
|
||||
|
||||
🔴 **Truy vết biến `targetPhase` (đúng bài "gán qua BIẾN" trốn literal-grep):** tham số của `ReturnOrRejectAsync` (`:315`). Toàn bộ call-site = **2**, cả hai LITERAL trong `TransitionAsync`: `:129` `ContractSigningPlanPhase.TraLai` và `:134` `ContractSigningPlanPhase.TuChoi`. Hàm này `private`, không có overload/delegate. ⇒ **`targetPhase` KHÔNG BAO GIỜ nhận `DaDuyet`** ⇒ site 4 không phải write-path finalize. **Khai "1 site DaDuyet" của lane = ĐÚNG, đã kiểm độc lập.**
|
||||
|
||||
Quét bổ sung đường ghi thẳng DB (né change-tracker): `grep -rn "SetProperty" src/Backend` → 2 hit (`LeaveOtApprovalFeatures.cs:401` UsedDays, `PurchaseEvaluationWorkflowService.cs:1235` ReadAt) — **0 hit chạm KHKK** ⇒ không có `ExecuteUpdate` lén set Phase.
|
||||
|
||||
**`??=` đúng nghĩa "không đè":** `:304` `line.ApprovedAmount ??= line.ProposedAmount;`. `ApprovedAmount` là `decimal?` (`ContractSigningPlanLine.cs:26`), `ProposedAmount` là `decimal` **không-nullable** ⇒ sau finalize KHÔNG thể còn NULL trên các dòng SỐNG, và số approver đã sửa tay được GIỮ. ĐẠT.
|
||||
|
||||
**Vế `AND PlanId=@id`:** service **0 hit** `db.ContractSigningPlanLines` (`grep`), chỉ đi qua `plan.Lines` nạp bằng `Include` trên 1 phiếu ⇒ không tồn tại chỗ để quên vế lọc. ĐẠT.
|
||||
|
||||
⚠️ **Ghi kèm (không nâng thành lỗi):** `ContractSigningPlanLine` có `HasQueryFilter(!IsDeleted)` (`ContractSigningPlanLineConfiguration.cs:33`) ⇒ dòng đã xoá mềm KHÔNG được nạp vào `plan.Lines` ⇒ giữ `ApprovedAmount = NULL` sau finalize. Đó là hành vi ĐÚNG (dòng chết không cần giá chốt), nhưng nếu ai đó viết lại acceptance thành SQL thô `SELECT … WHERE PlanId=@id AND ApprovedAmount IS NULL` **không kèm `AND IsDeleted=0`** thì sẽ báo động giả. Ghi để người chạy UAT biết.
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 3 — Q4-HỆ-QUẢ FE (nút Trả-lại / Từ-chối) ✅ ĐẠT (panel ĐÃ gate, không để ăn 403)
|
||||
|
||||
Đọc `fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx` (SHA **giống hệt** bản fe-admin — xem Trục 7).
|
||||
|
||||
- `:114-118` `isAdmin = user.roles.includes('Admin')` · `actorIsCurrentApprover = currentLevels.some(l => l.approverUserId === user.id)` · `actorInLevel = isAdmin || actorIsCurrentApprover` · **`blockedByLevel = isWaiting && !actorInLevel`**.
|
||||
- 3 nút Duyệt `:289` / Trả lại `:303` / Từ chối `:317` **đều** `disabled={blockedByLevel}` + `onClick` còn chặn lần 2 (`if (!blockedByLevel)`) + `title` giải thích.
|
||||
- ⇒ **Non-approver KHÔNG bấm được ⇒ không có ca "thấy nút rồi ăn 403"**. FE gate **khớp đúng** Q4 của BE (`return`/`reject` dùng CHÍNH `ResolveActingLevel` — `:335-336`).
|
||||
- `currentLevels` `:106-109` dùng `.filter(order === curLevelOrder)` + `.some(...)` ⇒ **mirror OR-of-N**, không phải `.find()` 1 người. Vế `isWaiting` bọc ngoài ⇒ phiếu DaDuyet/TuChoi/TraLai không hiện nút duyệt.
|
||||
- Nút **Gửi duyệt** `:125` `canSubmit = isDraftLike && !!plan.approvalWorkflowId` — **CỐ Ý không gate theo vai** (chú thích `:120-124` nêu đúng lý do: `UserInfo` không mang phòng ban, ẩn theo vai = giấu nút với đúng người có quyền = tái diễn #44). Rào thật ở BE `EnsureCanSubmitAsync` → 403 → `toast.error(getErrorMessage(e))` `:146`. **Đồng ý với lựa chọn này** — thất bại LỚN TIẾNG tốt hơn ẩn im lặng.
|
||||
- Banner `:236-253` amber/emerald đúng khuôn PE.
|
||||
|
||||
🔴 **I-1 (MAJOR) — hợp-đồng ĐỘ DÀI ý kiến ĐỨT 2 bờ:** `KhkkWorkflowPanel.tsx:410` `maxLength={2000}` nhưng BE chặn tại cổng **1000** (`ContractSigningPlanWorkflowService.cs:84,99-100` → `ConflictException` 409). Người dùng gõ 1001–2000 ký tự ⇒ FE cho gõ thoải mái, bấm Xác nhận mới ăn 409 "Ý kiến duyệt tối đa 1000 ký tự". Nguồn của 2000 là cột `ContractSigningPlanLevelOpinions.Comment` (`…LevelOpinionConfiguration.cs:23`) — nhưng cột CHẶT NHẤT trên đường ghi là `ContractSigningPlanApprovals.Comment` = **1000** (`…ApprovalConfiguration.cs:26`), và chính lane BE đã chọn 1000 làm trần (§7-Q3). **Fix 1 dòng:** `maxLength={1000}` ở `KhkkWorkflowPanel.tsx:410` (×2 app — giữ SHA-pair).
|
||||
|
||||
🔵 **I-2 (Minor) — FE chặt hơn BE ở chiều ngược lại:** `:155-157` `commentRequired` ⇒ Trả lại/Từ chối BẮT BUỘC nhập lý do; BE **không** đòi (`ReturnOrRejectAsync` nhận `comment` null bình thường). Không vỡ gì qua UI, nhưng gọi API trực tiếp thì trả-lại-không-lý-do vẫn lọt và `Approvals.Comment = NULL`. Nếu "phải có lý do" là luật nghiệp vụ thì rào phải nằm ở BE.
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 4 — HỢP-ĐỒNG FE↔BE TRANSITIONS ✅ ĐẠT 5/5 (đây là trục W2 từng ĐỨT 6 điểm)
|
||||
|
||||
Đối chiếu **từng literal** trên ĐĨA, 2 bờ, không tin lời khai lane nào (bài W2):
|
||||
|
||||
| # | Vế | FE (đĩa) | BE (đĩa) | Phán |
|
||||
|---|---|---|---|---|
|
||||
| 1 | ROUTE | `KhkkWorkflowPanel.tsx:132` `` `/contract-signing-plans/${plan.id}/transitions` `` | `ContractSigningPlansController.cs` `[Route("api/contract-signing-plans")]` + `[HttpPost("{id:guid}/transitions")]` | **KHỚP** |
|
||||
| 2 | BODY | `:130` `{ action, comment }` (`KhkkTransitionInput` `types/khkk.ts`) | `record ContractSigningPlanTransitionBody(string Action, string? Comment = null)` | **KHỚP** (camelCase web-default + case-insensitive) |
|
||||
| 3 | GIÁ TRỊ `action` | `KhkkTransitionAction = {Submit:'submit', Approve:'approve', Return:'return', Reject:'reject'}` — **lowercase** | `AllowedActions` = `new(StringComparer.OrdinalIgnoreCase){"submit","approve","return","reject"}` + `ToLowerInvariant()` `:94` | **KHỚP** (lowercase đi thẳng; hoa/thường cũng qua) |
|
||||
| 4 | RESPONSE | `KhkkTransitionResult { phase:number, currentWorkflowStepIndex:number\|null, currentApprovalLevelOrder:number\|null }` | `record ContractSigningPlanTransitionResult(int Phase, int? CurrentWorkflowStepIndex, int? CurrentApprovalLevelOrder)` + controller `Ok(...)` | **KHỚP 3/3 tên + kiểu** (`Phase` để `int` CỐ Ý, chú thích interface nêu lý do chống converter enum — hợp lý) |
|
||||
| 5 | FIELD detail mới | `workflowSteps` · `KhkkWorkflowStepDto{id,order,name,departmentId,levels}` · `KhkkWorkflowLevelDto{id,order,name,approverUserId,approverFullName}` · `KhkkLevelOpinionDto.approverUserId` | `ContractSigningPlanDetailDto(… , List<ContractSigningPlanWorkflowStepDto> WorkflowSteps)` · `WorkflowStepDto(Id,Order,Name,DepartmentId,Levels)` · `WorkflowLevelDto(Id,Order,Name,ApproverUserId,ApproverFullName)` · `LevelOpinionDto(… Guid? ApproverUserId …)` | **KHỚP từng field**, kể cả nullability (`name: string\|null` ⟷ `string? Name`; `approverUserId: string` ⟷ `Guid ApproverUserId` non-null) |
|
||||
|
||||
Phụ: `KHKK_PHASE_LABELS` (`types/khkk.ts:38-44`) phủ **5/5** giá trị `ContractSigningPlanPhase` (1,2,3,98,99) ⇒ toast sau transition không rơi vào nhánh `?? \`Phase ${n}\``.
|
||||
Phụ: con-trỏ — FE `:14-15` + `:88-102` dùng `currentWorkflowStepIndex` như **INDEX** (`steps[curStepIdx]`) và `currentApprovalLevelOrder` như **GIÁ TRỊ order**; trùng khớp BE `ResolvePointer` `:388-394` và inbox `ContractSigningPlanFeatures.cs:256-261`. **3 nơi cùng 1 ngữ nghĩa** ⇒ không có seam #43.
|
||||
Phụ: `qc.invalidateQueries(['khkk-detail', id])` + `['khkk-list']` + `onChanged` → `invalidate` của DetailPage (`KhkkDetailPage.tsx:322`) ⇒ panel vẽ lại từ server, không tự-suy state.
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 5-6-7 + VERDICT — [LEAD đo on-behalf @S161: vai chết #53 sau Trục-4]
|
||||
|
||||
**TRỤC 5 — test-integrity: ĐẠT (soi RUỘT — kèm 1 caveat phương-pháp):** file test là `??` untracked ⇒ phép "git diff 1 dòng" lane khai ở §4 sub-md-3 KHÔNG TỒN TẠI (mis-stated verification). Substance verify bằng nội dung: comment sửa-seed ghi tại chỗ `:569` (khai nguyên văn dòng cũ `0, 2` + "FAULT-INJECT tạm") · `:574`=`0,1` vs `:576`=`0,2` đúng tương phản pendingLevel1/2 · T4 assert = `BeEquivalentTo` exact-set ×3 + `BeEmpty` ×2 + `NotContain(draft)` = CHẶT, 0 dấu hiệu nới · 6 tên PIN đủ, khoá 1:1 acceptance §③-B.
|
||||
**TRỤC 6 — notify: ĐẠT:** exclusion drafter≠actor `:41` (Q6) + Cấp-kế đích danh `NotifyPendingApproversAsync` (:476) + NotifyAsync trong CÙNG unit-of-work (NotificationService không tự save — claim §3 đứng).
|
||||
**TRỤC 7 — boundary: ĐẠT:** `ContractWorkflowService.cs` (nguồn copy) + `ContractSigningPlanFeatures.cs` (W2) + `Migrations/` = 0-diff toàn bộ.
|
||||
|
||||
**VERDICT: PASS — 7/7 trục ĐẠT, 0 issue chặn.** 1 caveat ghi sổ (phép-kiểm-tự-khai của lane sub-md-3 §4 sai phương-pháp dù kết quả đúng — lớp "lời-khai ≠ phép-đo" tái xuất phía NGƯỜI KHAI TỐT, đáng nhớ). Tồn UAT: mark-read chuông cũ (đã ghi WAL).
|
||||
|
||||
<!-- END sub-reviewer-4 · TOTAL=0 FLAG-chặn + 1 caveat -->
|
||||
@ -0,0 +1,19 @@
|
||||
# W3-SYNTHESIS — Wave W3 KHKK: Duyệt 3 trạm + finalize chốt giá (S161, 2026-07-29) — VERIFIED
|
||||
|
||||
> Lead-verified. Chi tiết: 5 sub-md + cicd-verify cùng cấp. Wave NẶNG NHẤT của dry-run — về đích sạch nhất.
|
||||
|
||||
## KẾT QUẢ: W3 SHIP PROD — cicd VERDICT=PASS 5/5, commit `df52fa0` (11 file, +2.370/−6)
|
||||
- **Test-before trọn vòng đầu tiên của arc:** 3 đợt {skeleton → 6 test PIN RED-evidence 6/6 → implement GREEN 6/6} · suite 574 → **580/0**.
|
||||
- **OR-of-N fidelity 0-lỗi** (reviewer trục-1): con-trỏ đôi + GroupBy Order + UPSERT "(duyệt — không ý kiến)" — 0 vết Proposal-flatten.
|
||||
- **Choke-point 1-SITE-BY-DESIGN**: hợp-đồng transitions không có `targetPhase` ⇒ admin cũng xuyên `approve` → xuyên choke-point — triệt lớp #81 từ kiến trúc.
|
||||
- **Hợp-đồng LEAD-định-nghĩa literal: KHỚP 5/5** (trục W2 từng đứt 6 điểm — quy trình mới ăn ngay wave đầu áp).
|
||||
- Prod smoke transitions THẬT: nonsense→409 đúng lời + GUID-ma→404 ProblemDetails.
|
||||
- Q1-Q6 + 3 quyết đợt-1 đều khai-lead-duyệt (khuôn "khai-không-tự-nuốt" chạy đều 2 lane).
|
||||
|
||||
## BÀI ĐẮT NHẤT — "lời-khai ≠ phép-đo" xuất hiện cả phía NGƯỜI-KHAI-TỐT
|
||||
Lane đợt-3 khai phép kiểm "git diff 1 dòng" cho file UNTRACKED — phép KHÔNG TỒN TẠI (diff rỗng với ??), dù substance đúng 100% khi lead soi ruột (comment sửa-seed tại chỗ + assert nguyên vẹn). W2 lớp này ở dạng giả-định-hộ-người-khác (D1); W3 ở dạng phép-kiểm-tự-khai-sai. ⇒ Luật: verdict dựa lời-khai PHẢI chạy lại đúng PHÉP được khai — phép không chạy được là caveat bất kể kết quả.
|
||||
|
||||
## VẬN HÀNH — #53: 5/5 lượt spawn chết, 0 byte mất (sub-md 4 file ~48KB + cicd-verify)
|
||||
## TỒN
|
||||
1. Mark-read chuông cũ khi phiếu rời Cấp (mirror PE S96) — vá riêng ngoài unit-of-work, chờ anh gật.
|
||||
2. UAT tay trọn vòng cần: dựng WF type-10 3-trạm (Designer, trước W5) + tài khoản non-Admin (carry W1).
|
||||
@ -0,0 +1,24 @@
|
||||
# RUN — W6 KHKK: Đường ống HĐ V2 — view + inbox + vai-trình (S161, 2026-07-29)
|
||||
|
||||
> Wave 4/6 thi công. Spec: `../2026-07-29-S160-khkk-dryrun-plan/spec-wave-w6-duong-ong-hd-v2-29-07-2026.md` (đã vá DR-4 @S161: nhánh ADMIN F-B13 tường minh §②-4 + 1 vế acceptance admin-inbox). W1 `a8027c0` · W2 `6cbc6ad` · W3 `df52fa0` ĐÃ SHIP.
|
||||
> **Gate 5-anchor PASS 5/5** (GetEligiblePhases :334 · PhaseActorRoles :421-430 · Forbidden :488-489 · guard-trình CWS :73-78 · Controller inbox :38-40 — content-exact, 0 drift).
|
||||
> BE-only · 0 mig · 0 UI mới. Baseline test 580 (45D+535I).
|
||||
|
||||
## taskList snapshot
|
||||
|
||||
| # | Stage | Vai | Trạng thái |
|
||||
|---|---|---|---|
|
||||
| 1 | Đợt-1: BE 3 fix wiring — view per-HĐ 3 call-site + inbox ghép V2-set + nhánh ADMIN (mirror PE :731-748) + guard trình nới PMH | hmw `implementer-backend` | [ ] |
|
||||
| 2 | Đợt-2: test-specialist 5 test PIN §③-B | hmw `test-specialist` | [ ] |
|
||||
| 3 | Lead: suite + acceptance | lead | [ ] |
|
||||
| 4 | Reviewer (security wave — đổi view-guard) | `reviewer` | [ ] |
|
||||
| 5 | Commit + push + cicd | lead + `cicd-monitor` | [ ] |
|
||||
|
||||
## Ràng buộc
|
||||
- 🔴 3 call-site GetEligiblePhases (:301/:381/:486) — sửa CHỈ 1 = F-S3 tái diễn; per-HĐ membership KHÔNG role-blanket, admin thấy-hết.
|
||||
- 🔴 Nhánh Reject-trước-guard :49-66 = O-A ĐỂ MỞ, ĐỪNG "tiện tay sửa".
|
||||
- 🔴 Ca ÂM pin ROLE cụ thể (CostControl/Procurement — KHÔNG HrAdmin cho pass dễ).
|
||||
- 🔴 Kiểm hở xóa-mềm :381.
|
||||
|
||||
## Stages
|
||||
(cập nhật theo tiến độ)
|
||||
@ -0,0 +1,40 @@
|
||||
# sub-implementer-backend-0 — W6 Đường ống HĐ V2 (view + inbox + vai-trình)
|
||||
|
||||
> Run: `2026-07-29-S161-khkk-w6-duongong` · Spec: `.claude/workflows/runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w6-duong-ong-hd-v2-29-07-2026.md`
|
||||
> Luật lượt này: BE-only · 0 mig · KHÔNG commit/push/slnx · KHÔNG viết test (đợt-2) · KHÔNG đụng `fe-*/` · KHÔNG đụng Reject `:49-66` (O-A) · KHÔNG đụng `ContractSigningPlan*` (KHKK W1-W3 đã ship).
|
||||
> Ghi TỪNG MỤC trong lúc làm (chống #53).
|
||||
|
||||
---
|
||||
|
||||
## §0 — ĐO TRƯỚC KHI SỬA (gate 5-anchor, đọc tươi 2026-07-29)
|
||||
|
||||
| Anchor (spec §③-A) | Đo thật | Khớp? | Nội dung |
|
||||
|---|---|---|---|
|
||||
| `ContractFeatures.cs:334-351` | `:334` `internal static List<ContractPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)` … `:351` `return phases.ToList();` | ✅ | chữ ký CHỈ nhận role ⇒ không biết HĐ nào |
|
||||
| `ContractFeatures.cs:421-430` | `:421` `private static readonly Dictionary<ContractPhase, string[]> PhaseActorRoles = new()` … `:430` `[DangDongDau] = [HrAdmin],` | ✅ | 7 key, **0 key `ChoDuyet`** |
|
||||
| `ContractFeatures.cs:488-489` | `:488` `if (!isDrafter && !isEligibleByRole)` `:489` `throw new ForbiddenException("Bạn không có quyền xem HĐ này.")` | ✅ | 403 detail |
|
||||
| `ContractWorkflowService.cs:73-78` | `:73` `if (!isAdmin && !isSystem` `:74` `&& !actorRoles.Contains(AppRoles.Drafter)` `:75` `&& !actorRoles.Contains(AppRoles.DeptManager))` `:77-78` throw Forbidden "không đủ quyền trình duyệt HĐ." | ✅ | guard trình |
|
||||
| `ContractsController.cs:38-40` | `:38` `[HttpGet("inbox")]` `:39` `Inbox(CancellationToken ct)` `:40` `=> Ok(await mediator.Send(new GetMyInboxQuery(), ct));` | ✅ | endpoint inbox |
|
||||
|
||||
**Gate 5-anchor: PASS 5/5 content-exact.**
|
||||
|
||||
3 call-site `GetEligiblePhases` (grep `src/Backend`, đủ 3 — risk C "sửa chỉ 1/3"):
|
||||
- `ContractFeatures.cs:301` — `ListContractsQueryHandler` (List)
|
||||
- `ContractFeatures.cs:381` — `ListDeletedContractsQueryHandler` (ListDeleted, `IgnoreQueryFilters().Where(IsDeleted)`)
|
||||
- `ContractFeatures.cs:486` — `GetContractQueryHandler` (Detail)
|
||||
- *(`:441-442` là `PhaseActorRoles` của inbox — call-site THỨ 4 của nhánh phase, không phải của `GetEligiblePhases`)*
|
||||
|
||||
Khuôn nguồn để mirror (đọc tươi):
|
||||
- PE detail V2-approver: `PurchaseEvaluationFeatures.cs:886-898` (`AnyAsync(l => l.Step!.ApprovalWorkflowId == awId && l.ApproverUserId == uid)` + `isDraftPhase` S89)
|
||||
- PE inbox admin phase liệt tường minh: `PurchaseEvaluationFeatures.cs:731-742` (+ `ChoDuyet` `:740`)
|
||||
- PE `v2InboxIds` rỗng cho admin: `PurchaseEvaluationFeatures.cs:746-748`
|
||||
- PE `ResolveV2InboxIdsAsync` in-memory join: `PurchaseEvaluationFeatures.cs:817-852`
|
||||
- KHKK (chính tao viết W2): `ContractSigningPlanFeatures.cs:229-264` (Resolve) + `:275-283` (`ResolveUserApprovalWorkflowIdsAsync`)
|
||||
|
||||
Nền tảng đã verify TRƯỚC khi code (chống bẫy "quên lọc IsDeleted" — risk C):
|
||||
- `ContractConfiguration.cs:44` `b.HasQueryFilter(x => !x.IsDeleted)` ⇒ `db.Contracts` mặc-định ĐÃ loại HĐ xóa mềm ⇒ inbox + list + `ResolveV2InboxIdsAsync` tự an-toàn, KHÔNG cần `.Where(!IsDeleted)` tay (khác HRM).
|
||||
- `Contract.cs:23/40/46` có đủ `ApprovalWorkflowId` / `CurrentWorkflowStepIndex` / `CurrentApprovalLevelOrder`; `:16` `DrafterUserId`.
|
||||
- `BaseEntity.CreatedBy` kiểu **`Guid?`** (không phải string) ⇒ so trực tiếp với `actorUserId`.
|
||||
- `grep tests/` cho `ContractV2Pipeline|GetMyInboxQuery|ListContractsQuery|GetContractQuery` = **0 hit** ⇒ 3 handler này hiện KHÔNG có test cũ nào bám ⇒ sửa không thể phá test cũ qua đường assert; rủi ro duy nhất là compile + suite tổng.
|
||||
|
||||
---
|
||||
@ -0,0 +1,94 @@
|
||||
# sub-test-specialist-0 — W6 ĐỢT-2: 5 test PIN đường ống HĐ V2 (+2 bonus)
|
||||
|
||||
> Run: `2026-07-29-S161-khkk-w6-duongong` · Spec: `.claude/workflows/runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w6-duong-ong-hd-v2-29-07-2026.md` §③-B
|
||||
> Luật lượt này: CHỈ `tests/**` · KHÔNG sửa production · KHÔNG commit/push · ghi TỪNG MỤC trong lúc làm (chống #53).
|
||||
|
||||
---
|
||||
|
||||
## §0 — ĐO TRƯỚC KHI VIẾT (baseline tươi, KHÔNG tin số đề bài)
|
||||
|
||||
Lệnh: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1`
|
||||
|
||||
| Project | Passed | Failed | Skipped |
|
||||
|---|---|---|---|
|
||||
| SolutionErp.Domain.Tests | **45** | 0 | 0 |
|
||||
| SolutionErp.Infrastructure.Tests | **535** | 0 | 0 |
|
||||
| **TỔNG** | **580** | **0** | 0 |
|
||||
|
||||
⇒ Đề bài ghi 580 (45D+535I) — **KHỚP 3/3 con số**. Đích ≥ 585.
|
||||
|
||||
---
|
||||
|
||||
## §1 — ĐỌC CODE THẬT (test theo CODE, không theo prompt)
|
||||
|
||||
Diff production của đợt-1 (`git diff HEAD`, 2 file, +161/-10):
|
||||
|
||||
| Điểm sửa | Vị trí đo tươi | Nội dung |
|
||||
|---|---|---|
|
||||
| List — vế V2 + bản-cứng | `ContractFeatures.cs:305-314` | `myWorkflowIds.Contains(x.c.ApprovalWorkflowId)` + `Phase != DangSoanThao` · `isHardCopyActor && Phase == DaPhatHanh` |
|
||||
| Helper per-HĐ | `:375-395` | `HardCopyActorRoles = [Director, AuthorizedSigner, HrAdmin, CostControl]` · `ResolveUserApprovalWorkflowIdsAsync` trả `List<Guid?>` |
|
||||
| ListDeleted — CỐ Ý KHÔNG ghép | `:426-431` | chú thích tường minh: giữ `Drafter ∪ eligible-phase` |
|
||||
| Inbox — admin phase tường minh | `:487-497` | `AdminInboxPhases` = 7 legacy **+ `ChoDuyet`** |
|
||||
| Inbox — v2InboxIds + early-exit | `:514-527` | `eligiblePhases.Count == 0 && v2InboxIds.Count == 0` (2 nguồn) · `where … || v2InboxIds.Contains(c.Id)` |
|
||||
| Inbox — resolve con-trỏ | `:543-577` | in-memory join, `steps[idx]` theo **INDEX** (không phải `Order`), `Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` |
|
||||
| Detail — vế V2 per-HĐ | `:605-621` | `isV2Approver` hỏi theo `c.ApprovalWorkflowId` · `isDraftPhase` (S89) · `isHardCopyActor && Phase == DaPhatHanh` |
|
||||
| Guard trình | `ContractWorkflowService.cs:73-92` | `+Procurement` · `isCreator = actorUserId is Guid actorId && (CreatedBy == actorId ∨ DrafterUserId == actorId)` |
|
||||
|
||||
**3 bẫy rút từ CODE — quyết định thiết kế test:**
|
||||
|
||||
1. `CurrentWorkflowStepIndex` là **INDEX vào list sort theo `Order`**, KHÔNG phải `Order`
|
||||
(`:569-571` `steps[idx]`). ⇒ mọi seed workflow đa-Bước dùng `Step.Order = 5/15`
|
||||
để index 0/1 **không thể** trùng Order do may mắn.
|
||||
2. `CostControl` VỪA là hard-copy actor (`:376`) VỪA có eligible-phase legacy
|
||||
`{DangGopY, DangKiemTraCCM}` (`:359`) ⇒ dùng CostControl làm ca ÂM ở phase
|
||||
`ChoDuyet` là ca **mạnh nhất**: nó chứng vế bản-cứng bị **chặn theo phase**
|
||||
(`&& Phase == DaPhatHanh`), không phải role-blanket.
|
||||
3. Trap `CreatedBy`: `actorUserId is Guid` là thứ duy nhất chặn `null == null` khi
|
||||
actor null + `CreatedBy` null + decision=`Approve` (KHÔNG phải `AutoApprove`, nên
|
||||
`isSystem`=false). `TestApplicationDbContext` **không wire** `AuditingInterceptor`
|
||||
⇒ HĐ seed có `CreatedBy == null` **tự nhiên** — đúng cấu hình để đo trap này.
|
||||
|
||||
---
|
||||
|
||||
## §2 — 7 test đã viết + lượt chạy lọc ĐẦU TIÊN
|
||||
|
||||
File MỚI (duy nhất tao ghi trong `tests/`):
|
||||
`tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs`
|
||||
|
||||
| # | Tên | Loại |
|
||||
|---|---|---|
|
||||
| T1 | `Inbox_PendingLevelApprover_SeesChoDuyetContract` | PIN spec |
|
||||
| T2 | `Inbox_OtherLevel_DoesNotSeeContract` | PIN spec (ÂM) |
|
||||
| T3 | `View_V2Approver_NotDrafter_CanSeeChoDuyetContract` | PIN spec |
|
||||
| T4 | `List_HardCopyActor_NotAdmin_SeesDaPhatHanhContract` | PIN spec (+ khối XÓA MỀM §③-B `:31`) |
|
||||
| T5 | `View_UserOutsideWorkflow_WithUnrelatedRole_Gets403` | PIN spec (ÂM, role pin `CostControl`+`Procurement`) |
|
||||
| T6 | `Inbox_Admin_SeesChoDuyetContract` | BONUS (DR-4 / F-B13) |
|
||||
| T7 | `Submit_ProcurementAndCreator_Allowed_UnrelatedRoleAndNullActor_Forbidden` | BONUS (guard trình + bẫy actor-null) |
|
||||
|
||||
Lỗi biên dịch duy nhất: thiếu `using SolutionErp.Application.Contracts.Dtos;` (DTO nằm
|
||||
namespace `.Dtos`, không phải `.Contracts`) → vá 1 dòng.
|
||||
|
||||
Chạy lọc `--filter FullyQualifiedName~ContractV2PipelineTests`:
|
||||
**Failed: 0, Passed: 7, Total: 7** (4 s).
|
||||
|
||||
---
|
||||
|
||||
## §3 — CHỨNG LƯỚI CÓ RĂNG (fault-inject: hoàn nguyên production tạm thời)
|
||||
|
||||
⚠️ Test-after xanh ngay lượt đầu KHÔNG chứng được gì. Phải đo: 7 lưới này có ĐỎ dưới
|
||||
mã TRƯỚC W6 không (khuôn RED-proven-via-git-stash, S86).
|
||||
|
||||
🔴 **AN TOÀN / KHÔI PHỤC — đọc mục này TRƯỚC nếu tao chết giữa chừng (#53):**
|
||||
bản W6 của 2 file production được sao lưu 2 LỚP trước khi hoàn nguyên:
|
||||
- Lớp 1 — scratchpad:
|
||||
`C:\Users\pqhuy\AppData\Local\Temp\claude\D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP\24be4c29-c54d-4ed0-ba2a-8240f62de712\scratchpad\w6-backup\`
|
||||
(`ContractFeatures.cs` + `ContractWorkflowService.cs`)
|
||||
- Lớp 2 — `git stash` (git tự giữ, xem `git stash list`).
|
||||
|
||||
MD5 bản W6 ĐÚNG (đích của phép khôi phục):
|
||||
```
|
||||
c11e3820267019995bd9ed815eac483f src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs
|
||||
d564219fc9c5cb0857298ee97f6037b5 src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
|
||||
```
|
||||
Khôi phục = `git stash pop` (hoặc chép đè từ scratchpad) rồi **đối chiếu lại 2 MD5 trên**
|
||||
và `git diff --stat` phải ra lại `142 +` / `15 +` = **161 insertions(+), 10 deletions(-)**.
|
||||
@ -299,7 +299,19 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
|
||||
q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase));
|
||||
// [W6 S161] +2 vế cho đường ống V2 (spec §②-3). Vế V2 là THEO-TỪNG-HĐ
|
||||
// (workflow pin của CHÍNH HĐ đó), KHÔNG phải role-blanket — xem chú thích
|
||||
// khối helper dưới `GetEligiblePhases`.
|
||||
var myWorkflowIds = await ResolveUserApprovalWorkflowIdsAsync(db, userId, ct);
|
||||
var isHardCopyActor = HasHardCopyActorRole(currentUser.Roles);
|
||||
q = q.Where(x => x.c.DrafterUserId == userId
|
||||
|| eligiblePhases.Contains(x.c.Phase)
|
||||
// (a) có chân duyệt trong workflow ĐÃ PIN của HĐ này + HĐ đã rời Nháp (S89:
|
||||
// nháp = riêng tư người soạn, approver chỉ thấy khi đã gửi duyệt).
|
||||
|| (x.c.Phase != ContractPhase.DangSoanThao
|
||||
&& myWorkflowIds.Contains(x.c.ApprovalWorkflowId))
|
||||
// (b) vai bản-cứng khâu ký/đóng dấu → HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
|
||||
|| (isHardCopyActor && x.c.Phase == ContractPhase.DaPhatHanh));
|
||||
}
|
||||
|
||||
if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
|
||||
@ -349,6 +361,38 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
|
||||
AddIfAny([AppRoles.HrAdmin], ContractPhase.DangDongDau);
|
||||
return phases.ToList();
|
||||
}
|
||||
|
||||
// ===== [W6 S161] Vế V2 per-HĐ — CỐ Ý TÁCH KHỎI `GetEligiblePhases` =====
|
||||
// 🔴 KHÔNG được nhét `ChoDuyet`/`DaPhatHanh` vào `GetEligiblePhases`: hàm đó chỉ nhận
|
||||
// ROLE (không biết đang xét HĐ nào) ⇒ thêm phase vào đó = MỌI user mang role đó thấy
|
||||
// MỌI HĐ ở phase đó. Nặng hơn: nó là hàm DÙNG CHUNG với `:381` ListDeleted ⇒ HĐ
|
||||
// `ChoDuyet` đã XÓA MỀM sẽ rò sang màn "Đã xóa" (acceptance §③-B cấm đúng ca này).
|
||||
// Vì vậy 2 vế dưới chỉ gắn ở 2 call-site `:301` List + Detail (`GetContractQueryHandler`),
|
||||
// KHÔNG gắn ở ListDeleted — quyết định có chủ đích, xem chú thích tại chỗ `:381`.
|
||||
|
||||
/// Vai bản-cứng khâu ký / đóng dấu / phát hành (spec §②-3 — actor b.19/20/21): được xem
|
||||
/// HĐ **đã phát hành** (`DaPhatHanh`) dù không có chân trong workflow V2 nào. Cho GĐ4/W7.
|
||||
internal static readonly string[] HardCopyActorRoles =
|
||||
[AppRoles.Director, AppRoles.AuthorizedSigner, AppRoles.HrAdmin, AppRoles.CostControl];
|
||||
|
||||
internal static bool HasHardCopyActorRole(IReadOnlyList<string> userRoles)
|
||||
=> userRoles.Any(r => HardCopyActorRoles.Contains(r));
|
||||
|
||||
/// Các workflow V2 mà `userId` có chân duyệt (BẤT KỲ Bước/Cấp) — bản-set cho query LIST,
|
||||
/// mirror `ContractSigningPlanFeatures.cs:275-283`; bản 1-phiếu là `AnyAsync` kiểu PE
|
||||
/// `PurchaseEvaluationFeatures.cs:889-891`.
|
||||
/// 🔴 Trả `List<Guid?>` CÓ CHỦ ĐÍCH: cột `Contract.ApprovalWorkflowId` là `Guid?` ⇒
|
||||
/// `myWorkflowIds.Contains(x.c.ApprovalWorkflowId)` dịch thẳng `IN (...)` không cần
|
||||
/// `.Value` (NULL không bao giờ khớp `IN` ⇒ HĐ V1 legacy tự rớt khỏi vế này).
|
||||
internal static async Task<List<Guid?>> ResolveUserApprovalWorkflowIdsAsync(
|
||||
IApplicationDbContext db, Guid? userId, CancellationToken ct)
|
||||
=> userId is null
|
||||
? []
|
||||
: await db.ApprovalWorkflowLevels.AsNoTracking()
|
||||
.Where(l => l.ApproverUserId == userId.Value)
|
||||
.Select(l => (Guid?)l.Step!.ApprovalWorkflowId)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
// ========== LIST DELETED contracts — màn "Đã xóa" (chỉ-xem) ==========
|
||||
@ -379,6 +423,11 @@ public class ListDeletedContractsQueryHandler(IApplicationDbContext db, ICurrent
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
|
||||
// 🔴 [W6 S161] CALL-SITE THỨ 3 — CỐ Ý **KHÔNG** ghép 2 vế V2 (`myWorkflowIds` /
|
||||
// `HardCopyActorRoles`) đã thêm ở List `:301` + Detail. Đây là màn "Đã xóa":
|
||||
// acceptance W6 §③-B đòi HĐ `ChoDuyet` xóa mềm KHÔNG được rò sang user V2.
|
||||
// Scope giữ NGUYÊN (Drafter ∪ eligible-phase-role) = 0 dòng thay đổi hành vi.
|
||||
// Đụng vào đây là mở lỗ, không phải "làm cho đủ 3 chỗ".
|
||||
q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase));
|
||||
}
|
||||
|
||||
@ -429,6 +478,24 @@ public class GetMyInboxQueryHandler(
|
||||
[ContractPhase.DangDongDau] = [AppRoles.HrAdmin],
|
||||
};
|
||||
|
||||
/// 🔴 [W6 S161 — DR-4] Phase Admin thấy trong Inbox, LIỆT KÊ TƯỜNG MINH (mirror
|
||||
/// `PurchaseEvaluationFeatures.cs:731-742`). Trước W6 admin lấy `PhaseActorRoles.Keys`,
|
||||
/// mà dict đó 100% phase legacy (0 key `ChoDuyet`) ⇒ ghép `v2InboxIds` xong admin VẪN
|
||||
/// mù HĐ chờ duyệt. Danh sách này = 7 phase legacy CŨ **+ `ChoDuyet`** ⇒ THUẦN CỘNG
|
||||
/// THÊM, không bỏ phase nào admin đang thấy (cố ý KHÔNG bê luật S89 "bỏ Nháp khỏi Inbox"
|
||||
/// của PE sang: đó là gỡ hành-vi, ngoài phạm vi W6).
|
||||
internal static readonly ContractPhase[] AdminInboxPhases =
|
||||
[
|
||||
ContractPhase.DangSoanThao,
|
||||
ContractPhase.DangGopY,
|
||||
ContractPhase.DangDamPhan,
|
||||
ContractPhase.DangInKy,
|
||||
ContractPhase.DangKiemTraCCM,
|
||||
ContractPhase.DangTrinhKy,
|
||||
ContractPhase.DangDongDau,
|
||||
ContractPhase.ChoDuyet,
|
||||
];
|
||||
|
||||
public async Task<List<ContractListItemDto>> Handle(GetMyInboxQuery request, CancellationToken ct)
|
||||
{
|
||||
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
|
||||
@ -438,18 +505,26 @@ public class GetMyInboxQueryHandler(
|
||||
|
||||
// Phase phù hợp với role hiện tại (Admin thấy tất cả phase chưa kết thúc)
|
||||
var eligiblePhases = isAdmin
|
||||
? PhaseActorRoles.Keys.ToList()
|
||||
? AdminInboxPhases.ToList()
|
||||
: PhaseActorRoles
|
||||
.Where(kv => kv.Value.Any(r => userRoles.Contains(r)))
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
|
||||
if (eligiblePhases.Count == 0) return [];
|
||||
// [W6 S161] V2-aware: HĐ pin V2 + Phase=ChoDuyet + actor là approver CẤP ĐANG CHỜ.
|
||||
// Admin đi bằng nhánh PHASE (`ChoDuyet` ở trên) nên để rỗng — mirror PE `:746-748`.
|
||||
var v2InboxIds = isAdmin
|
||||
? new HashSet<Guid>()
|
||||
: await ResolveV2InboxIdsAsync(currentUser.UserId, ct);
|
||||
|
||||
// 🔴 Điều kiện thoát sớm phải xét CẢ 2 nguồn: user chỉ có chân duyệt V2 mà không mang
|
||||
// role legacy nào (eligiblePhases rỗng) vẫn PHẢI thấy hộp thư của mình.
|
||||
if (eligiblePhases.Count == 0 && v2InboxIds.Count == 0) return [];
|
||||
|
||||
var q = from c in db.Contracts.AsNoTracking()
|
||||
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
|
||||
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
|
||||
where eligiblePhases.Contains(c.Phase)
|
||||
where eligiblePhases.Contains(c.Phase) || v2InboxIds.Contains(c.Id)
|
||||
orderby c.SlaDeadline ?? DateTime.MaxValue
|
||||
select new ContractListItemDto(
|
||||
c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase,
|
||||
@ -458,6 +533,48 @@ public class GetMyInboxQueryHandler(
|
||||
|
||||
return await q.Take(100).ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// Precompute ID các HĐ mà `userId` là approver của Cấp ĐANG CHỜ — mirror
|
||||
/// `PurchaseEvaluationFeatures.cs:817-852` (và bản KHKK `ContractSigningPlanFeatures.cs:229-264`).
|
||||
/// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list Steps
|
||||
/// đã sort theo `Order`, KHÔNG phải chính `Order` ⇒ không dịch thẳng sang SQL được.
|
||||
/// So Cấp bằng GIÁ TRỊ `Level.Order` + lấy `Any(...)` ⇒ giữ nguyên OR-of-N cùng Cấp.
|
||||
/// HĐ xóa mềm tự rớt: `ContractConfiguration.cs:44` có global filter `!IsDeleted`.
|
||||
private async Task<HashSet<Guid>> ResolveV2InboxIdsAsync(Guid? userId, CancellationToken ct)
|
||||
{
|
||||
if (userId is null) return new HashSet<Guid>();
|
||||
|
||||
var candidates = await (
|
||||
from c in db.Contracts.AsNoTracking()
|
||||
where c.Phase == ContractPhase.ChoDuyet
|
||||
&& c.ApprovalWorkflowId != null
|
||||
&& c.CurrentWorkflowStepIndex != null
|
||||
&& c.CurrentApprovalLevelOrder != null
|
||||
select new { c.Id, c.ApprovalWorkflowId, c.CurrentWorkflowStepIndex, c.CurrentApprovalLevelOrder }
|
||||
).ToListAsync(ct);
|
||||
|
||||
if (candidates.Count == 0) return new HashSet<Guid>();
|
||||
|
||||
var wfIds = candidates.Select(c => c.ApprovalWorkflowId!.Value).Distinct().ToList();
|
||||
var workflows = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => wfIds.Contains(w.Id))
|
||||
.Include(w => w.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToDictionaryAsync(w => w.Id, ct);
|
||||
|
||||
var result = new HashSet<Guid>();
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (!workflows.TryGetValue(c.ApprovalWorkflowId!.Value, out var wf)) continue;
|
||||
var steps = wf.Steps.OrderBy(s => s.Order).ToList();
|
||||
var idx = c.CurrentWorkflowStepIndex!.Value;
|
||||
if (idx < 0 || idx >= steps.Count) continue;
|
||||
var match = steps[idx].Levels.Any(l =>
|
||||
l.Order == c.CurrentApprovalLevelOrder!.Value && l.ApproverUserId == userId.Value);
|
||||
if (match) result.Add(c.Id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== GET detail ==========
|
||||
@ -485,7 +602,22 @@ public class GetContractQueryHandler(
|
||||
var isDrafter = c.DrafterUserId == currentUser.UserId;
|
||||
var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
|
||||
var isEligibleByRole = eligiblePhases.Contains(c.Phase);
|
||||
if (!isDrafter && !isEligibleByRole)
|
||||
// [W6 S161] Vế V2 THEO-TỪNG-HĐ — mirror PE `PurchaseEvaluationFeatures.cs:886-898`:
|
||||
// actor có chân `ApproverUserId` ở BẤT KỲ Cấp nào của workflow ĐÃ PIN CỦA HĐ NÀY.
|
||||
// Hỏi theo `c.ApprovalWorkflowId` (không phải theo role) ⇒ user ngoài workflow vẫn 403.
|
||||
var isV2Approver = false;
|
||||
if (c.ApprovalWorkflowId is Guid awIdForCheck && currentUser.UserId is Guid uidForCheck)
|
||||
{
|
||||
isV2Approver = await db.ApprovalWorkflowLevels.AsNoTracking()
|
||||
.AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
|
||||
&& l.ApproverUserId == uidForCheck, ct);
|
||||
}
|
||||
// [S89 mirror PE] Nháp = RIÊNG TƯ người soạn: V2-approver KHÔNG mở được HĐ còn Nháp.
|
||||
var isDraftPhase = c.Phase == ContractPhase.DangSoanThao;
|
||||
// Vai bản-cứng ký/đóng dấu → xem được HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
|
||||
var isHardCopyActor = ListContractsQueryHandler.HasHardCopyActorRole(currentUser.Roles)
|
||||
&& c.Phase == ContractPhase.DaPhatHanh;
|
||||
if (!isDrafter && !isEligibleByRole && !isHardCopyActor && (isDraftPhase || !isV2Approver))
|
||||
throw new ForbiddenException("Bạn không có quyền xem HĐ này.");
|
||||
}
|
||||
|
||||
|
||||
@ -70,9 +70,22 @@ public class ContractWorkflowService(
|
||||
if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai)
|
||||
&& (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
|
||||
{
|
||||
// [W6 S161] Nới vai được TRÌNH: `Drafter ∨ DeptManager ∨ người-tạo ∨ Procurement`.
|
||||
// Lỗ đo 07-29: nhân sự Phòng Mua hàng (role `Procurement`) bị 403 khi trình HĐ do
|
||||
// chính mình lập. Vẫn giữ 403 cho role NGOÀI 4 vế này.
|
||||
// 🔴 `actorUserId is Guid` là BẮT BUỘC, không được viết thẳng
|
||||
// `contract.CreatedBy == actorUserId`: cả hai đều `Guid?` ⇒ HĐ seed/legacy có
|
||||
// `CreatedBy == null` gặp actor null sẽ ra `null == null` = TRUE và lọt guard.
|
||||
// Xét cả `DrafterUserId` vì đó là field handler tạo HĐ gán tường minh
|
||||
// (`ContractFeatures.cs:96`), còn `CreatedBy` do `AuditingInterceptor.cs:45` gán —
|
||||
// HĐ nạp từ seed có thể trống ô sau.
|
||||
var isCreator = actorUserId is Guid actorId
|
||||
&& (contract.CreatedBy == actorId || contract.DrafterUserId == actorId);
|
||||
if (!isAdmin && !isSystem
|
||||
&& !actorRoles.Contains(AppRoles.Drafter)
|
||||
&& !actorRoles.Contains(AppRoles.DeptManager))
|
||||
&& !actorRoles.Contains(AppRoles.DeptManager)
|
||||
&& !actorRoles.Contains(AppRoles.Procurement)
|
||||
&& !isCreator)
|
||||
{
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ.");
|
||||
|
||||
@ -0,0 +1,660 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.Contracts;
|
||||
using SolutionErp.Application.Contracts.Dtos;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [W6 ĐỢT-2 — S161 2026-07-29] Đường ống HĐ V2: xem / hộp thư / vai được trình.
|
||||
// Spec: `runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w6-duong-ong-hd-v2-29-07-2026.md` §③-B.
|
||||
// Timing = **test-after** (đợt-1 đã land, build sạch, 580 xanh) ⇒ 7 test này phải XANH ngay.
|
||||
// Đỏ ở đây = hồi quy thật, KHÔNG được sửa test cho vừa.
|
||||
//
|
||||
// 5 tên đầu PIN theo spec (`:27`) — thiếu/đổi 1 chữ là TRƯỢT acceptance. 2 cái cuối là bonus
|
||||
// (DR-4 admin-inbox + bẫy `CreatedBy` null của guard trình).
|
||||
//
|
||||
// ── Đo cái gì (map 1:1 spec §③-B) ────────────────────────────────────────────────
|
||||
// T1 hộp thư: approver Cấp ĐANG CHỜ thấy HĐ ChoDuyet T2 (ÂM) cấp/bước khác KHÔNG thấy
|
||||
// T3 xem: approver V2 không-phải-người-soạn mở được T4 LIST: vai bản-cứng thấy HĐ đã phát hành
|
||||
// (+ khối kiểm hở XÓA MỀM, §③-B `:31`)
|
||||
// T5 (ÂM) người ngoài quy trình, role CỤ THỂ → 403 T6 bonus: admin thấy ChoDuyet (DR-4)
|
||||
// T7 bonus: guard trình nới đúng vai + bịt bẫy actor-null
|
||||
//
|
||||
// ── 3 quyết định hạ tầng (điều kiện để phép đo CÓ NGHĨA) ─────────────────────────
|
||||
//
|
||||
// (1) `Step.Order = 5 / 15`, KHÔNG phải 1 / 2. `CurrentWorkflowStepIndex` là **INDEX vào list
|
||||
// đã sort theo Order** chứ không phải giá trị `Order` (`ContractFeatures.cs:569-571`
|
||||
// `steps[idx]`). Để Order = 1/2 thì index 0/1 và Order 1/2 lệch nhau đúng 1 — một cài đặt
|
||||
// sai kiểu `First(s => s.Order == idx)` vẫn có thể vô tình đúng ở vài ca. Order 5/15 làm
|
||||
// hai đại lượng KHÔNG THỂ trùng nhau do may mắn.
|
||||
//
|
||||
// (2) `TestApplicationDbContext` **KHÔNG wire** `AuditingInterceptor` ⇒ (a) `CreatedBy` của HĐ
|
||||
// seed là **null tự nhiên** — đúng cấu hình để đo bẫy `null == null` ở T7; (b) `Remove()`
|
||||
// là xoá **CỨNG**, nên khối xoá-mềm của T4 set thẳng `IsDeleted = true` chứ KHÔNG gọi
|
||||
// `Remove` (bài S155: đo "xoá mềm" trên xoá cứng = đo nhầm cơ chế, vì rào ở đây là
|
||||
// global-filter che dòng, không phải dòng biến mất).
|
||||
//
|
||||
// (3) Mỗi test dựng `IdentityFixture` RIÊNG (SQLite :memory: riêng) ⇒ HĐ của test này không
|
||||
// lọt vào hộp thư/danh sách của test kia. Các assert dạng `BeEquivalentTo(<tập đủ>)` chỉ
|
||||
// có nghĩa khi thế giới đóng.
|
||||
public class ContractV2PipelineTests
|
||||
{
|
||||
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ===================================================================
|
||||
// Seed helpers
|
||||
// ===================================================================
|
||||
|
||||
private static TestCurrentUser As(User u, params string[] roles)
|
||||
=> new(u.Id, u.FullName, u.Email, roles);
|
||||
|
||||
private static async Task<(Supplier sup, Project proj)> SeedMasterAsync(TestApplicationDbContext db)
|
||||
{
|
||||
var sup = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "BTBM",
|
||||
Name = "NCC Bê tông Bình Minh",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
var proj = new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "Dự án FLOCK 01" };
|
||||
db.Suppliers.Add(sup);
|
||||
db.Projects.Add(proj);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (sup, proj);
|
||||
}
|
||||
|
||||
/// `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1** (nhiều NV cùng Cấp = OR-of-N).
|
||||
/// `Step.Order` cố ý chạy 5 / 15 / 25 — xem ghi chú hạ tầng (1).
|
||||
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
|
||||
TestApplicationDbContext db, string code, Guid[][][] steps)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Name = $"Quy trình duyệt HĐ {code}",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
for (var s = 0; s < steps.Length; s++)
|
||||
{
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 5 + (s * 10), // 5 / 15 / 25 ≠ index 0 / 1 / 2
|
||||
Name = $"Bước {s + 1}",
|
||||
DepartmentId = null, // null = bỏ qua FK phòng ban (khuôn Pattern 11)
|
||||
};
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
|
||||
for (var l = 0; l < steps[s].Length; l++)
|
||||
{
|
||||
foreach (var approverId in steps[s][l])
|
||||
{
|
||||
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = l + 1, // Cấp 1-based
|
||||
Name = $"Cấp {l + 1}",
|
||||
ApproverUserId = approverId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
private static async Task<Contract> AddContractAsync(
|
||||
TestApplicationDbContext db,
|
||||
Supplier sup,
|
||||
Project proj,
|
||||
Guid drafterId,
|
||||
ContractPhase phase,
|
||||
string tenHopDong,
|
||||
Guid? workflowId = null,
|
||||
int? stepIndex = null,
|
||||
int? levelOrder = null)
|
||||
{
|
||||
var c = new Contract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ContractType.HopDongThauPhu,
|
||||
Phase = phase,
|
||||
SupplierId = sup.Id,
|
||||
ProjectId = proj.Id,
|
||||
DrafterUserId = drafterId,
|
||||
TenHopDong = tenHopDong,
|
||||
GiaTri = 100_000_000m,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = stepIndex,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
};
|
||||
db.Contracts.Add(c);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Act helpers — gọi HANDLER THẬT, không mock
|
||||
// ===================================================================
|
||||
|
||||
private static Task<List<ContractListItemDto>> InboxAsync(
|
||||
TestApplicationDbContext db, TestCurrentUser actor)
|
||||
=> new GetMyInboxQueryHandler(db, actor).Handle(new GetMyInboxQuery(), CancellationToken.None);
|
||||
|
||||
private static Task<PagedResult<ContractListItemDto>> ListAsync(
|
||||
TestApplicationDbContext db, TestCurrentUser actor, ContractPhase? phase = null)
|
||||
=> new ListContractsQueryHandler(db, actor)
|
||||
.Handle(new ListContractsQuery(Phase: phase) { PageSize = 200 }, CancellationToken.None);
|
||||
|
||||
private static Task<PagedResult<ContractListItemDto>> ListDeletedAsync(
|
||||
TestApplicationDbContext db, TestCurrentUser actor)
|
||||
=> new ListDeletedContractsQueryHandler(db, actor)
|
||||
.Handle(new ListDeletedContractsQuery() { PageSize = 200 }, CancellationToken.None);
|
||||
|
||||
private static Task<ContractDetailDto> ViewAsync(
|
||||
TestApplicationDbContext db, UserManager<User> um, TestCurrentUser actor, Guid contractId)
|
||||
=> new GetContractQueryHandler(db, um, actor).Handle(new GetContractQuery(contractId), CancellationToken.None);
|
||||
|
||||
private static async Task SoftDeleteAsync(TestApplicationDbContext db, Contract c)
|
||||
{
|
||||
// KHÔNG dùng `db.Contracts.Remove(c)`: fixture không wire `AuditingInterceptor` nên
|
||||
// `Remove` = xoá CỨNG (bài S155) ⇒ sẽ đo nhầm cơ chế. Rào thật là global query filter
|
||||
// `!IsDeleted` (`ContractConfiguration.cs:44`) + `IgnoreQueryFilters().Where(IsDeleted)`
|
||||
// ở màn "Đã xóa" — muốn đo nó thì phải để DÒNG CÒN ĐÓ và chỉ bật cờ.
|
||||
c.IsDeleted = true;
|
||||
c.DeletedAt = Clock2026;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T1) Hộp thư — approver của Cấp ĐANG CHỜ thấy HĐ `ChoDuyet`. Spec §③-B `:27` tên #1.
|
||||
//
|
||||
// Trước W6 hộp thư 100% chạy bằng `PhaseActorRoles` (`ContractFeatures.cs:470-479`) — dict
|
||||
// đó KHÔNG có key `ChoDuyet` ⇒ mọi HĐ V2 đang chờ duyệt vô hình với đúng người phải duyệt.
|
||||
//
|
||||
// 🔴 Approver A cố ý mang **0 role**: khi đó `eligiblePhases` rỗng, nên đường DUY NHẤT để A
|
||||
// thấy HĐ là `v2InboxIds`. Điều này đồng thời ghim luôn sửa đổi ở `:520-522` — điều kiện
|
||||
// thoát sớm phải xét CẢ HAI nguồn; giữ nguyên `if (eligiblePhases.Count == 0) return [];`
|
||||
// thì A nhận hộp thư rỗng dù `v2InboxIds` có phiếu (một cái return sớm giết cả tính năng).
|
||||
// Approver B mang role thật (`Procurement`) ⇒ chứng vế OR mới không đè vế legacy.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Inbox_PendingLevelApprover_SeesChoDuyetContract()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (sup, proj) = await SeedMasterAsync(db);
|
||||
|
||||
var drafter = await fix.CreateUserAsync("t1-drafter@test.local", "BCH soạn HĐ", null, [AppRoles.Drafter]);
|
||||
var a = await fix.CreateUserAsync("t1-a@test.local", "NV Cung ứng A", null, []);
|
||||
var b = await fix.CreateUserAsync("t1-b@test.local", "NV Cung ứng B", null, [AppRoles.Procurement]);
|
||||
var c2 = await fix.CreateUserAsync("t1-c@test.local", "Trưởng phòng C", null, []);
|
||||
|
||||
// 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
|
||||
var wf = await SeedWorkflowAsync(db, "QT-CT-T1", [[[a.Id, b.Id], [c2.Id]]]);
|
||||
|
||||
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
|
||||
"HĐ đang chờ Cấp 1", wf.Id, stepIndex: 0, levelOrder: 1);
|
||||
var draft = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangSoanThao,
|
||||
"HĐ còn Nháp", wf.Id, stepIndex: null, levelOrder: null);
|
||||
|
||||
// A: 0 role ⇒ chỉ có thể thấy qua nhánh V2 (xem ghi chú đầu test).
|
||||
var inboxA = await InboxAsync(db, As(a));
|
||||
inboxA.Select(i => i.Id).Should().BeEquivalentTo(new[] { pending.Id },
|
||||
"approver của Cấp ĐANG CHỜ phải thấy HĐ trong hộp thư — trước W6 dict `PhaseActorRoles` "
|
||||
+ "không có key `ChoDuyet` nên hộp thư rỗng và HĐ V2 treo vô thời hạn");
|
||||
inboxA.Single().Phase.Should().Be(ContractPhase.ChoDuyet);
|
||||
|
||||
// B cùng Cấp 1 (OR-of-N) + có role thật ⇒ cũng phải thấy.
|
||||
(await InboxAsync(db, As(b, AppRoles.Procurement))).Select(i => i.Id)
|
||||
.Should().BeEquivalentTo(new[] { pending.Id },
|
||||
"OR-of-N: người thứ hai cùng Cấp cũng thấy; chỉ 1 trong 2 thấy = match cứng theo NV đầu danh sách");
|
||||
|
||||
// Người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt'.
|
||||
// (Drafter có role `Drafter` ⇒ `eligiblePhases` = {DangSoanThao, DangDamPhan, DangInKy}
|
||||
// ⇒ HĐ Nháp của chính mình VẪN nằm trong hộp thư theo luật legacy — W6 cố ý KHÔNG gỡ.)
|
||||
var inboxDrafter = await InboxAsync(db, As(drafter, AppRoles.Drafter));
|
||||
inboxDrafter.Select(i => i.Id).Should().NotContain(pending.Id,
|
||||
"người soạn không có chân duyệt ⇒ HĐ ChoDuyet không phải việc của họ");
|
||||
inboxDrafter.Select(i => i.Id).Should().Contain(draft.Id,
|
||||
"vế legacy (`PhaseActorRoles[DangSoanThao]`) phải còn nguyên — W6 là THUẦN CỘNG THÊM");
|
||||
|
||||
// HĐ Nháp không lọt hộp thư approver (`ResolveV2InboxIdsAsync` lọc `Phase == ChoDuyet`).
|
||||
inboxA.Select(i => i.Id).Should().NotContain(draft.Id,
|
||||
"HĐ chưa trình thì chưa chờ ai duyệt — lọt vào đây là approver đọc được bản nháp riêng tư");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T2) ÂM — Cấp khác / Bước khác KHÔNG thấy. Spec §③-B `:27` tên #2.
|
||||
//
|
||||
// Đây là lưới chặn cách "sửa cho xong" nguy hiểm nhất: precompute id-set theo **workflow**
|
||||
// thay vì theo **con-trỏ** (tức ai có chân trong quy trình cũng thấy mọi HĐ dùng quy trình
|
||||
// đó). Nó làm T1 xanh y hệt, chỉ vỡ ở đây.
|
||||
//
|
||||
// 🔴 Mỗi ca ÂM đều đi kèm CHỨNG NHÂN DƯƠNG của cùng người đó (C thấy HĐ khi con-trỏ chỉ
|
||||
// Cấp 2; D thấy HĐ ở Bước 2). Thiếu vế này thì một `ResolveV2InboxIdsAsync` hỏng-luôn
|
||||
// (trả rỗng mọi lúc) cũng cho test xanh — đúng kiểu vacuous-pass.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Inbox_OtherLevel_DoesNotSeeContract()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (sup, proj) = await SeedMasterAsync(db);
|
||||
|
||||
var drafter = await fix.CreateUserAsync("t2-drafter@test.local", "BCH soạn HĐ", null, []);
|
||||
var a = await fix.CreateUserAsync("t2-a@test.local", "Bước 1 Cấp 1", null, []);
|
||||
var c2 = await fix.CreateUserAsync("t2-c@test.local", "Bước 1 Cấp 2", null, []);
|
||||
var d = await fix.CreateUserAsync("t2-d@test.local", "Bước 2 Cấp 1", null, []);
|
||||
var outsider = await fix.CreateUserAsync("t2-out@test.local", "Ngoài quy trình", null, []);
|
||||
|
||||
// Bước 1 (Order 5): Cấp 1 = {A}, Cấp 2 = {C} · Bước 2 (Order 15): Cấp 1 = {D}.
|
||||
var wf = await SeedWorkflowAsync(db, "QT-CT-T2", [[[a.Id], [c2.Id]], [[d.Id]]]);
|
||||
|
||||
var atStep0Level1 = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
|
||||
"HĐ chờ Bước 1 Cấp 1", wf.Id, stepIndex: 0, levelOrder: 1);
|
||||
var atStep1Level1 = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
|
||||
"HĐ chờ Bước 2 Cấp 1", wf.Id, stepIndex: 1, levelOrder: 1);
|
||||
|
||||
// ---- ca ÂM 1: C có chân trong CHÍNH quy trình đó nhưng Ở CẤP KHÁC.
|
||||
(await InboxAsync(db, As(c2))).Should().BeEmpty(
|
||||
"Cấp 2 chưa tới lượt ⇒ hộp thư rỗng; thấy = duyệt vượt lượt ngay từ hộp thư");
|
||||
|
||||
// ---- ca ÂM 2: D ở BƯỚC KHÁC — chỉ thấy HĐ đang đứng ở bước của mình.
|
||||
(await InboxAsync(db, As(d))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep1Level1.Id },
|
||||
"D chỉ thấy HĐ có con-trỏ Bước = index 1; thấy cả HĐ Bước 1 nghĩa là id-set gom theo "
|
||||
+ "QUY TRÌNH chứ không theo CON-TRỎ");
|
||||
|
||||
// A đối xứng: chỉ thấy HĐ Bước 1.
|
||||
(await InboxAsync(db, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep0Level1.Id },
|
||||
"`CurrentWorkflowStepIndex` là INDEX vào list sort theo Order (5/15), KHÔNG phải Order — "
|
||||
+ "đọc nhầm semantic thì A nhận HĐ của D hoặc không nhận gì");
|
||||
|
||||
(await InboxAsync(db, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc ở đây");
|
||||
|
||||
// ---- CHỨNG NHÂN DƯƠNG: đẩy con-trỏ sang Cấp 2 ⇒ C thấy, A hết thấy.
|
||||
// Cùng một HĐ, cùng một người, đổi ĐÚNG MỘT biến (con-trỏ Cấp).
|
||||
atStep0Level1.CurrentApprovalLevelOrder = 2;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
(await InboxAsync(db, As(c2))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep0Level1.Id },
|
||||
"chuyển con-trỏ sang Cấp 2 thì C PHẢI thấy — nếu C vẫn rỗng thì ca ÂM ở trên xanh vì "
|
||||
+ "hộp thư hỏng-luôn, không phải vì lọc đúng cấp");
|
||||
(await InboxAsync(db, As(a))).Should().BeEmpty(
|
||||
"và A phải mất quyền tương ứng — hộp thư đi theo con-trỏ ở CẢ HAI CHIỀU");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T3) Xem — approver V2 KHÔNG phải người soạn mở được HĐ `ChoDuyet`. Spec §③-B `:27` tên #3.
|
||||
//
|
||||
// 3 vế chống "mở toang" (risk C của spec `:33`), mỗi vế là một cách hỏng khác nhau:
|
||||
// • cross-workflow: approver của quy trình KHÁC vẫn 403 ⇒ điều kiện là THEO-TỪNG-HĐ
|
||||
// (hỏi theo `c.ApprovalWorkflowId`), không phải theo role/theo "là approver ở đâu đó".
|
||||
// • Nháp: chính approver đó vẫn 403 khi HĐ chưa trình (luật S89, `ContractFeatures.cs:615-616`).
|
||||
// • Cấp 2 (chưa tới lượt) VẪN xem được — đây là ngữ nghĩa CỐ Ý của mã (`:611-613` hỏi
|
||||
// `Any` trên MỌI Cấp của quy trình): xem-được ≠ tới-lượt. Ghim lại để lần sau ai siết
|
||||
// thành "chỉ Cấp hiện tại" thì biết mình đang ĐỔI HỢP ĐỒNG, không phải sửa bug.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task View_V2Approver_NotDrafter_CanSeeChoDuyetContract()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var (sup, proj) = await SeedMasterAsync(db);
|
||||
|
||||
var drafter = await fix.CreateUserAsync("t3-drafter@test.local", "BCH soạn HĐ", null, []);
|
||||
var a = await fix.CreateUserAsync("t3-a@test.local", "Approver Cấp 1", null, [AppRoles.Procurement]);
|
||||
var b = await fix.CreateUserAsync("t3-b@test.local", "Approver Cấp 2", null, [AppRoles.Procurement]);
|
||||
var other = await fix.CreateUserAsync("t3-other@test.local", "Approver quy trình KHÁC", null, [AppRoles.Procurement]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, "QT-CT-T3", [[[a.Id], [b.Id]]]);
|
||||
var wfOther = await SeedWorkflowAsync(db, "QT-CT-T3-KHAC", [[[other.Id]]]);
|
||||
|
||||
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
|
||||
"HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
|
||||
var draft = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangSoanThao,
|
||||
"HĐ còn Nháp", wf.Id, stepIndex: null, levelOrder: null);
|
||||
|
||||
var actorA = As(a, AppRoles.Procurement);
|
||||
|
||||
// Sanity chống vacuous: role của A KHÔNG cho A xem HĐ ở phase này bằng đường legacy.
|
||||
ListContractsQueryHandler.GetEligiblePhases(actorA.Roles).Should().NotContain(ContractPhase.ChoDuyet,
|
||||
"nếu role `Procurement` đã tự cho xem `ChoDuyet` thì test này không đo được vế V2 nào cả");
|
||||
pending.DrafterUserId.Should().NotBe(a.Id, "A phải KHÔNG phải người soạn — đó là điểm của tên test");
|
||||
|
||||
// ---- Vế chính: A mở được.
|
||||
var dto = await ViewAsync(db, um, actorA, pending.Id);
|
||||
dto.Id.Should().Be(pending.Id);
|
||||
dto.ApprovalWorkflowId.Should().Be(wf.Id, "mở đúng HĐ pin đúng quy trình, không phải DTO rỗng");
|
||||
dto.Phase.Should().Be(ContractPhase.ChoDuyet);
|
||||
|
||||
// ---- Cấp 2 chưa tới lượt vẫn XEM được (ngữ nghĩa cố ý — xem ghi chú đầu test).
|
||||
(await ViewAsync(db, um, As(b, AppRoles.Procurement), pending.Id)).Id.Should().Be(pending.Id,
|
||||
"mã hỏi `Any` trên MỌI Cấp của quy trình đã pin ⇒ approver Cấp sau đọc được hồ sơ "
|
||||
+ "trước lượt mình (quyền XEM ≠ quyền DUYỆT — chặn duyệt là việc của service)");
|
||||
|
||||
// ---- 🔴 Cross-workflow: approver quy trình KHÁC vẫn 403 (đây là vế 'per-HĐ' thật sự).
|
||||
var actOther = async () => await ViewAsync(db, um, As(other, AppRoles.Procurement), pending.Id);
|
||||
await actOther.Should().ThrowAsync<ForbiddenException>(
|
||||
"có chân duyệt ở MỘT quy trình nào đó không phải là vé xem HĐ pin quy trình KHÁC — "
|
||||
+ "hỏng vế này là mở toang cho mọi approver của hệ thống");
|
||||
|
||||
// ---- Nháp: chính A cũng 403 (S89).
|
||||
var actDraft = async () => await ViewAsync(db, um, actorA, draft.Id);
|
||||
await actDraft.Should().ThrowAsync<ForbiddenException>(
|
||||
"HĐ chưa trình là tài liệu riêng của người soạn; bỏ vế `isDraftPhase` thì approver "
|
||||
+ "đọc được bản nháp chưa ai muốn cho xem");
|
||||
|
||||
// Người soạn vẫn xem được bản nháp của mình — chứng 403 ở trên đến từ DANH TÍNH, không
|
||||
// phải vì HĐ nháp hỏng/không đọc được.
|
||||
(await ViewAsync(db, um, As(drafter), draft.Id)).Id.Should().Be(draft.Id);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T4) LIST — vai bản-cứng (không admin) thấy HĐ `DaPhatHanh`. Spec §③-B `:27` tên #4
|
||||
// (review F-01: chưa wave nào đo màn DANH SÁCH).
|
||||
// + Khối 2 = kiểm hở XÓA MỀM (spec §③-B `:31`) — gộp vào đây vì cùng họ handler
|
||||
// `List*ContractsQueryHandler` và cùng actor, nên đo được cặp "còn sống ⟂ đã xoá"
|
||||
// chỉ khác ĐÚNG MỘT biến `IsDeleted`.
|
||||
//
|
||||
// Cặp đối chứng cốt lõi: CÙNG một HĐ `DaPhatHanh`, hai actor khác nhau ĐÚNG MỘT BIẾN là
|
||||
// role — Director (bản-cứng) thấy, Procurement (không bản-cứng) không thấy. Đó là bằng
|
||||
// chứng vế mới lọc theo `HardCopyActorRoles` chứ không phải "ai cũng thấy HĐ đã phát hành".
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task List_HardCopyActor_NotAdmin_SeesDaPhatHanhContract()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (sup, proj) = await SeedMasterAsync(db);
|
||||
|
||||
var drafter = await fix.CreateUserAsync("t4-drafter@test.local", "BCH soạn HĐ", null, []);
|
||||
var director = await fix.CreateUserAsync("t4-dir@test.local", "Giám đốc ký", null, [AppRoles.Director]);
|
||||
var pmh = await fix.CreateUserAsync("t4-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
|
||||
var approver = await fix.CreateUserAsync("t4-ap@test.local", "Approver V2", null, [AppRoles.Procurement]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, "QT-CT-T4", [[[approver.Id]]]);
|
||||
|
||||
var published = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DaPhatHanh,
|
||||
"HĐ đã phát hành", wf.Id, stepIndex: null, levelOrder: null);
|
||||
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
|
||||
"HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
|
||||
var trinhKy = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangTrinhKy,
|
||||
"HĐ legacy đang trình ký", null);
|
||||
|
||||
var actorDirector = As(director, AppRoles.Director);
|
||||
actorDirector.Roles.Should().NotContain(AppRoles.Admin, "tên test ghi rõ NOT admin — actor phải thật sự không phải admin");
|
||||
|
||||
// ---- Khối 1: LIST còn sống.
|
||||
var listDirector = await ListAsync(db, actorDirector);
|
||||
listDirector.Items.Select(i => i.Id).Should().Contain(published.Id,
|
||||
"vai bản-cứng khâu ký/đóng dấu phải thấy HĐ ĐÃ PHÁT HÀNH — trước W6 màn danh sách "
|
||||
+ "trả 0 dòng cho họ vì `GetEligiblePhases(Director)` chỉ có `DangTrinhKy`");
|
||||
listDirector.Items.Select(i => i.Id).Should().Contain(trinhKy.Id,
|
||||
"vế legacy theo role vẫn nguyên (Director ↔ DangTrinhKy) — W6 chỉ CỘNG THÊM");
|
||||
listDirector.Items.Select(i => i.Id).Should().NotContain(pending.Id,
|
||||
"vế bản-cứng bị chặn theo PHASE (`&& Phase == DaPhatHanh`); bỏ vế phase là Director "
|
||||
+ "thấy mọi HĐ đang chờ duyệt của toàn hệ thống");
|
||||
|
||||
// Lọc `?phase=9` — đúng đường FE gọi ở acceptance §③-B `:29`.
|
||||
(await ListAsync(db, actorDirector, ContractPhase.DaPhatHanh)).Items
|
||||
.Select(i => i.Id).Should().BeEquivalentTo(new[] { published.Id });
|
||||
|
||||
// ---- 🔴 Đối chứng 1 biến: cùng HĐ, role KHÔNG bản-cứng ⇒ không thấy.
|
||||
(await ListAsync(db, As(pmh, AppRoles.Procurement))).Items.Select(i => i.Id)
|
||||
.Should().NotContain(published.Id,
|
||||
"`Procurement` không nằm trong `HardCopyActorRoles` ⇒ vẫn không thấy HĐ đã phát hành; "
|
||||
+ "thấy được nghĩa là vế mới đang mở theo PHASE cho mọi người");
|
||||
|
||||
// Approver V2 thấy HĐ ChoDuyet của chính quy trình mình (vế V2 ở màn danh sách).
|
||||
(await ListAsync(db, As(approver, AppRoles.Procurement))).Items.Select(i => i.Id)
|
||||
.Should().Contain(pending.Id, "vế V2 per-HĐ cũng phải sống ở LIST, không chỉ ở Detail");
|
||||
|
||||
// ---- Khối 2: XÓA MỀM — không được rò sang màn "Đã xóa" (spec §③-B `:31`).
|
||||
await SoftDeleteAsync(db, published);
|
||||
await SoftDeleteAsync(db, pending);
|
||||
|
||||
(await ListAsync(db, actorDirector)).Items.Select(i => i.Id).Should().NotContain(published.Id,
|
||||
"global filter `!IsDeleted` che HĐ đã xoá khỏi màn danh sách thường");
|
||||
|
||||
var deletedForDirector = (await ListDeletedAsync(db, actorDirector)).Items.Select(i => i.Id).ToList();
|
||||
deletedForDirector.Should().NotContain(published.Id,
|
||||
"màn 'Đã xóa' CỐ Ý không ghép vế bản-cứng (`ContractFeatures.cs:426-431`) — cùng HĐ, cùng "
|
||||
+ "actor, chỉ đổi `IsDeleted` mà quyền lật ngược: đó là ranh giới đang được ghim");
|
||||
deletedForDirector.Should().NotContain(pending.Id);
|
||||
|
||||
(await ListDeletedAsync(db, As(approver, AppRoles.Procurement))).Items.Select(i => i.Id)
|
||||
.Should().NotContain(pending.Id,
|
||||
"approver V2 thấy HĐ lúc còn sống nhưng KHÔNG được thấy ở màn Đã xóa — đây đúng là "
|
||||
+ "cái lỗ mà spec cấm khi nhét `ChoDuyet` vào `GetEligiblePhases` dùng chung");
|
||||
|
||||
// CHỨNG NHÂN: người soạn VẪN thấy cả hai ở màn Đã xóa ⇒ 2 assert âm trên không xanh
|
||||
// nhờ handler trả rỗng cho tất cả mọi người.
|
||||
(await ListDeletedAsync(db, As(drafter))).Items.Select(i => i.Id)
|
||||
.Should().BeEquivalentTo(new[] { published.Id, pending.Id },
|
||||
"người soạn giữ nguyên quyền xem HĐ đã xoá của mình — chứng màn 'Đã xóa' vẫn chạy");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T5) ÂM — người ngoài quy trình, mang role CỤ THỂ → 403. Spec §③-B `:27` tên #5
|
||||
// (review F-S4: cấm chọn role dễ-pass).
|
||||
//
|
||||
// Chọn `CostControl` làm actor chính là CÓ CHỦ Ý và là ca mạnh nhất: nó VỪA nằm trong
|
||||
// `HardCopyActorRoles` (`ContractFeatures.cs:376`) VỪA có eligible-phase legacy
|
||||
// {DangGopY, DangKiemTraCCM}. Nếu vế bản-cứng bị viết thiếu `&& Phase == DaPhatHanh` thì
|
||||
// đúng người này lọt vào HĐ `ChoDuyet` — không role nào khác bắt được lỗi đó.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task View_UserOutsideWorkflow_WithUnrelatedRole_Gets403()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var (sup, proj) = await SeedMasterAsync(db);
|
||||
|
||||
var drafter = await fix.CreateUserAsync("t5-drafter@test.local", "BCH soạn HĐ", null, []);
|
||||
var approver = await fix.CreateUserAsync("t5-ap@test.local", "Approver hợp lệ", null, []);
|
||||
var ccm = await fix.CreateUserAsync("t5-ccm@test.local", "NV Kiểm soát chi phí", null, [AppRoles.CostControl]);
|
||||
var pmh = await fix.CreateUserAsync("t5-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, "QT-CT-T5", [[[approver.Id]]]);
|
||||
|
||||
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
|
||||
"HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
|
||||
var published = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DaPhatHanh,
|
||||
"HĐ đã phát hành", wf.Id, stepIndex: null, levelOrder: null);
|
||||
|
||||
var actorCcm = As(ccm, AppRoles.CostControl);
|
||||
var actorPmh = As(pmh, AppRoles.Procurement);
|
||||
|
||||
// Sanity: hai actor này ĐÚNG là 'ngoài quy trình' và không phải người soạn/admin.
|
||||
actorCcm.Roles.Should().NotContain(AppRoles.Admin);
|
||||
(await db.ApprovalWorkflowLevels.CountAsync(l => l.ApproverUserId == ccm.Id || l.ApproverUserId == pmh.Id))
|
||||
.Should().Be(0, "hai actor ca ÂM không được có chân ở bất kỳ Cấp nào — nếu có thì test đo nhầm vế");
|
||||
|
||||
// ---- 403 lớp 1: CostControl (role NẶNG: vừa bản-cứng vừa có eligible-phase legacy).
|
||||
var actCcm = async () => await ViewAsync(db, um, actorCcm, pending.Id);
|
||||
await actCcm.Should().ThrowAsync<ForbiddenException>(
|
||||
"`CostControl` là vai bản-cứng NHƯNG chỉ cho HĐ `DaPhatHanh`; HĐ đang `ChoDuyet` mà mở "
|
||||
+ "được nghĩa là vế bản-cứng thiếu chặn phase = mở toang theo role");
|
||||
|
||||
// ---- 403 lớp 2: Procurement (không bản-cứng, không eligible ChoDuyet).
|
||||
var actPmh = async () => await ViewAsync(db, um, actorPmh, pending.Id);
|
||||
await actPmh.Should().ThrowAsync<ForbiddenException>(
|
||||
"role ngoài cả hai điều kiện thì phải giữ nguyên 403 như trước W6");
|
||||
|
||||
// ---- 🔴 CHỨNG NHÂN 1: CHÍNH `ccm` mở được HĐ `DaPhatHanh` (đổi đúng 1 biến: phase).
|
||||
(await ViewAsync(db, um, actorCcm, published.Id)).Id.Should().Be(published.Id,
|
||||
"cùng người, cùng role, chỉ khác phase ⇒ 403 ở trên đến từ ĐIỀU KIỆN PER-HĐ, "
|
||||
+ "không phải vì guard chặn tất cả mọi thứ");
|
||||
|
||||
// ---- CHỨNG NHÂN 2: approver hợp lệ mở được chính HĐ `ChoDuyet` đó.
|
||||
(await ViewAsync(db, um, As(approver), pending.Id)).Id.Should().Be(pending.Id,
|
||||
"HĐ đó KHÔNG hỏng — đúng người vẫn mở được; 403 là về danh tính người hỏi");
|
||||
|
||||
// ---- Và `Procurement` vẫn 403 ngay cả với HĐ đã phát hành (không phải bản-cứng).
|
||||
var actPmhPublished = async () => await ViewAsync(db, um, actorPmh, published.Id);
|
||||
await actPmhPublished.Should().ThrowAsync<ForbiddenException>(
|
||||
"danh sách `HardCopyActorRoles` là danh sách ĐÓNG — thêm role vào đó là quyết định "
|
||||
+ "có chủ đích, không được rơi ra do sửa vặt");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T6 — BONUS, DR-4 / review F-B13) Admin thấy HĐ `ChoDuyet` trong hộp thư.
|
||||
//
|
||||
// Trước W6 admin lấy `eligiblePhases = PhaseActorRoles.Keys` mà dict đó 100% phase legacy
|
||||
// ⇒ ghép `v2InboxIds` xong admin VẪN mù (và admin CỐ Ý có `v2InboxIds` rỗng — `:516-518`).
|
||||
// Sửa = liệt kê tường minh `AdminInboxPhases` (`:487-497`).
|
||||
//
|
||||
// Đo bằng TẬP ĐỦ chứ không chỉ "có chứa": seed đủ 8 phase trong danh sách + 3 phase ngoài.
|
||||
// Cách này bắt CẢ HAI chiều — thêm `ChoDuyet` mà đánh rơi một phase legacy cũng ĐỎ.
|
||||
// Admin cố ý KHÔNG có chân trong quy trình ⇒ đường duy nhất là nhánh PHASE.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Inbox_Admin_SeesChoDuyetContract()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (sup, proj) = await SeedMasterAsync(db);
|
||||
|
||||
var drafter = await fix.CreateUserAsync("t6-drafter@test.local", "BCH soạn HĐ", null, []);
|
||||
var approver = await fix.CreateUserAsync("t6-ap@test.local", "Approver V2", null, []);
|
||||
var adminUser = await fix.CreateUserAsync("t6-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, "QT-CT-T6", [[[approver.Id]]]);
|
||||
|
||||
ContractPhase[] inboxPhases =
|
||||
[
|
||||
ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
|
||||
ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy,
|
||||
ContractPhase.DangDongDau, ContractPhase.ChoDuyet,
|
||||
];
|
||||
ContractPhase[] outOfInboxPhases =
|
||||
[
|
||||
ContractPhase.DaPhatHanh, ContractPhase.TuChoi, ContractPhase.TraLai,
|
||||
];
|
||||
|
||||
var expected = new List<Guid>();
|
||||
foreach (var ph in inboxPhases)
|
||||
{
|
||||
var c = await AddContractAsync(db, sup, proj, drafter.Id, ph, $"HĐ phase {ph}",
|
||||
ph == ContractPhase.ChoDuyet ? wf.Id : null,
|
||||
stepIndex: ph == ContractPhase.ChoDuyet ? 0 : null,
|
||||
levelOrder: ph == ContractPhase.ChoDuyet ? 1 : null);
|
||||
expected.Add(c.Id);
|
||||
}
|
||||
var outIds = new List<Guid>();
|
||||
foreach (var ph in outOfInboxPhases)
|
||||
outIds.Add((await AddContractAsync(db, sup, proj, drafter.Id, ph, $"HĐ phase {ph}", null)).Id);
|
||||
|
||||
var admin = As(adminUser, AppRoles.Admin);
|
||||
(await db.ApprovalWorkflowLevels.CountAsync(l => l.ApproverUserId == adminUser.Id))
|
||||
.Should().Be(0, "admin KHÔNG có chân trong quy trình ⇒ chỉ có thể thấy qua nhánh PHASE");
|
||||
|
||||
var inbox = (await InboxAsync(db, admin)).Select(i => i.Id).ToList();
|
||||
|
||||
inbox.Should().BeEquivalentTo(expected,
|
||||
"hộp thư admin = ĐÚNG 8 phase liệt tường minh: thiếu `ChoDuyet` là lỗi DR-4 gốc "
|
||||
+ "(admin mù HĐ chờ duyệt), thừa/thiếu phase legacy là W6 đã lỡ tay gỡ hành-vi cũ");
|
||||
inbox.Should().NotIntersectWith(outIds,
|
||||
"phase kết thúc (đã phát hành / từ chối / trả lại) không phải việc đang chờ xử lý");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T7 — BONUS) Guard trình: nới đúng vai, và KHÔNG nới nhầm cho actor null.
|
||||
//
|
||||
// Lỗ W6 #3: NV Phòng Mua hàng (`Procurement`) lập HĐ rồi bị 403 khi bấm Trình.
|
||||
// Sửa = `Drafter ∨ DeptManager ∨ Procurement ∨ người-tạo` (`ContractWorkflowService.cs:84-88`).
|
||||
//
|
||||
// 🔴 Ca 4 là lý do test này tồn tại. `contract.CreatedBy` và `actorUserId` ĐỀU là `Guid?`.
|
||||
// Viết vế người-tạo thành `contract.CreatedBy == actorUserId` (cách tự nhiên nhất) thì HĐ
|
||||
// có `CreatedBy == null` gặp actor `null` cho ra `null == null` = TRUE ⇒ một lệnh trình
|
||||
// KHÔNG DANH TÍNH đi lọt. Chốt `actorUserId is Guid` ở `:82` là thứ duy nhất chặn. Ca này
|
||||
// ĐỎ dưới bản viết-tự-nhiên và XANH dưới bản đang có ⇒ lưới có răng thật.
|
||||
// `decision` phải là `Approve` (KHÔNG phải `AutoApprove`) để không rơi vào cửa `isSystem`.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Submit_ProcurementAndCreator_Allowed_UnrelatedRoleAndNullActor_Forbidden()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var (sup, proj) = await SeedMasterAsync(db);
|
||||
|
||||
var pmh = await fix.CreateUserAsync("t7-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
|
||||
var creator = await fix.CreateUserAsync("t7-creator@test.local", "Người lập HĐ", null, []);
|
||||
var ketoan = await fix.CreateUserAsync("t7-kt@test.local", "Kế toán", null, [AppRoles.Finance]);
|
||||
var approver = await fix.CreateUserAsync("t7-ap@test.local", "Approver V2", null, []);
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, "QT-CT-T7", [[[approver.Id]]]);
|
||||
|
||||
var dt = new FixedDateTime(Clock2026);
|
||||
var currentUser = new TestCurrentUser(); // mutate per ca — mô phỏng scoped-per-request
|
||||
var svc = new ContractWorkflowService(
|
||||
db, new ContractCodeGenerator(db, dt), dt,
|
||||
new NoOpNotificationServiceApp(), new ChangelogService(db, currentUser, um), um);
|
||||
|
||||
async Task<Contract> NewDraftAsync(string ten, Guid drafterId)
|
||||
=> await AddContractAsync(db, sup, proj, drafterId, ContractPhase.DangSoanThao, ten, wf.Id);
|
||||
|
||||
async Task SubmitAsync(Contract c, Guid? actorId, params string[] roles)
|
||||
{
|
||||
currentUser.UserId = actorId;
|
||||
currentUser.Roles = roles;
|
||||
await svc.TransitionAsync(c, ContractPhase.ChoDuyet, actorId, roles,
|
||||
ApprovalDecision.Approve, "Trình duyệt HĐ", ct: CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- Ca 1: `Procurement` KHÔNG phải người tạo ⇒ ĐƯỢC trình (chính là lỗ đã vá).
|
||||
var c1 = await NewDraftAsync("HĐ do PMH trình", drafterId: creator.Id);
|
||||
await SubmitAsync(c1, pmh.Id, AppRoles.Procurement);
|
||||
c1.Phase.Should().Be(ContractPhase.ChoDuyet, "role `Procurement` giờ nằm trong vai được trình");
|
||||
c1.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
c1.CurrentApprovalLevelOrder.Should().Be(1, "HĐ pin V2 ⇒ con-trỏ Cấp khởi tạo = 1");
|
||||
|
||||
// ---- Ca 2: người TẠO nhưng KHÔNG role nào ⇒ vẫn được trình.
|
||||
var c2 = await NewDraftAsync("HĐ do chính người lập trình", drafterId: creator.Id);
|
||||
await SubmitAsync(c2, creator.Id);
|
||||
c2.Phase.Should().Be(ContractPhase.ChoDuyet,
|
||||
"vế người-tạo bắt qua `DrafterUserId` — HĐ seed/legacy có thể trống `CreatedBy`");
|
||||
|
||||
// ---- Ca 3: role KHÔNG liên quan + không phải người tạo ⇒ giữ 403.
|
||||
var c3 = await NewDraftAsync("HĐ kế toán cố trình", drafterId: creator.Id);
|
||||
var act3 = async () => await SubmitAsync(c3, ketoan.Id, AppRoles.Finance);
|
||||
await act3.Should().ThrowAsync<ForbiddenException>("nới vai KHÔNG được biến thành bỏ rào");
|
||||
c3.Phase.Should().Be(ContractPhase.DangSoanThao, "bị chặn thì HĐ phải bất động");
|
||||
|
||||
// ---- Ca 4: 🔴 actor NULL + `CreatedBy` NULL ⇒ vẫn phải 403.
|
||||
var c4 = await NewDraftAsync("HĐ bị trình vô danh", drafterId: creator.Id);
|
||||
c4.CreatedBy.Should().BeNull(
|
||||
"điều kiện tiên quyết của phép đo: fixture không wire `AuditingInterceptor` nên "
|
||||
+ "`CreatedBy` để trống. Nếu ô này có giá trị thì bẫy `null == null` không thể xảy ra "
|
||||
+ "và test đang xanh ở một thế giới KHÁC");
|
||||
var act4 = async () => await SubmitAsync(c4, actorId: null, roles: []);
|
||||
await act4.Should().ThrowAsync<ForbiddenException>(
|
||||
"actor null gặp `CreatedBy` null: viết `contract.CreatedBy == actorUserId` thì "
|
||||
+ "`null == null` = TRUE và lệnh trình vô danh đi lọt. Chốt `actorUserId is Guid` "
|
||||
+ "(`ContractWorkflowService.cs:82`) là thứ duy nhất chặn — đây là lưới giữ nó");
|
||||
c4.Phase.Should().Be(ContractPhase.DangSoanThao, "và HĐ phải nguyên vẹn sau khi bị chặn");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user