[CLAUDE] Contract: K3 SP-002 8 workflow nhom KHKK-N1-8 + port level-finalize + designer (0-mig) + 5 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m10s

Seed 8 workflow type-10 theo ROSTER LIVE prod (QT-DN-V2-001, lead verify tay
@SQLEXPRESS: 3 tram PRO->CCM->CEO, 11 nguoi, resolve EMAIL, idempotent per-Code;
co Finalize CHI cap chuong.phan). Port nhanh level-finalize mirror PE :866-876
(EndedByLevelFinalize K2-de-K3-tieu + ApplyApprovedValuesOnFinalize truoc Phase;
opt-out applyLevelFinalize 3-tang interface/service/controller, default true).
Sweep ep-false va-3: GO CREATE ep-false Finalize type-10 (OG-9 supersede PHA-VO
S155); GIU PUT-Conflict (luat versioning CHUNG - spec liet nham, khai lech);
GIU ep-false SkipToFinal type-10 (K3-test O-1 chong config-lie mới) + FE disable
o SkipToFinal cho KHKK (gate-K3 F-2). Rao (v) pin-dung-nhom regex KHKK-N[1-8].
+8 MenuKeys AwV2_KhkkN* (All 56->64, policies 224->256 may-dem) + 8 menu-row +
resolvePath fe-admin phep-am Khkk_ 0-hit. 5 test K3 (T-a..T-e, anti-tautology).
Gate K3 PASS-WITH-FLAGS 8f/0-blocker (sub-reviewer-gate-k3.md): F-1 checkbox
opt-out man duyet = KHAI HANDOFF dau-viec-#1 lan sau (tram CEO tam thoi qua
Designer bo co); F-2 va; F-3 dong truoc do. Suite 614/0 (45D+569I). K3-BE 2 luot
engine-exit -> che nho + lead-solo (bai: task-hep-Write-som song).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 18:21:39 +07:00
parent d6cb5469b3
commit 15349e8ee0
15 changed files with 766 additions and 83 deletions

View File

@ -74,7 +74,7 @@
**(46)** 🔴 **Thước "17,1K" là thước MA — chọn 1 trong 3 ngã** (`harness-refine` A1, nó **cố ý không đặt số** vì số là quyền anh): **(i) WIRE** thêm key `tiers.l1_hot.self_target_bytes` để máy soi được · **(ii) RETIRE** gỡ luật khỏi 5 diary vì thước thật là 25.600 · **(iii) RELABEL** giữ nhưng ghi rõ "advisory". Bằng chứng nó **đang tốn công thật**: `investigator-codebase:58` nén về ~17,1K @S155, **3 ngày sau 20.646 B lại**; áp thước này thì **9/23 vai vượt** trong khi mọi máy in "0 over". **(46)** 🔴 **Thước "17,1K" là thước MA — chọn 1 trong 3 ngã** (`harness-refine` A1, nó **cố ý không đặt số** vì số là quyền anh): **(i) WIRE** thêm key `tiers.l1_hot.self_target_bytes` để máy soi được · **(ii) RETIRE** gỡ luật khỏi 5 diary vì thước thật là 25.600 · **(iii) RELABEL** giữ nhưng ghi rõ "advisory". Bằng chứng nó **đang tốn công thật**: `investigator-codebase:58` nén về ~17,1K @S155, **3 ngày sau 20.646 B lại**; áp thước này thì **9/23 vai vượt** trong khi mọi máy in "0 over".
**(47)** `cicd-monitor/archive/_INDEX.md` **19.484/20.480 = 95,14%**, tăng ~81 B/ngày ⇒ **chạm cap ~2026-08-09**. A1@S153 chưa land, `harness-refine` đã kích chốt-dừng ⇒ lead {thi-hành nguyên văn | HỦY ghi rõ}; nếu chọn **nâng cap 20.480** thì là quyền anh. **(47)** `cicd-monitor/archive/_INDEX.md` **19.484/20.480 = 95,14%**, tăng ~81 B/ngày ⇒ **chạm cap ~2026-08-09**. A1@S153 chưa land, `harness-refine` đã kích chốt-dừng ⇒ lead {thi-hành nguyên văn | HỦY ghi rõ}; nếu chọn **nâng cap 20.480** thì là quyền anh.
**(48)** `harvest-curator/MEMORY.md` **19.456 B = 0 byte đổi kể từ S154**; món nợ **tự khai 2 lần** (budget `:192` từ S107 · diary `:5` từ S153) chưa trả lần nào — 3 lối ra đều là số/nhịp của anh. **(48)** `harvest-curator/MEMORY.md` **19.456 B = 0 byte đổi kể từ S154**; món nợ **tự khai 2 lần** (budget `:192` từ S107 · diary `:5` từ S153) chưa trả lần nào — 3 lối ra đều là số/nhịp của anh.
🔸 **Không chặn W1-W2:** 5 câu thiết kế spec KHKK (Q3 · Q6 · Q11 + O-Q1 · O-Q2) · nguồn thứ 4 `SOL-PRO-SP-001` chưa ai đọc · 2 đường kết thúc sớm W3 (`AllowApproverFinalize` · `CeoApprovalThreshold` — anh từng phán **PHÁ VỠ** @S155) · thứ tự chạy wave. 🔸 **Không chặn W1-W2:** 5 câu thiết kế spec KHKK (Q3 · Q6 · Q11 + O-Q1 · O-Q2) · nguồn thứ 4 `SOL-PRO-SP-001` chưa ai đọc · 2 đường kết thúc sớm W3: `AllowApproverFinalize` **ĐÃ PORT @K3 S165** (OG-9 đảo "PHÁ VỠ" @S155 — owner @S164 verbatim *"chỗ budget chốt chặn là a Chương… bài toán trần budget xem như đóng"*; ⚠️ **gate-K3 F-1 MAJOR — checkbox opt-out màn duyệt CHƯA có UI, đầu việc #1 lần sau**: hiện `?? true` ⇒ MỌI lượt anh Chương bấm Duyệt đều KẾT THÚC phiếu — muốn phiếu lên CEO tạm thời chỉ có cách admin BỎ cờ ở Designer trước; vá = mirror checkbox `PeWorkflowPanel.tsx:778` vào KhkkWorkflowPanel ×2 + DTO expose cờ level) · `CeoApprovalThreshold` KHÔNG BAO GIỜ port (OG-3 human-gate vĩnh viễn) · thứ tự chạy wave.
🔸 **Treo CÓ CHỦ ĐÍCH, không phải quên:** lỗ hổng an ninh HĐ (`ContractWorkflowService.cs:48-66` Reject-trước-guard + controller class-trần) — anh chốt *"chỗ hợp đồng cứ từ từ"*. **Wave 5 chạm HĐ ⇒ phải hỏi lại anh trước khi chạy.** 🔸 **Treo CÓ CHỦ ĐÍCH, không phải quên:** lỗ hổng an ninh HĐ (`ContractWorkflowService.cs:48-66` Reject-trước-guard + controller class-trần) — anh chốt *"chỗ hợp đồng cứ từ từ"*. **Wave 5 chạm HĐ ⇒ phải hỏi lại anh trước khi chạy.**
🧊 *Segment cũ (S153) giữ nguyên bên dưới làm lineage:* 2026-07-26 chiều (**S153** — phiên-logic 6, 1 cửa-sổ: **adopt TRỌN thư Ctx soft-memory `0df10df4` + OWNER-VETO 3-vai + RENAME hub-form + bookend 2 đầu**). Governance-only, 0 prod-code (test 532 giữ, `git diff` src/tests/fe rỗng). Chi tiết = `docs/STATUS.md` dòng CURRENT + log `changelog/sessions/2026-07-26-1600-S153-ctx-adopt-3vai-rename.md`. ⭐ Bài đắt nhất phiên: **GAP-#9** — máy tự-build của SE tái hiện đúng bẫy F-1 mà thư hub kể (closed-mode: *xoá con-trỏ trung thực* → xanh RẺ HƠN *giữ* → trượt ⇒ gradient thưởng đường SAI; vá bằng TRƯỢT). Bài phụ: **đo wall-clock phải gồm nhánh ĐẮT** (ước lệch 2.2-5.4×) · **fixture-hỏng-cho-xanh trông y hệt máy-đúng-cho-xanh** (phải soi RUỘT fixture) · **bẫy parse `]` trong comment** nuốt non array (18 vs 23). 🧊 *Segment cũ (S153) giữ nguyên bên dưới làm lineage:* 2026-07-26 chiều (**S153** — phiên-logic 6, 1 cửa-sổ: **adopt TRỌN thư Ctx soft-memory `0df10df4` + OWNER-VETO 3-vai + RENAME hub-form + bookend 2 đầu**). Governance-only, 0 prod-code (test 532 giữ, `git diff` src/tests/fe rỗng). Chi tiết = `docs/STATUS.md` dòng CURRENT + log `changelog/sessions/2026-07-26-1600-S153-ctx-adopt-3vai-rename.md`. ⭐ Bài đắt nhất phiên: **GAP-#9** — máy tự-build của SE tái hiện đúng bẫy F-1 mà thư hub kể (closed-mode: *xoá con-trỏ trung thực* → xanh RẺ HƠN *giữ* → trượt ⇒ gradient thưởng đường SAI; vá bằng TRƯỢT). Bài phụ: **đo wall-clock phải gồm nhánh ĐẮT** (ước lệch 2.2-5.4×) · **fixture-hỏng-cho-xanh trông y hệt máy-đúng-cho-xanh** (phải soi RUỘT fixture) · **bẫy parse `]` trong comment** nuốt non array (18 vs 23).

View File

@ -3,7 +3,7 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row `🔥 In Progress`. Xong → `✅ Recently Done`. > **Update rule:** trước khi bắt đầu 1 task → ghi row `🔥 In Progress`. Xong → `✅ Recently Done`.
> **Tiering rule (S40):** chỉ giữ **state hiện tại + 3 session gần nhất** ở file này. Session cũ hơn → `docs/changelog/sessions/`. Full history pre-S40 → `docs/_archive/STATUS-preS40-fullhistory.md`. (Tránh over-context — xóa double, không cắt nội dung.) > **Tiering rule (S40):** chỉ giữ **state hiện tại + 3 session gần nhất** ở file này. Session cũ hơn → `docs/changelog/sessions/`. Full history pre-S40 → `docs/_archive/STATUS-preS40-fullhistory.md`. (Tránh over-context — xóa double, không cắt nội dung.)
**🔥 CURRENT (S161→S162 + S164/S165 2026-07-31 — phiên-LOGIC **L9**: chuỗi SP-002 B0→B3 spec ĐÓNG BĂNG + **B4 K1 danh mục loại HĐ SHIP** gate PASS-WITH-FLAGS; lineage L8 giữ dưới):** Mig **71** (K1 Mig70 `AddContractCatalogEntries` danh mục 86 dòng + K2 Mig71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` — phiếu KHKK +nhóm duyệt N1-N8 backfill=1 + Line +hạng-mục + unique 3-cột) · **97 bảng** · **607 test PASS** (45D+562I, đo tươi @S165 gate-K2) · gotcha **85** · counter **39** · roster 23 · **MenuItems prod 142 GIỮ** (key `KeHoachKyKet` sinh **policy** không sinh menu-row — H1 bác nghi-vấn stale) · Policies **224** · Menu keys **56** (+`ContractCatalog` K1 4 policy sinh ra, giới-hạn khai: chưa endpoint nào tiêu thụ, controller mirror khuôn Catalogs `Roles=Admin` #82) · bundle prod → **canonical ở `:479`, KHÔNG chép số ở đây** (B1). 🔴 *Cặp `DXoYDy7R`/`o8KDluD1` từng ghi ở dòng này **chưa bao giờ là trạng-thái cuối** — `e5123ff` ship thêm 6 file FE-src SAU snapshot; đo live @S164: admin `B43R6Y17` · user `NLI5umBg`.* Contracts prod **0**. **🔥 CURRENT (S161→S162 + S164/S165 2026-07-31 — phiên-LOGIC **L9**: chuỗi SP-002 B0→B3 spec ĐÓNG BĂNG + **B4 K1+K2 SHIP prod-verified + K3 code-xong-gate-đóng** (K4a→K8 + checkbox-opt-out-màn-duyệt = lần `/tiep` sau); lineage L8 giữ dưới):** Mig **71** (K1 Mig70 `AddContractCatalogEntries` danh mục 86 dòng + K2 Mig71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` — phiếu KHKK +nhóm duyệt N1-N8 backfill=1 + Line +hạng-mục + unique 3-cột) · **97 bảng** · **614 test PASS** (45D+569I, đo tươi @S165 sau K3) · gotcha **85** · counter **39** · roster 23 · **MenuItems prod 142 GIỮ** (key `KeHoachKyKet` sinh **policy** không sinh menu-row — H1 bác nghi-vấn stale) · Policies **256** · Menu keys **64** (+`ContractCatalog` K1 [4 policy chưa endpoint tiêu thụ — khai #82] · +8 `AwV2_KhkkN1..N8` K3 [designer 8 nhóm, All=64 máy-đếm]) · bundle prod → **canonical ở `:479`, KHÔNG chép số ở đây** (B1). 🔴 *Cặp `DXoYDy7R`/`o8KDluD1` từng ghi ở dòng này **chưa bao giờ là trạng-thái cuối** — `e5123ff` ship thêm 6 file FE-src SAU snapshot; đo live @S164: admin `B43R6Y17` · user `NLI5umBg`.* Contracts prod **0**.
**5 wave KHKK SHIP (window-3, S161):** W1 `a8027c0` (Mig 69, 7 bảng, 89→96) · W2 `6cbc6ad` (CRUD+Dossier 15 policy) · W3 `df52fa0` (duyệt V2 3-trạm, test-before RED→GREEN) · W6 `761d818` (đường-ống HĐ V2) · W7 `3119cb9` (bản-cứng **1-MỐC** theo owner: form điền đủ → duyệt nếu có → upload bản cứng = xong). Mỗi wave: gate 5-anchor → hmw → test → reviewer → F-0 → cicd 3-chân. 3 quyết owner: mã `KHKK/{YYYY}/{Seq:D3}` · **bỏ "MB"** · GĐ4 1-mốc. **5 wave KHKK SHIP (window-3, S161):** W1 `a8027c0` (Mig 69, 7 bảng, 89→96) · W2 `6cbc6ad` (CRUD+Dossier 15 policy) · W3 `df52fa0` (duyệt V2 3-trạm, test-before RED→GREEN) · W6 `761d818` (đường-ống HĐ V2) · W7 `3119cb9` (bản-cứng **1-MỐC** theo owner: form điền đủ → duyệt nếu có → upload bản cứng = xong). Mỗi wave: gate 5-anchor → hmw → test → reviewer → F-0 → cicd 3-chân. 3 quyết owner: mã `KHKK/{YYYY}/{Seq:D3}` · **bỏ "MB"** · GĐ4 1-mốc.
**Cây 4-folder GĐ (window-4, S162) — `bfc7b79`, cicd PASS 5/5 Run #432:** lệnh owner *"Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng. / Duyệt Hợp đồng / Hợp đồng cứng."* + AskUser chốt **DƯỚI TỪNG GÓI THẦU** + **áp CẢ trang GĐ khác**. 3 file MỚI ×2 app (`PipelineStageFolders` · `PipelineTreePanel` · `usePipelineStages`) + 3 trang ×2 app, **SHA-pair 6/6**, thuần FE 0-mig. designer SHIP-READY (tự soi ảnh bắt 4 lỗi build-không-báo) → reviewer PASS-WITH-FLAGS 8 → lead vá F-1/3/4/6/7 + F-8. 🔴 **CÒN NỢ: trang GĐ3 chưa làm** (owner nói "CẢ", lead giao 3/4 — lead-gap FLAG-3) · F-2 liên-danh N-HĐ chờ W5 · F-5 layout 19rem chờ owner nghiệm thu prod. **Cây 4-folder GĐ (window-4, S162) — `bfc7b79`, cicd PASS 5/5 Run #432:** lệnh owner *"Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng. / Duyệt Hợp đồng / Hợp đồng cứng."* + AskUser chốt **DƯỚI TỪNG GÓI THẦU** + **áp CẢ trang GĐ khác**. 3 file MỚI ×2 app (`PipelineStageFolders` · `PipelineTreePanel` · `usePipelineStages`) + 3 trang ×2 app, **SHA-pair 6/6**, thuần FE 0-mig. designer SHIP-READY (tự soi ảnh bắt 4 lỗi build-không-báo) → reviewer PASS-WITH-FLAGS 8 → lead vá F-1/3/4/6/7 + F-8. 🔴 **CÒN NỢ: trang GĐ3 chưa làm** (owner nói "CẢ", lead giao 3/4 — lead-gap FLAG-3) · F-2 liên-danh N-HĐ chờ W5 · F-5 layout 19rem chờ owner nghiệm thu prod.
**Bookend-close 5 vòng, 10 vai:** H1 `PASS_WITH_FLAGS` 10 finding · H2 **`GATE-FAIL` 4** (⭐ `ctx-verifier` chạy thật mà **chưa bao giờ tự APPEND** diary — S153+S158 đều lead-seed; `cicd-monitor` khai "verbatim→archive" thứ `git log -S` chứng **chưa từng vào archive**) · cặp H24 **20 FLAG** (stale 16 = **SÀN** + gap 4, `gap-carry-dropped` **streak 8**) · trio memory **MIXED** (headroom 165.767 tok đơn-điệu giảm 3 mốc · light 9/6 OVERDUE · baseline `measured` 17/23) · orphan run-folder **3 xác nhận** (2 thiếu THẬT + 1 dương-giả TÊN). **Bookend-close 5 vòng, 10 vai:** H1 `PASS_WITH_FLAGS` 10 finding · H2 **`GATE-FAIL` 4** (⭐ `ctx-verifier` chạy thật mà **chưa bao giờ tự APPEND** diary — S153+S158 đều lead-seed; `cicd-monitor` khai "verbatim→archive" thứ `git log -S` chứng **chưa từng vào archive**) · cặp H24 **20 FLAG** (stale 16 = **SÀN** + gap 4, `gap-carry-dropped` **streak 8**) · trio memory **MIXED** (headroom 165.767 tok đơn-điệu giảm 3 mốc · light 9/6 OVERDUE · baseline `measured` 17/23) · orphan run-folder **3 xác nhận** (2 thiếu THẬT + 1 dương-giả TÊN).

View File

@ -173,6 +173,14 @@ function resolvePath(key: string): string | null {
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') { if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') {
return `/system/approval-workflows-v2/${code}` return `/system/approval-workflows-v2/${code}`
} }
// [K3 S164/S165] Họ MỚI `AwV2_KhkkN1..N8` — 8 quy trình duyệt theo NHÓM của
// Kế hoạch ký kết HĐ (mỗi nhóm 1 đội hình 3 trạm PRO → CCM → CEO). Đây là "chỗ
// thứ 4" Pattern 16-bis: BE seed leaf mà thiếu nhánh này thì `resolvePath` trả
// null ⇒ MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
// 🔴 CHỈ nới họ `AwV2_*` (Designer, fe-admin CÓ trang). KHÔNG nới họ `Khkk_*`
// — fe-admin cố ý KHÔNG có `WorkflowMatrixViewPage` (xem ghi chú khối `Pe_*`
// bên trên); nới ở đó = đẻ leaf chết rơi vào catch-all "chưa được build".
if (/^KhkkN[1-8]$/.test(code)) return `/system/approval-workflows-v2/${code}`
} }
return null return null

View File

@ -4,9 +4,17 @@
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn" // Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến. // (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt, // ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize / // KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là // choke-point, KHÔNG phải việc của panel này.
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này. //
// 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC:
// owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho
// KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng
// `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ".
// Panel VẪN không có control riêng cho đường kết-thúc-sớm — nhưng vì lý do KHÁC HẲN: `AllowApproverFinalize`
// là cấu-hình PER-CẤP do admin tick ở Designer, BE đọc lúc duyệt để quyết dừng hay trình tiếp CEO. Người
// duyệt chỉ bấm "Duyệt" như thường; không có gì để panel render thêm.
// //
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute. // 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`. // KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.

View File

@ -142,14 +142,23 @@ const LEVEL_ORDERS: LevelOrder[] = [1, 2, 3]
// [W1 KHKK S161] "Kế hoạch ký kết HĐ" = slot 10 của `ApprovalWorkflowApplicableType` // [W1 KHKK S161] "Kế hoạch ký kết HĐ" = slot 10 của `ApprovalWorkflowApplicableType`
// (`ApprovalWorkflow.cs:66` trước đó dừng ở `TravelRequest = 9`). Đặt TÊN cho số 10 // (`ApprovalWorkflow.cs:66` trước đó dừng ở `TravelRequest = 9`). Đặt TÊN cho số 10
// vì nó dùng ở HAI nơi trong file: map deep-link ngay bên dưới + rào 2 ô "duyệt sớm" // vì nó dùng ở HAI nơi trong file: map deep-link ngay bên dưới + gợi ý mã quy trình
// trong Designer — số trần rải 2 chỗ là kiểu sửa 1 quên 1. // mặc định — số trần rải 2 chỗ là kiểu sửa 1 quên 1.
// [K3 S164 — GỠ KHOÁ] Chỗ dùng THỨ BA của hằng này (rào 2 ô "duyệt sớm" trong
// Designer) ĐÃ GỠ, xem khối ghi chú ngay dưới.
const TYPE_INT_CONTRACT_SIGNING_PLAN = 10 const TYPE_INT_CONTRACT_SIGNING_PLAN = 10
// Câu giải thích DÙNG CHUNG cho tooltip 2 ô bị khoá + dòng ghi chú (1 chuỗi, không // [K3 S164 — SUPERSEDE W1 S161] 🔴 Duyệt sớm ĐÃ MỞ cho KHKK từ K3 (owner đảo phán
// chép 3 ln rồi lệch chữ). // quyết S155 @S164: TP.CCM quyết lên CEO hay kết thúc). Trước đó file này có hằng
const KHKK_EARLY_FINALIZE_NOTE = // `KHKK_EARLY_FINALIZE_NOTE` + cờ `earlyFinalizeLocked` ép 2 ô "Duyệt thẳng Cấp cuối"
'Chưa hỗ trợ cho quy trình Kế hoạch ký kết HĐ — backend chưa xử lý đường "duyệt sớm", tick vào sẽ KHÔNG có tác dụng.' // và "Duyệt là KẾT THÚC" về mờ-và-false cho type 10, vì backend chưa port đường
// finalize. BE đã port nhánh finalize ở K3 CÙNG WAVE ⇒ cờ hết là "cờ chết", khoá lại
// thành khoá SAI (chặn đúng cái owner vừa yêu cầu mở). Gỡ ở CẢ HAI lớp đã rào —
// render (banner + `disabled`) và payload (`levelFlagsPayload` ép false) — vì chỉ gỡ
// 1 lớp thì màn hình và dữ liệu nói hai đằng.
// Banner giải thích đi kèm cũng XOÁ (không đổi chữ): nội dung nó chỉ để cảnh báo
// khoá, hết khoá là hết việc; còn câu supersede mang từ vựng nội bộ ("K3", "S155/
// S164") nên thuộc về comment này, không đẩy lên UI admin.
// FE typeCode → BE int (giống MenuKeys ApplicableType) // FE typeCode → BE int (giống MenuKeys ApplicableType)
const TYPE_CODE_TO_INT: Record<string, number> = { const TYPE_CODE_TO_INT: Record<string, number> = {
@ -239,12 +248,11 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
// - 2 cờ ĐỔI-ĐƯỜNG-ĐI (SkipToFinal, Finalize): PUT chỉ ECHO — gửi lệch ⇒ 409 // - 2 cờ ĐỔI-ĐƯỜNG-ĐI (SkipToFinal, Finalize): PUT chỉ ECHO — gửi lệch ⇒ 409
// (ApprovalWorkflowV2AdminFeatures.cs:580-585). Vẫn PHẢI gửi đúng giá trị đang lưu. // (ApprovalWorkflowV2AdminFeatures.cs:580-585). Vẫn PHẢI gửi đúng giá trị đang lưu.
// //
// [W1 KHKK S161] `lockEarlyFinalize` = quy trình thuộc loại CHƯA hỗ trợ 2 đường // [K3 S164 — GỠ KHOÁ] Tham số thứ 2 `lockEarlyFinalize` (W1 S161, ép 2 cờ "duyệt sớm"
// "duyệt sớm" (hiện chỉ Kế hoạch ký kết HĐ, type 10) ⇒ ép 2 cờ đó về `false` TẠI ĐÂY, // về `false` cho type 10) ĐÃ BỎ: mọi loại quy trình nay gửi đúng giá trị admin tick.
// không ch khoá ô tick ở màn hình: khoá render mà payload vẫn bê giá trị cũ thì // Xoá hẳn tham số ch không để nó nằm chết — `noUnusedParameters` bật, và param mồ côi
// clone/dữ liệu lạ vẫn đẩy `true` lên BE. Echo của PUT vẫn khớp DB vì mọi bản type 10 // ở hàm 2 call-site là mồi cho lượt sau tưởng còn rào.
// đều do chính màn này tạo ra với 2 cờ = false ⇒ không sinh 409. function levelFlagsPayload(e: EditLevelEntry) {
function levelFlagsPayload(e: EditLevelEntry, lockEarlyFinalize: boolean) {
return { return {
allowReturnOneLevel: e.allowReturnOneLevel, allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep, allowReturnOneStep: e.allowReturnOneStep,
@ -252,8 +260,8 @@ function levelFlagsPayload(e: EditLevelEntry, lockEarlyFinalize: boolean) {
allowReturnToDrafter: e.allowReturnToDrafter, allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails, allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget, allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: lockEarlyFinalize ? false : e.allowApproverSkipToFinal, allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: lockEarlyFinalize ? false : e.allowApproverFinalize, allowApproverFinalize: e.allowApproverFinalize,
allowApproverDelete: e.allowApproverDelete, allowApproverDelete: e.allowApproverDelete,
} }
} }
@ -630,13 +638,10 @@ function Designer({
const isEdit = editTarget !== null const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom const source = editTarget ?? cloneFrom
// [W1 KHKK S161 — chặn "cờ chết"] 2 ô "duyệt sớm" (Duyệt thẳng Cấp cuối / Duyệt là // [K3 S164 — GỠ KHOÁ] `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN: 2 ô "duyệt sớm"
// KẾT THÚC) ĐỔI ĐƯỜNG ĐI của phiếu. Module Kế hoạch ký kết HĐ chưa port 2 đường này // nay sống cho MỌI loại quy trình, kể cả Kế hoạch ký kết HĐ (type 10) — BE đã port
// ở backend ⇒ để ô tick sống = admin tưởng đã bật mà phiếu vẫn chạy y như cũ (cờ // đường finalize ở K3 cùng wave, và owner chốt TP.CCM là người quyết lên CEO hay
// chết đánh lừa người cấu hình). Khoá ở CẢ HAI lớp: render (disabled + ghi chú) và // kết thúc. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`).
// payload (levelFlagsPayload ép false).
const earlyFinalizeLocked = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN
const initialSteps: EditStep[] = useMemo( const initialSteps: EditStep[] = useMemo(
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]), () => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source], [source],
@ -780,7 +785,7 @@ function Designer({
name: `Cấp ${e.order}`, name: `Cấp ${e.order}`,
approverUserId: e.approverUserId, approverUserId: e.approverUserId,
// 9 cờ qua ĐÚNG 1 hàm dùng chung với POST — xem levelFlagsPayload(). // 9 cờ qua ĐÚNG 1 hàm dùng chung với POST — xem levelFlagsPayload().
...levelFlagsPayload(e, earlyFinalizeLocked), ...levelFlagsPayload(e),
})), })),
})), })),
}) })
@ -812,7 +817,7 @@ function Designer({
// field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick // field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick
// vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT), // vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT),
// 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi. // 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi.
...levelFlagsPayload(e, earlyFinalizeLocked), ...levelFlagsPayload(e),
})), })),
})), })),
}) })
@ -1278,51 +1283,32 @@ function Designer({
/> />
<span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span> <span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span>
</label> </label>
{/* [W1 KHKK S161] Ghi chú vì sao 2 ô ngay dưới bị mờ. Chỉ hiện
cho loại quy trình chưa hỗ trợ "duyệt sớm" (type 10 — Kế hoạch
ký kết HĐ); các loại khác giao diện KHÔNG đổi 1 pixel. */}
{earlyFinalizeLocked && (
<div className="col-span-2 rounded border border-slate-200 bg-slate-50 px-1.5 py-1 text-[10.5px] leading-relaxed text-slate-500">
{KHKK_EARLY_FINALIZE_NOTE}
</div>
)}
{/* Mig 31 (S23 t1) — F2 AllowApproverSkipToFinal admin opt-in per-slot. {/* Mig 31 (S23 t1) — F2 AllowApproverSkipToFinal admin opt-in per-slot.
Approver tick = skip thẳng Cấp cuối khi đang ChoDuyet. */} Approver tick = skip thẳng Cấp cuối khi đang ChoDuyet.
<label [K3 S165 — gate-K3 F-2] KHKK (type-10) CHỈ mở ô "KẾT THÚC" (dưới):
className={ service KHKK CHƯA port nhánh SkipToFinal + BE CREATE vẫn ép-false cờ
earlyFinalizeLocked này cho type-10 ⇒ ô này DISABLE cho KHKK kẻo tick bị nuốt im lặng
? 'col-span-2 flex cursor-not-allowed items-center gap-1 text-[11px] text-slate-400' (class "UI nói một đằng DB một nẻo" — đúng thứ W1 S161 cảnh báo). */}
: 'col-span-2 flex items-center gap-1 text-[11px] text-slate-700' <label className={`col-span-2 flex items-center gap-1 text-[11px] ${applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'text-slate-400' : 'text-slate-700'}`}>
}
title={earlyFinalizeLocked ? KHKK_EARLY_FINALIZE_NOTE : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
// [W1 KHKK S161] Khoá thì hiện KHÔNG tick — khớp đúng cái checked={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? false : entry.allowApproverSkipToFinal}
// payload gửi lên (levelFlagsPayload ép false), không để màn disabled={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}
// hình nói một đằng dữ liệu một nẻo. title={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'Kế hoạch ký kết chưa hỗ trợ duyệt vượt cấp — service chưa port nhánh này (K3)' : undefined}
checked={earlyFinalizeLocked ? false : entry.allowApproverSkipToFinal}
disabled={earlyFinalizeLocked}
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)} onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
/> />
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span> <span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
</label> </label>
{/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot {/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương). */} này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương).
<label [K3 S164] Với Kế hoạch ký kết HĐ đây CHÍNH LÀ ô owner cần: tick cho
className={ Cấp TP.CCM (anh Chương) ⇒ anh ấy quyết kết thúc hay trình tiếp CEO. */}
earlyFinalizeLocked <label className="col-span-2 flex items-center gap-1 text-[11px] text-emerald-700">
? 'col-span-2 flex cursor-not-allowed items-center gap-1 text-[11px] text-slate-400'
: 'col-span-2 flex items-center gap-1 text-[11px] text-emerald-700'
}
title={earlyFinalizeLocked ? KHKK_EARLY_FINALIZE_NOTE : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={earlyFinalizeLocked ? false : entry.allowApproverFinalize} checked={entry.allowApproverFinalize}
disabled={earlyFinalizeLocked}
onChange={e => updateField('allowApproverFinalize', e.target.checked)} onChange={e => updateField('allowApproverFinalize', e.target.checked)}
/> />
<span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span> <span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span>

View File

@ -4,9 +4,17 @@
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn" // Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến. // (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt, // ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize / // KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là // choke-point, KHÔNG phải việc của panel này.
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này. //
// 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC:
// owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho
// KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng
// `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ".
// Panel VẪN không có control riêng cho đường kết-thúc-sớm — nhưng vì lý do KHÁC HẲN: `AllowApproverFinalize`
// là cấu-hình PER-CẤP do admin tick ở Designer, BE đọc lúc duyệt để quyết dừng hay trình tiếp CEO. Người
// duyệt chỉ bấm "Duyệt" như thường; không có gì để panel render thêm.
// //
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute. // 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`. // KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.

View File

@ -209,7 +209,8 @@ public class ContractSigningPlansController(
[Authorize(Policy = "KeHoachKyKet.Update")] [Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<ActionResult<ContractSigningPlanTransitionResult>> Transition( public async Task<ActionResult<ContractSigningPlanTransitionResult>> Transition(
Guid id, [FromBody] ContractSigningPlanTransitionBody body, CancellationToken ct) Guid id, [FromBody] ContractSigningPlanTransitionBody body, CancellationToken ct)
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment, ct)); => Ok(await workflow.TransitionAsync(id, body.Action, body.Comment,
body.ApplyLevelFinalize ?? true, ct));
// ========================= Body records ========================= // ========================= Body records =========================
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật. // PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
@ -223,7 +224,10 @@ public class ContractSigningPlansController(
// `Comment` tuỳ chọn. Tên property PascalCase bind đúng JSON "action"/"comment" — // `Comment` tuỳ chọn. Tên property PascalCase bind đúng JSON "action"/"comment" —
// ASP.NET Core mặc định camelCase + đọc không phân biệt hoa/thường // ASP.NET Core mặc định camelCase + đọc không phân biệt hoa/thường
// (`Api/Program.cs:33-34` chỉ thêm converter DateTime, KHÔNG đổi naming policy). // (`Api/Program.cs:33-34` chỉ thêm converter DateTime, KHÔNG đổi naming policy).
// [K3 S165] `ApplyLevelFinalize` — OPT-OUT khuôn PE S96: null/true = auto kết-thúc-tại-cấp
// khi Cấp có `AllowApproverFinalize`; false = người duyệt BỎ tick ⇒ trình tiếp CEO.
public record ContractSigningPlanTransitionBody( public record ContractSigningPlanTransitionBody(
string Action, string Action,
string? Comment = null); string? Comment = null,
bool? ApplyLevelFinalize = null);
} }

View File

@ -375,10 +375,17 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditBudget = l.AllowApproverEditBudget, AllowApproverEditBudget = l.AllowApproverEditBudget,
// [W1 S161 — reviewer F-8, F-B3 containment TẦNG BE] type=10 KHKK: 2 đường // [W1 S161 — reviewer F-8, F-B3 containment TẦNG BE] type=10 KHKK: 2 đường
// early-finalize anh phán "PHÁ VỠ" (S155), W3 không port code path ⇒ ép false // early-finalize anh phán "PHÁ VỠ" (S155), W3 không port code path ⇒ ép false
// server-side (FE đã ép ở levelFlagsPayload — đây chặn curl bypass). PUT // 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155, sweep vá-3] GỠ ép-false
// sửa-tại-chỗ đã có echo-so-BẰNG chặn đổi cờ ⇒ chỉ cần guard ở CREATE. // type-10 cho `AllowApproverFinalize`: owner @S164 đảo phán quyết ("a Chương
// quyết cái gì cần CEO") — service KHKK ĐÃ port nhánh level-finalize cùng
// wave ⇒ cờ hết config-lie #78. Đường bật cờ = TẠO VERSION MỚI (đây);
// PUT sửa-tại-chỗ vẫn chặn đổi cờ (luật versioning CHUNG mọi type — spec
// vá-3 liệt nhầm vào "cụm ép-false", lead giữ + khai lệch sub-implbe-k3.md).
// 🔴 `AllowApproverSkipToFinal` GIỮ ép-false CHO RIÊNG type-10 (K3-test O-1):
// service KHKK 0 lần đọc cờ này ("giữ nguyên không port" — 3-ĐIỂM-ADAPT (b))
// ⇒ echo nó cho type-10 là ĐẺ config-lie #78 mới (DB true, máy im).
AllowApproverSkipToFinal = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverSkipToFinal, AllowApproverSkipToFinal = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverSkipToFinal,
AllowApproverFinalize = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverFinalize, AllowApproverFinalize = l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6 AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).ToList(), }).ToList(),
}) })

View File

@ -406,6 +406,19 @@ public class CreateContractSigningPlanCommandHandler(
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`. // ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct); await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
// ---- Rào (v) [K3 S165 — QĐ-3 "K3 chỉ ĐỌC ApprovalGroup"] Workflow họ nhóm
// `KHKK-N{n}` ⇒ n PHẢI khớp nhóm duyệt của phiếu (phiếu nhóm 3 pin quy trình nhóm 5
// = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho
// 8 workflow nhóm seed @K3.
var wfCode = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == request.ApprovalWorkflowId)
.Select(w => w.Code).FirstOrDefaultAsync(ct);
var groupMatch = System.Text.RegularExpressions.Regex.Match(wfCode ?? string.Empty, "^KHKK-N([1-8])$");
if (groupMatch.Success && int.Parse(groupMatch.Groups[1].Value) != approvalGroup)
throw new ConflictException(
$"Quy trình '{wfCode}' thuộc nhóm duyệt N{groupMatch.Groups[1].Value}, " +
$"không khớp nhóm duyệt N{approvalGroup} của phiếu — chọn đúng quy trình của nhóm.");
// 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT // 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
// (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE + // (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
// `SaveChangesAsync` nội bộ). Nếu phiếu đã nằm trong change-tracker lúc gọi, lượt // `SaveChangesAsync` nội bộ). Nếu phiếu đã nằm trong change-tracker lúc gọi, lượt

View File

@ -26,6 +26,7 @@ public interface IContractSigningPlanWorkflowService
Guid planId, Guid planId,
string action, string action,
string? comment, string? comment,
bool applyLevelFinalize = true,
CancellationToken ct = default); CancellationToken ct = default);
} }

View File

@ -79,6 +79,23 @@ public static class MenuKeys
public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới
public const string ApprovalWorkflowDuyetNccPhuongAnV2 = "AwV2_DuyetNccPhuongAn"; // leaf cho Duyệt NCC và Giải pháp mới public const string ApprovalWorkflowDuyetNccPhuongAnV2 = "AwV2_DuyetNccPhuongAn"; // leaf cho Duyệt NCC và Giải pháp mới
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 quy trình duyệt KHKK theo NHÓM
// DUYỆT N1..N8 (danh mục SOL-CCM-SP-002 Mig 70 chia 86 dòng thành 8 nhóm; phiếu
// KHKK chốt nhóm lúc tạo — Mig 71). Mỗi nhóm 1 workflow `KHKK-N{n}` type-10 do
// `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed.
// 🔴 8 key này VÀO `All` (lệnh lead K3-N1) ⇒ sinh 32 policy AwV2_KhkkN{1..8}.{R,C,U,D}
// ⇒ 2 số canonical `docs/STATUS.md` đổi: Menu keys +8 · Policies +32 (DERIVED |All|×|Actions|).
// 🔴 Vào `All` thì BẮT BUỘC có menu-row tương ứng trong `SeedMenuTreeAsync` —
// thiếu row = Permission FK nổ ngay lần boot đầu (invariant test T6 K1).
public const string ApprovalWorkflowKhkkN1 = "AwV2_KhkkN1";
public const string ApprovalWorkflowKhkkN2 = "AwV2_KhkkN2";
public const string ApprovalWorkflowKhkkN3 = "AwV2_KhkkN3";
public const string ApprovalWorkflowKhkkN4 = "AwV2_KhkkN4";
public const string ApprovalWorkflowKhkkN5 = "AwV2_KhkkN5";
public const string ApprovalWorkflowKhkkN6 = "AwV2_KhkkN6";
public const string ApprovalWorkflowKhkkN7 = "AwV2_KhkkN7";
public const string ApprovalWorkflowKhkkN8 = "AwV2_KhkkN8";
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_List/Bg_Create/Bg_Pending) // [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_List/Bg_Create/Bg_Pending)
// đã XÓA — thay bằng "Ngân sách gói thầu" nhúng trong phiếu PE // đã XÓA — thay bằng "Ngân sách gói thầu" nhúng trong phiếu PE
// (PeWorkItemBudgets per cặp Dự án × Hạng mục). Menu/Permission rows cũ // (PeWorkItemBudgets per cặp Dự án × Hạng mục). Menu/Permission rows cũ
@ -185,6 +202,9 @@ public static class MenuKeys
KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete} KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete}
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows, System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22 ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy).
ApprovalWorkflowKhkkN1, ApprovalWorkflowKhkkN2, ApprovalWorkflowKhkkN3, ApprovalWorkflowKhkkN4,
ApprovalWorkflowKhkkN5, ApprovalWorkflowKhkkN6, ApprovalWorkflowKhkkN7, ApprovalWorkflowKhkkN8,
]; ];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"]; public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -164,6 +164,12 @@ public static class DbInitializer
await SeedSampleTravelRequestWorkflowV2Async(db, userManager, logger); await SeedSampleTravelRequestWorkflowV2Async(db, userManager, logger);
await SeedSampleVehicleBookingWorkflowV2Async(db, userManager, logger); await SeedSampleVehicleBookingWorkflowV2Async(db, userManager, logger);
// [K3 nhịp-1 — S164/S165] 8 quy trình duyệt KHKK theo nhóm duyệt N1..N8
// (ApplicableType=ContractSigningPlan=10). INFRASTRUCTURE seed NOT gated
// DemoSeed (gotcha #51) — prod cần đủ 8 quy trình ngay deploy đầu, thiếu thì
// Drafter không pick được quy trình lúc tạo phiếu KHKK. Idempotent per-Code.
await SeedKhkkGroupWorkflowsAsync(db, userManager, logger);
await WarnDefaultAdminPasswordAsync(userManager, logger); await WarnDefaultAdminPasswordAsync(userManager, logger);
} }
@ -517,6 +523,169 @@ public static class DbInitializer
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for VehicleBooking: QT-XE-V2-001 v01"); logger.LogInformation("Seeded sample ApprovalWorkflow V2 for VehicleBooking: QT-XE-V2-001 v01");
} }
// ============================================================================
// [K3 nhịp-1 — S164/S165] 8 QUY TRÌNH DUYỆT KHKK theo NHÓM DUYỆT N1..N8
// ============================================================================
// Danh mục Hợp đồng SOL-CCM-SP-002 (Mig 70) chia 86 dòng vào 8 nhóm duyệt
// (`ContractCatalogEntry.ApprovalGroup` 1..8); phiếu KHKK chốt nhóm lúc TẠO
// (`ContractSigningPlan.ApprovalGroup`, Mig 71) ⇒ mỗi nhóm cần 1 quy trình V2
// riêng để pin. Code `KHKK-N{n}`, ApplicableType=ContractSigningPlan (=10).
//
// ĐỘI HÌNH: 3 trạm PRO → CCM → BGĐ, sao đúng workflow Duyệt NCC prod bản LIVE
// (`QT-DN-V2-001`, ApplicableType=1) theo owner OG-2 — "lấy đúng bộ người duyệt
// đang cấu hình ở workflow Duyệt NCC prod". Cấp 1 mỗi trạm = OR-of-N (N row cùng
// Order ⇒ bất kỳ ai trong cấp duyệt là xong cấp đó), Cấp 2 = trưởng phòng.
//
// INFRASTRUCTURE seed — NOT gated DemoSeed (gotcha #51).
// Idempotent PER-CODE (`KHKK-N{n}`), CỐ Ý KHÔNG dùng khuôn `AnyAsync(w.Applicable
// Type == ...)` của 6 seeder mẫu phía trên: ở đây 8 workflow DÙNG CHUNG 1 type,
// check theo type sẽ chặn 7 nhóm còn lại ngay khi nhóm đầu vừa được tạo.
//
// Resolve người duyệt THEO EMAIL. User vắng mặt (DB dev thiếu người thật) ⇒ BỎ QUA
// đúng slot đó + log warning, KHÔNG throw (seeder chạy lúc boot — throw = API chết).
// Cấp không còn ai ⇒ bỏ cấp (Order được đánh LẠI liên tục 1,2,… để không vi phạm
// ràng buộc sequential của Designer); Bước không còn cấp nào ⇒ bỏ bước; workflow
// 0 bước vẫn TẠO (giữ mã cho phiếu pin + admin bổ sung người qua Designer) + log.
private static async Task SeedKhkkGroupWorkflowsAsync(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
// Roster VERIFY LIVE trên prod 2026-07-31 (bản QT-DN-V2-001 IsActive=1) —
// bước × phòng × [Cấp 1 OR-of-N] × [Cấp 2] × cờ finalize của Cấp 2.
var roster = new (string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[]
{
("Phòng Cung Ứng", "PRO",
new[]
{
"phuong.nguyen@solutions.com.vn", // Nguyễn Thị Bích Phượng
"long.chau@solutions.com.vn", // Châu Tạ Kim Long
"duy.nguyen@solutions.com.vn", // Nguyễn Văn Duy
"binh.lethanh@solutions.com.vn", // Bình Lê Thanh (⟂ binh.le bên CCM)
},
new[] { "tra.bui@solutions.com.vn" }, // Bùi Lê Thủy Trà (TP.PRO)
false),
("P. Kiểm soát Chi phí", "CCM",
new[]
{
"luu.tran@solutions.com.vn", // Trần Xuân Lưu
"truong.letrandang@solutions.com.vn", // Lê Trần Đăng Trường (⟂ truong.nguyen bên BGĐ)
"binh.le@solutions.com.vn", // Lê Văn Bình
"nguyen.ho@solutions.com.vn", // Hồ Thị Nữ Nguyên
},
new[] { "chuong.phan@solutions.com.vn" }, // Phan Văn Chương (TP.CCM)
// 🔴 AllowApproverFinalize=true (owner OG-3/OG-9: a Chương quyết lên CEO hay
// kết thúc). Nhánh service port ở nhịp sau CÙNG WAVE trước push nên không
// phải config-lie #78.
true),
("Ban Giám đốc", "BOD",
new[] { "truong.nguyen@solutions.com.vn" }, // Nguyễn Văn Trường (CEO)
Array.Empty<string>(),
false),
};
// Resolve email → UserId MỘT lượt (11 email, dùng lại cho cả 8 workflow).
var usersByEmail = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
var missingEmails = new List<string>();
foreach (var email in roster.SelectMany(r => r.Level1.Concat(r.Level2))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
var user = await userManager.FindByEmailAsync(email);
if (user is null) { missingEmails.Add(email); continue; }
usersByEmail[email] = user.Id;
}
if (missingEmails.Count > 0)
logger.LogWarning(
"SeedKhkkGroupWorkflowsAsync: {Count} email người duyệt KHÔNG có user — bỏ qua slot đó: {Emails}",
missingEmails.Count, string.Join(", ", missingEmails));
var deptIdByCode = (await db.Departments.ToListAsync())
.GroupBy(d => d.Code)
.ToDictionary(g => g.Key, g => (Guid?)g.First().Id);
var seeded = 0;
for (var n = 1; n <= 8; n++)
{
var code = $"KHKK-N{n}";
if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
Name = $"Duyệt KHKK — Nhóm {n}",
Description =
$"Quy trình duyệt Kế hoạch ký kết HĐ cho nhóm duyệt N{n} (danh mục SOL-CCM-SP-002). " +
"3 trạm PRO → CCM → BGĐ, đội hình sao từ workflow Duyệt NCC prod. " +
"Admin có thể chỉnh người/quyền tại chỗ hoặc tạo version mới qua Designer.",
IsActive = true,
IsUserSelectable = true,
ActivatedAt = DateTime.UtcNow,
};
var stepOrder = 1;
foreach (var r in roster)
{
var step = new ApprovalWorkflowStep
{
ApprovalWorkflow = wf,
Order = stepOrder,
Name = r.StepName,
DepartmentId = deptIdByCode.GetValueOrDefault(r.DeptCode),
};
var levelOrder = 1;
foreach (var (emails, finalize) in new[] { (r.Level1, false), (r.Level2, r.Level2Finalize) })
{
var approverIds = emails
.Where(usersByEmail.ContainsKey)
.Select(e => usersByEmail[e])
.ToArray();
if (approverIds.Length == 0) continue; // cấp rỗng → bỏ, KHÔNG chừa lỗ Order
foreach (var approverId in approverIds)
{
step.Levels.Add(new ApprovalWorkflowLevel
{
Step = step,
Order = levelOrder, // N row cùng Order = OR-of-N trong 1 Cấp
Name = $"Cấp {levelOrder}",
ApproverUserId = approverId,
AllowApproverFinalize = finalize,
});
}
levelOrder++;
}
if (step.Levels.Count == 0)
{
logger.LogWarning(
"SeedKhkkGroupWorkflowsAsync: {Code} bỏ Bước \"{Step}\" — 0 người duyệt resolve được",
code, r.StepName);
continue;
}
wf.Steps.Add(step);
stepOrder++;
}
if (wf.Steps.Count == 0)
logger.LogWarning(
"SeedKhkkGroupWorkflowsAsync: {Code} tạo RỖNG (0 Bước có người) — admin phải thêm người qua Designer",
code);
db.ApprovalWorkflows.Add(wf);
seeded++;
}
if (seeded > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} KHKK group workflows (K3 SP-002)", seeded);
}
}
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent: // Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber). // skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger) private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
@ -1808,6 +1977,18 @@ public static class DbInitializer
(MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"), (MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"),
(MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"), (MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"),
(MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2, "Duyệt NCC và Giải pháp (Mới)", MenuKeys.ApprovalWorkflowsV2, 2, "FileCheck"), (MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2, "Duyệt NCC và Giải pháp (Mới)", MenuKeys.ApprovalWorkflowsV2, 2, "FileCheck"),
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (workflow
// `KHKK-N{n}` type-10 seed ở SeedKhkkGroupWorkflowsAsync). Key CÓ trong
// MenuKeys.All ⇒ Admin được grant tự động qua loop SeedAdminPermissionsAsync;
// upsert dưới là idempotent per-Key (key đã có → chỉ re-set Order rồi continue).
(MenuKeys.ApprovalWorkflowKhkkN1, "Duyệt KHKK — Nhóm 1", MenuKeys.ApprovalWorkflowsV2, 3, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN2, "Duyệt KHKK — Nhóm 2", MenuKeys.ApprovalWorkflowsV2, 4, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN3, "Duyệt KHKK — Nhóm 3", MenuKeys.ApprovalWorkflowsV2, 5, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN4, "Duyệt KHKK — Nhóm 4", MenuKeys.ApprovalWorkflowsV2, 6, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN5, "Duyệt KHKK — Nhóm 5", MenuKeys.ApprovalWorkflowsV2, 7, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN6, "Duyệt KHKK — Nhóm 6", MenuKeys.ApprovalWorkflowsV2, 8, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN7, "Duyệt KHKK — Nhóm 7", MenuKeys.ApprovalWorkflowsV2, 9, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN8, "Duyệt KHKK — Nhóm 8", MenuKeys.ApprovalWorkflowsV2, 10, "FileSignature"),
// [S61 Mig 50] Menu module Ngân sách cũ (Budgets + 3 leaf Bg_*) đã XÓA — // [S61 Mig 50] Menu module Ngân sách cũ (Budgets + 3 leaf Bg_*) đã XÓA —
// rows cũ trên DB cleanup qua SQL trong migration (idempotent DELETE). // rows cũ trên DB cleanup qua SQL trong migration (idempotent DELETE).
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR. // Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR.

View File

@ -34,16 +34,17 @@ namespace SolutionErp.Infrastructure.Services;
// //
// 3 ĐIỂM ADAPT: // 3 ĐIỂM ADAPT:
// (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set // (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔴 KHAI RÕ: service này có **ĐÚNG MỘT** // `DaDuyet` — xem khối LUẬT ngay trên helper. 🔄 [K3 S165] service NAY có **HAI** nhánh
// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy // set DaDuyet (đường-thường Cấp-cuối + level-finalize dưới đây), CẢ HAI qua choke-point;
// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN). // vẫn KHÔNG có site gán-qua-BIẾN kiểu nguồn copy (`ContractWorkflowService.cs:190-193`):
// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số // hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số `targetPhase`
// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve` // ⇒ Admin kết thúc phiếu vẫn đi đúng đường `approve` (miễn vế "có tên trong Cấp",
// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point). // KHÔNG min choke-point). Tự-kiểm 2 lệnh grep ở khối LUẬT phải cùng khớp.
// Đó chính là write-path 2 mà test T3 đo. // (b) 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155] Owner @S164 đảo phán quyết (verbatim:
// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`) // "chỗ budget chốt chặn là a Chương… bài toán trần budget xem như đóng") ⇒ ĐÃ PORT
// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án // nhánh `AllowApproverFinalize` (level-finalize, OPT-OUT khuôn PE S96 — cờ seed cho
// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật. // TP.CCM Phan Văn Chương @K3). VẪN KHÔNG port `CeoApprovalThreshold` (ngưỡng máy —
// NĐUQ = human-gate vĩnh viễn, OG-3) và `AllowApproverSkipToFinal` giữ nguyên không port.
// (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1 // (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
// Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc — // Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
// mở thêm mode là đổi hành vi duyệt, thuộc quyền lead. // mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
@ -87,6 +88,7 @@ public class ContractSigningPlanWorkflowService(
Guid planId, Guid planId,
string action, string action,
string? comment, string? comment,
bool applyLevelFinalize = true,
CancellationToken ct = default) CancellationToken ct = default)
{ {
// Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên, // Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
@ -121,7 +123,7 @@ public class ContractSigningPlanWorkflowService(
break; break;
case "approve": case "approve":
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct); await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, applyLevelFinalize, ct);
break; break;
case "return": case "return":
@ -242,7 +244,8 @@ public class ContractSigningPlanWorkflowService(
// approve — approver Cấp đang chờ duyệt (OR-of-N) // approve — approver Cấp đang chờ duyệt (OR-of-N)
// ===================================================================== // =====================================================================
private async Task ApproveV2Async( private async Task ApproveV2Async(
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct) ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment,
bool applyLevelFinalize, CancellationToken ct)
{ {
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet) if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt."); throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
@ -264,6 +267,27 @@ public class ContractSigningPlanWorkflowService(
var fromPhase = plan.Phase; var fromPhase = plan.Phase;
// ---- [K3 S165 — OG-3/OG-9 port level-finalize, mirror PE `:866-876`] Cấp có cờ
// `AllowApproverFinalize` (seed K3: TP.CCM Phan Văn Chương, Bước 2 Cấp 2) được KẾT THÚC
// quy trình TẠI ĐÂY thay vì trình tiếp CEO. OPT-OUT khuôn PE S96: client gửi
// `applyLevelFinalize=false` (người duyệt BỎ tick "Cấp này KẾT THÚC") ⇒ rơi xuống
// advance thường. Supersede "PHÁ VỠ" @S155 theo lời owner @S164 (xem 3-ĐIỂM-ADAPT (b)).
// Cờ RUNTIME `EndedByLevelFinalize` = Mig 71 K2 đẻ — K3 tiêu thụ, HẾT ghost-window.
if (actingLevel.AllowApproverFinalize && applyLevelFinalize)
{
plan.EndedByLevelFinalize = true;
ApplyApprovedValuesOnFinalize(plan); // LUẬT choke-point: TRƯỚC khi gán Phase
plan.Phase = ContractSigningPlanPhase.DaDuyet;
plan.CurrentWorkflowStepIndex = null;
plan.CurrentApprovalLevelOrder = null;
await LogSigningPlanTransitionAsync(
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
actingLevel.Id,
$"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — KẾT THÚC TẠI CẤP (level-finalize), kế hoạch ĐÃ DUYỆT, chốt giá",
ct);
return;
}
// ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1); // ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
// hết Bước → FINALIZE. // hết Bước → FINALIZE.
if (currentLevelOrder < maxLevelOrder) if (currentLevelOrder < maxLevelOrder)

View File

@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ApprovalWorkflowsV2; // [K3 S164] CreateAwDefinitionCommand — test giữ-cờ
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống) using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
using SolutionErp.Application.ContractSigningPlans.Services; using SolutionErp.Application.ContractSigningPlans.Services;
@ -307,7 +308,7 @@ public class ContractSigningPlanApprovalTests
{ {
await using var db = f.NewDb(actor); await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp()); var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None); return await svc.TransitionAsync(planId, action, comment, ct: CancellationToken.None);
} }
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkWorkflowFixture f, Guid planId) private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkWorkflowFixture f, Guid planId)
@ -794,4 +795,347 @@ public class ContractSigningPlanApprovalTests
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2, ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2,
"A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá"); "A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá");
} }
// ###################################################################
// [K3 S164 — 2026-07-31] KẾT-THÚC-TẠI-CẤP (level-finalize).
//
// Owner @S164 ĐẢO phán quyết "PHÁ VỠ" của S155 ⇒ service port nhánh mới
// (`ContractSigningPlanWorkflowService.cs:276-289`), đứng NGAY SAU `UpsertLevelOpinionAsync`
// và TRƯỚC mọi nhánh advance. Đây là đường thứ HAI đưa phiếu sang `DaDuyet` — tức đường
// thứ hai phải đi qua choke-point chốt giá; bỏ sót nó thì phiếu "Đã duyệt" mà
// `ApprovedAmount` rỗng và GĐ3 sinh hợp đồng 0đ (đúng lớp hỏng mà T3 đã rào cho đường 1).
//
// Ba lưới dưới đây là MỘT MA TRẬN 2 chiều trên CÙNG MỘT bộ seed (kỹ thuật "cùng-ruột,
// khác đúng 1 biến" — S153): chỉ khi cả ba cùng xanh thì mới kết luận được rằng nhánh
// finalize phụ thuộc CẢ HAI vế, chứ không phải một vế nào đó tình cờ đúng.
//
// │ apply=true (mặc định) │ apply=false (bỏ tick)
// ─────────────────────┼───────────────────────┼──────────────────────
// Cấp CÓ cờ │ (T-a) KẾT THÚC │ (T-b) advance thường
// Cấp KHÔNG cờ │ (T-c) advance thường │ — (không có thông tin thêm)
//
// 🔴 Điều kiện để phép đo có nghĩa: Cấp mang cờ là **Bước 2 Cấp 2 của quy trình 3 Bước**,
// tức KHÔNG phải cấp cuối. Nếu đặt cờ ở cấp cuối thì "kết thúc" xảy ra ở cả hai thế giới
// (có cờ / không cờ) và T-a xanh mà chẳng chứng minh gì. Mỗi test vì thế assert lại số
// Bước = 3 trước khi kết luận.
// ###################################################################
/// Bộ seed dùng chung cho T-a/T-b/T-c. Ba phiếu SONG SINH trên CÙNG một quy trình:
/// • `PlanAtFlagLevel` — đứng ở Bước 2 Cấp 2 (Cấp MANG cờ) → đối tượng đo của T-a/T-b
/// • `WitnessAtFlagLevel` — y hệt, KHÔNG ai đụng → bắt lỗi finalize quên vế "chỉ phiếu này"
/// • `PlanAtPlainLevel` — đứng ở Bước 1 Cấp 1 (Cấp KHÔNG cờ) → đối tượng đo của T-c
private sealed record FinalizeScenario(
Guid WorkflowId,
Guid PlanAtFlagLevel,
Guid WitnessAtFlagLevel,
Guid PlanAtPlainLevel,
User FlagApprover,
User PlainApprover,
TestCurrentUser Drafter);
/// Bẫy nguồn số cố ý: `ProposedAmount` ≠ `PeReferenceAmount` ở MỌI dòng (lấy nhầm cột là lộ
/// ngay), và dòng thứ 3 đã có `ApprovedAmount` sẵn để đo đúng ngữ nghĩa `??=` (GIỮ, không đè).
private static readonly LineSeed[] FinalizeLines =
[
new(PeReference: 999m, Proposed: 111m),
new(PeReference: 888m, Proposed: 222m),
new(PeReference: 777m, Proposed: 333m, Approved: 300m),
];
private static async Task<FinalizeScenario> SeedFinalizeScenarioAsync(KhkkWorkflowFixture f, string tag)
{
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var s1c1 = await AddUserAsync(f, "PMH cấp 1");
var s1c2 = await AddUserAsync(f, "PMH cấp 2");
var s2c1 = await AddUserAsync(f, "CCM cấp 1");
var s2c2 = await AddUserAsync(f, "TP.CCM — Cấp được KẾT THÚC");
var s3c1 = await AddUserAsync(f, "Thư ký CEO");
var s3c2 = await AddUserAsync(f, "CEO");
// Order 5/15/25 (KHÔNG 1/2/3) giữ nguyên chủ ý của T2: `CurrentWorkflowStepIndex` là
// INDEX vào list đã sort, đọc nhầm thành `Step.Order` là lộ ngay chứ không trùng do may.
var wf = await SeedWorkflowAsync(f, $"QT-KHKK-FIN-{tag}", steps:
[
[[s1c1.Id], [s1c2.Id]], // Bước 1
[[s2c1.Id], [s2c2.Id]], // Bước 2 ← Cấp 2 mang cờ
[[s3c1.Id], [s3c2.Id]], // Bước 3 ← còn NGUYÊN phía sau ⇒ kết thúc ở Bước 2 là SỚM
],
stepOrderStart: 5, stepOrderStride: 10);
await SetLevelFinalizeAsync(f, wf, stepIndex: 1, levelOrder: 2, expectedApprover: s2c2.Id);
var flagPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}01",
ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
var witness = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}02",
ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
var plainPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}03",
ContractSigningPlanPhase.ChoDuyet, 0, 1, FinalizeLines);
return new FinalizeScenario(wf, flagPlan, witness, plainPlan, s2c2, s1c1, drafter);
}
/// Bật cờ `AllowApproverFinalize` cho ĐÚNG MỘT Cấp, rồi tự kiểm 2 CHIỀU từ ĐĨA:
/// (i) Cấp được bật đúng là Cấp của người ta nghĩ; (ii) TOÀN quy trình chỉ có đúng nó
/// mang cờ. Thiếu vế (ii) thì một seed lỡ bật thêm Cấp khác vẫn cho T-c xanh ở thế giới sai.
private static async Task SetLevelFinalizeAsync(
KhkkWorkflowFixture f, Guid workflowId, int stepIndex, int levelOrder, Guid expectedApprover)
{
await using (var db = f.NewDb(TestCurrentUser.System()))
{
var stepIds = await db.ApprovalWorkflowSteps
.Where(s => s.ApprovalWorkflowId == workflowId)
.OrderBy(s => s.Order)
.Select(s => s.Id)
.ToListAsync(CancellationToken.None);
var levels = await db.ApprovalWorkflowLevels
.Where(l => l.ApprovalWorkflowStepId == stepIds[stepIndex] && l.Order == levelOrder)
.ToListAsync(CancellationToken.None);
levels.Should().ContainSingle("seed đặt đúng 1 NV ở Cấp mang cờ — nhiều hơn là OR-of-N, phép đo đổi nghĩa");
levels[0].ApproverUserId.Should().Be(expectedApprover,
"bật cờ nhầm Cấp thì mọi assert phía sau nói về một người khác");
levels[0].AllowApproverFinalize = true;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.NewDb(TestCurrentUser.System());
var flagged = await verify.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.Step!.ApprovalWorkflowId == workflowId && l.AllowApproverFinalize)
.Select(l => l.ApproverUserId)
.ToListAsync(CancellationToken.None);
flagged.Should().BeEquivalentTo(new[] { expectedApprover },
"ĐÚNG MỘT Cấp trong cả quy trình mang cờ — cờ rò sang Cấp khác thì T-c mất răng");
}
/// Số Bước của quy trình — dùng để chốt "còn Bước phía sau" (điều kiện KẾT THÚC SỚM có nghĩa).
private static async Task<int> StepCountAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ApprovalWorkflowSteps.AsNoTracking()
.CountAsync(s => s.ApprovalWorkflowId == workflowId, CancellationToken.None);
}
/// `ActAsync` luôn dùng mặc định (`applyLevelFinalize = true`) — helper NÀY là đường DUY NHẤT
/// gửi `false`, tách riêng để không đụng vào `ActAsync` mà 6 test cũ đang dựa vào.
private static async Task<ContractSigningPlanTransitionResult> ActWithFinalizeFlagAsync(
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action,
string? comment, bool applyLevelFinalize)
{
await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, applyLevelFinalize, CancellationToken.None);
}
/// Hai dòng khởi điểm `ApprovedAmount = null` phải CÒN null; dòng đã có 300 phải GIỮ 300.
/// Viết thành hàm để ba test nói cùng một câu, không mỗi chỗ một kiểu.
private static void LinesShouldBeUnpriced(ContractSigningPlan plan, string because)
{
plan.Lines.Where(l => l.ProposedAmount != 333m).Should()
.OnlyContain(l => l.ApprovedAmount == null, because);
plan.Lines.Single(l => l.ProposedAmount == 333m).ApprovedAmount.Should().Be(300m,
"dòng vốn đã có giá thì giữ nguyên số cũ — " + because);
}
// ===================================================================
// (T-a) Cấp MANG cờ + không bỏ tick ⇒ KẾT THÚC NGAY tại Cấp đó, kèm chốt giá.
// ===================================================================
[Fact]
public async Task ApproveV2_CapMangCoKetThuc_ChotPhieuNgay_KhongTrinhTiepBuocSau()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "A");
// Sanity: đo đúng thế giới mình nghĩ — phiếu đứng ở Cấp mang cờ, chưa từng finalize,
// và quy trình CÒN Bước 3 phía sau (nếu không thì "kết thúc sớm" là vô nghĩa).
var before = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(before, ContractSigningPlanPhase.ChoDuyet, 1, 2, "seed đặt phiếu đúng Bước 2 Cấp 2");
before.EndedByLevelFinalize.Should().BeFalse("cờ runtime phải sạch trước khi đo");
(await StepCountAsync(f, sc.WorkflowId)).Should().Be(3,
"Cấp mang cờ KHÔNG được là cấp cuối — nếu là cấp cuối thì phiếu kết thúc ở CẢ HAI thế giới "
+ "(có cờ / không cờ) và test này xanh mà không chứng minh gì");
var r = await ActAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
"approve", "TP.CCM chốt — không cần trình CEO");
var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(after, ContractSigningPlanPhase.DaDuyet, null, null,
"Cấp mang cờ duyệt = kết thúc quy trình: phase DaDuyet và XOÁ cả 2 con-trỏ; "
+ "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của Bước 3");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeTrue(
"cờ RUNTIME phân biệt 'kết thúc sớm THẬT' với 'chạy hết quy trình' — FE/GĐ3 đọc cột này; "
+ "suy ngược từ cấu hình quy trình là bài học gotcha #78 (config-lie) đã trả giá ở PE S97");
// Chốt giá: đường finalize là WRITE-PATH THỨ HAI tới DaDuyet ⇒ cũng phải qua choke-point.
after.Lines.Should().NotContain(l => l.ApprovedAmount == null,
"phiếu 'Đã duyệt' mà ApprovedAmount rỗng thì GĐ3 sinh hợp đồng 0đ — nhánh finalize "
+ "PHẢI gọi `ApplyApprovedValuesOnFinalize`, đúng như nhánh duyệt-cấp-cuối");
var byProposed = after.Lines.ToDictionary(l => l.ProposedAmount);
byProposed[111m].ApprovedAmount.Should().Be(111m,
"giá chốt lấy `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999");
byProposed[222m].ApprovedAmount.Should().Be(222m);
byProposed[333m].ApprovedAmount.Should().Be(300m,
"`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333");
// Nhánh finalize nằm SAU `UpsertLevelOpinionAsync` ⇒ chữ ký của người kết thúc vẫn phải có.
var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
op.Should().ContainSingle("kết thúc tại Cấp vẫn là một lượt DUYỆT ⇒ vẫn ghi ý kiến của Cấp đó");
op[0].SignedByUserId.Should().Be(sc.FlagApprover.Id,
"người kết thúc phiếu phải để lại tên — bản in FO-002.01 lấy chữ ký từ đây");
// Chứng nhân: phiếu song sinh (cùng quy trình, cùng con-trỏ) tuyệt đối bất động.
var w = await ReadPlanAsync(f, sc.WitnessAtFlagLevel);
ShouldBeAt(w, ContractSigningPlanPhase.ChoDuyet, 1, 2,
"finalize chỉ được chạm phiếu ĐANG duyệt — lây sang phiếu khác là duyệt hộ cả hệ thống");
w.EndedByLevelFinalize.Should().BeFalse("cờ runtime cũng không được lây");
LinesShouldBeUnpriced(w, "phiếu hàng xóm không được dính giá chốt");
}
// ===================================================================
// (T-b) OPT-OUT — CÙNG bộ seed, đổi ĐÚNG MỘT biến `applyLevelFinalize: false`.
// Người duyệt bỏ tick "Cấp này kết thúc" ⇒ phiếu đi tiếp Bước 3 như thường.
// ===================================================================
[Fact]
public async Task ApproveV2_CapMangCoKetThuc_NhungBoTick_ThiVanTrinhTiepBuocSau()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "B");
(await StepCountAsync(f, sc.WorkflowId)).Should().Be(3, "cùng bộ seed với T-a");
var r = await ActWithFinalizeFlagAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
"approve", "Gói lớn — tôi vẫn trình CEO", applyLevelFinalize: false);
var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 2, 1,
"bỏ tick ⇒ rơi xuống advance THƯỜNG: hết Cấp của Bước 2 ⇒ sang Bước 3 (index 2) và "
+ "Cấp RESET về 1. Ra DaDuyet ở đây tức là cờ quy trình một mình quyết định, "
+ "quyền chọn của người duyệt bị bỏ qua");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeFalse(
"phiếu KHÔNG kết thúc sớm ⇒ cờ runtime phải sạch; bật ở đây là badge 'kết thúc sớm' "
+ "hiện sai trên phiếu vẫn còn đang chờ CEO (đúng bệnh #78 mà PE S97 đã sửa)");
LinesShouldBeUnpriced(after,
"chưa tới DaDuyet thì TUYỆT ĐỐI chưa được chốt giá — chốt sớm là ký thay CEO");
// Vòng chạy tiếp là THẬT: đúng người của Bước 3 Cấp 1 duyệt được (không phải con-trỏ
// bị đặt tới một chỗ chết).
var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
op.Should().ContainSingle("lượt duyệt opt-out vẫn ghi ý kiến của Cấp vừa ký");
}
// ===================================================================
// (T-c) Cờ KHÔNG RÒ — Cấp không mang cờ, duyệt với mặc định (`applyLevelFinalize = true`)
// vẫn phải advance thường. Đây là vế chứng minh `applyLevelFinalize` MỘT MÌNH không đủ
// để kết thúc phiếu — nếu điều kiện viết nhầm thành `applyLevelFinalize` đơn lẻ (hoặc `||`
// thay vì `&&`) thì MỌI lượt duyệt đầu tiên sẽ chốt luôn cả phiếu.
// ===================================================================
[Fact]
public async Task ApproveV2_CapKhongMangCo_ThiKhongBaoGioKetThucSom()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "C");
var r = await ActAsync(f, As(sc.PlainApprover), sc.PlanAtPlainLevel,
"approve", "Bước 1 Cấp 1 duyệt");
var after = await ReadPlanAsync(f, sc.PlanAtPlainLevel);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 2,
"Cấp KHÔNG có cờ ⇒ chỉ tăng Cấp trong cùng Bước, dù client gửi mặc định "
+ "`applyLevelFinalize = true`; ra DaDuyet ở đây là quy trình 3 Bước bị nuốt còn 1 lượt ký");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeFalse("không có cờ thì không có kết-thúc-sớm để ghi");
LinesShouldBeUnpriced(after, "mới Bước 1 Cấp 1 — chưa có gì để chốt");
// Và phiếu ĐANG đứng ở Cấp mang cờ vẫn nguyên (cờ là thuộc tính của CẤP, không phải
// của quy trình/phiếu — một lượt duyệt ở Cấp khác không được kích hoạt nó).
var atFlag = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(atFlag, ContractSigningPlanPhase.ChoDuyet, 1, 2, "phiếu kia bất động");
atFlag.EndedByLevelFinalize.Should().BeFalse("cờ chỉ bật khi CHÍNH Cấp mang cờ bấm duyệt");
}
// ===================================================================
// (T-e) GIỮ CỜ QUA LỆNH TẠO QUY TRÌNH — `ApprovalWorkflowV2AdminFeatures.cs:376-385`.
//
// Trước @K3, handler tạo version ÉP `AllowApproverFinalize = false` riêng cho type-10
// (containment thời "PHÁ VỠ" S155). Owner đảo phán quyết ⇒ ép-false đã gỡ. Nếu ai đó
// khôi phục nó thì admin vẫn tick được cờ trên Designer, DB vẫn im lặng lưu `false`, và
// T-a phía trên KHÔNG bắt được (T-a set cờ thẳng vào DB, không đi qua lệnh tạo) —
// đúng hình "config-lie" gotcha #78: giao diện nói một đằng, máy chạy một nẻo.
//
// Ba vế để phép đo không rỗng:
// (i) Cấp tick cờ ⇒ DB `true`;
// (ii) Cấp KHÔNG tick ⇒ DB `false` (chứng handler không bật bừa mọi dòng);
// (iii) ĐỐI CHỨNG type khác (DuyetNcc = 1) cũng `true` ⇒ cơ chế là ECHO tham số, không
// phải một nhánh đặc-cách theo loại — vế này là thứ phân biệt "đã gỡ ép-false"
// với "đổi ép-false thành ép-true".
// ===================================================================
[Fact]
public async Task CreateAwDefinition_LoaiKhkk_GiuNguyenCoKetThucTaiCap_KhongConEpFalse()
{
using var f = new KhkkWorkflowFixture();
var plain = await AddUserAsync(f, "NV duyệt Cấp 1");
var finalizer = await AddUserAsync(f, "TP.CCM — Cấp kết thúc");
var khkkId = await CreateAwDefinitionAsync(
f, ApprovalWorkflowApplicableType.ContractSigningPlan, "KHKK-N1", plain.Id, finalizer.Id);
var peId = await CreateAwDefinitionAsync(
f, ApprovalWorkflowApplicableType.DuyetNcc, "QT-PE-DOICHUNG", plain.Id, finalizer.Id);
var khkk = await ReadLevelFlagsAsync(f, khkkId);
khkk.Should().HaveCount(2, "lệnh tạo dựng đúng 2 Cấp");
khkk.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
"admin tick 'Cấp này KẾT THÚC' thì DB phải lưu true — ép false ở đây là cờ chết: "
+ "Designer hiện đã tick, service `…WorkflowService.cs:276` đọc ra false, "
+ "và không ai biết cho tới khi phiếu thật treo chờ CEO");
khkk.Single(l => l.Approver == plain.Id).Finalize.Should().BeFalse(
"Cấp KHÔNG tick phải là false — nếu true thì handler đang bật bừa, và vế trên xanh vô nghĩa");
var pe = await ReadLevelFlagsAsync(f, peId);
pe.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
"ĐỐI CHỨNG: loại quy trình khác cũng giữ cờ ⇒ cơ chế là echo tham số của lệnh, "
+ "không phải một nhánh đặc-cách theo `ApplicableType`");
}
private sealed record LevelFlag(int Order, Guid Approver, bool Finalize);
/// Tạo 1 quy trình 1 Bước × 2 Cấp qua lệnh THẬT: Cấp 1 không tick, Cấp 2 tick kết-thúc.
private static async Task<Guid> CreateAwDefinitionAsync(
KhkkWorkflowFixture f, ApprovalWorkflowApplicableType type, string code,
Guid plainApprover, Guid finalizeApprover)
{
await using var db = f.NewDb(TestCurrentUser.System());
var handler = new CreateAwDefinitionCommandHandler(db);
return await handler.Handle(
new CreateAwDefinitionCommand(
ApplicableType: (int)type,
Code: code,
Name: $"Quy trình {code}",
Description: null,
Steps:
[
new CreateAwStepInput(1, "Bước 1", null,
[
new CreateAwLevelInput(1, "Cấp 1", plainApprover),
new CreateAwLevelInput(2, "Cấp 2", finalizeApprover, AllowApproverFinalize: true),
]),
]),
CancellationToken.None);
}
private static async Task<List<LevelFlag>> ReadLevelFlagsAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.Step!.ApprovalWorkflowId == workflowId)
.OrderBy(l => l.Order)
.Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
.ToListAsync(CancellationToken.None);
}
} }

View File

@ -244,7 +244,7 @@ public class ContractSigningPlanGroupAndCatalogTests
{ {
await using var db = f.NewDb(actor); await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp()); var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None); return await svc.TransitionAsync(planId, action, comment, ct: CancellationToken.None);
} }
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkGroupFixture f, Guid planId) private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkGroupFixture f, Guid planId)
@ -821,4 +821,83 @@ public class ContractSigningPlanGroupAndCatalogTests
new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup), new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup),
CancellationToken.None); CancellationToken.None);
} }
// ===================================================================
// [K3 S164 — 2026-07-31] Rào (v) PIN-ĐÚNG-NHÓM — `ContractSigningPlanFeatures.cs:409-420`.
//
// 8 quy trình seed @K3 mang mã họ `KHKK-N{n}`; phiếu nhóm 3 mà pin quy trình nhóm 5 thì
// toàn bộ dây chuyền ký (người ký, thứ tự trạm) là của LOẠI HỢP ĐỒNG KHÁC — sai im lặng,
// chỉ lộ khi hồ sơ tới tay người không có thẩm quyền ký. Mã NGOÀI họ (legacy/chung) cố ý
// không bị ràng buộc.
//
// Ba điều được đo, mỗi cái bịt một cách "xanh giả":
// (1) CÙNG MỘT quy trình `KHKK-N5` cho ra HAI kết quả trái ngược khi chỉ đổi nhóm của
// phiếu (3 → chặn, 5 → cho qua). Chỉ đo một chiều thì một rào chặn-tuốt hoặc một
// rào chưa-bao-giờ-chạy đều xanh.
// (2) Lần bị chặn KHÔNG ĐỐT MÃ: rào (v) đứng TRƯỚC `codeGen.GenerateAsync` (`:428`), mà
// codegen TỰ COMMIT trong transaction riêng ⇒ nếu ai đó đảo thứ tự, số thứ tự bốc hơi
// vĩnh viễn và mã phiếu nhảy cóc. Đo bằng chính mã của phiếu hợp lệ ĐẦU TIÊN (001).
// (3) Mã ngoài họ đi lọt — vế này giữ cho rào không nuốt các quy trình chung.
// ===================================================================
[Fact]
public async Task Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa()
{
using var f = new KhkkGroupFixture();
var bch = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var pe = await SeedPeForCreateAsync(f);
var wfN5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
var wfN3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
var wfLegacy = await SeedWorkflowAsync(f, "QT-KHKK-LEGACY", steps: [[[approver.Id]]]);
// ---- (1a) Phiếu nhóm 3 pin quy trình nhóm 5 ⇒ chặn, và nói ĐÍCH DANH quy trình sai.
var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3);
var ex = await wrongGroup.Should().ThrowAsync<ConflictException>(
"quy trình họ KHKK-N{n} chỉ dùng được cho phiếu đúng nhóm n — sai nhóm là ký sai dây chuyền");
ex.Which.Message.Should().Contain("KHKK-N5").And.Contain("N3",
"thông báo phải chỉ ra CẢ quy trình đang pin lẫn nhóm của phiếu, nếu không người dùng "
+ "chỉ thấy '409' và không biết đổi cái gì");
// Bị chặn thì KHÔNG được để lại dấu vết nào.
(await ReadPlanScopesAsync(f)).Should().BeEmpty("rào ném trước khi `Add` ⇒ không có phiếu nửa vời");
// ---- (2) + (1b) Pin ĐÚNG nhóm ⇒ qua. Mã 001 chứng minh lần 409 không tiêu số thứ tự.
var okSameGroup = await CreateAsync(f, bch, pe.PeId, wfN3, approvalGroup: 3);
okSameGroup.MaKeHoach.Should().Be("KHKK/2026/001",
"rào (v) đứng TRƯỚC codegen ⇒ lần bị chặn KHÔNG đốt mã; ra 002 tức là số thứ tự đã bị "
+ "tiêu cho một phiếu chưa từng tồn tại (codegen tự commit, không rollback theo request)");
// ---- (1c) CHÍNH quy trình vừa bị chặn, nay hợp lệ vì phiếu đúng nhóm 5.
var okOtherGroup = await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 5);
okOtherGroup.MaKeHoach.Should().Be("KHKK/2026/002",
"cùng một quy trình KHKK-N5: nhóm 3 bị chặn, nhóm 5 cho qua — hai kết quả trái ngược "
+ "chỉ khác đúng một biến, đó là bằng chứng rào SO KHỚP chứ không chặn-tuốt");
// ---- (3) Mã ngoài họ `KHKK-N{n}` KHÔNG bị ràng buộc (quy trình chung/legacy).
var okLegacy = await CreateAsync(f, bch, pe.PeId, wfLegacy, approvalGroup: 4);
okLegacy.MaKeHoach.Should().Be("KHKK/2026/003",
"quy trình ngoài họ đi thẳng — rào chỉ áp cho 8 mã nhóm seed @K3");
// ---- Chốt bằng ĐĨA: 3 phiếu, mỗi phiếu pin đúng quy trình của nó.
var scopes = await ReadPlanScopesAsync(f);
scopes.Should().BeEquivalentTo(new[]
{
new PlanScope("KHKK/2026/001", 3, wfN3),
new PlanScope("KHKK/2026/002", 5, wfN5),
new PlanScope("KHKK/2026/003", 4, wfLegacy),
}, "nhóm duyệt + quy trình phải được ghi đúng như lúc tạo — rào cho qua mà lưu sai cặp "
+ "thì phiếu vẫn chạy nhầm dây chuyền, chỉ là muộn hơn một bước");
}
private sealed record PlanScope(string MaKeHoach, int ApprovalGroup, Guid ApprovalWorkflowId);
private static async Task<List<PlanScope>> ReadPlanScopesAsync(KhkkGroupFixture f)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlans.AsNoTracking()
.OrderBy(p => p.MaKeHoach)
.Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value))
.ToListAsync(CancellationToken.None);
}
} }