[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".
**(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.
🔸 **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.**
🧊 *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`.
> **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.
**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).

View File

@ -173,6 +173,14 @@ function resolvePath(key: string): string | null {
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') {
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

View File

@ -4,9 +4,17 @@
// 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.
// ĐƠ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 /
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
// KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là 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.
// 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`
// (`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"
// trong Designer — số trần rải 2 chỗ là kiểu sửa 1 quên 1.
// vì nó dùng ở HAI nơi trong file: map deep-link ngay bên dưới + gợi ý mã quy trình
// 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
// Câu giải thích DÙNG CHUNG cho tooltip 2 ô bị khoá + dòng ghi chú (1 chuỗi, không
// chép 3 ln rồi lệch chữ).
const KHKK_EARLY_FINALIZE_NOTE =
'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.'
// [K3 S164 — SUPERSEDE W1 S161] 🔴 Duyệt sớm ĐÃ MỞ cho KHKK từ K3 (owner đảo phán
// quyết S155 @S164: TP.CCM quyết lên CEO hay kết thúc). Trước đó file này có hằng
// `KHKK_EARLY_FINALIZE_NOTE` + cờ `earlyFinalizeLocked` ép 2 ô "Duyệt thẳng Cấp cuối"
// 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)
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
// (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
// "duyệt sớm" (hiện chỉ Kế hoạch ký kết HĐ, type 10) ⇒ ép 2 cờ đó về `false` TẠI ĐÂY,
// không ch khoá ô tick ở màn hình: khoá render mà payload vẫn bê giá trị cũ thì
// 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
// đều do chính màn này tạo ra với 2 cờ = false ⇒ không sinh 409.
function levelFlagsPayload(e: EditLevelEntry, lockEarlyFinalize: boolean) {
// [K3 S164 — GỠ KHOÁ] Tham số thứ 2 `lockEarlyFinalize` (W1 S161, ép 2 cờ "duyệt sớm"
// về `false` cho type 10) ĐÃ BỎ: mọi loại quy trình nay gửi đúng giá trị admin tick.
// Xoá hẳn tham số ch không để nó nằm chết — `noUnusedParameters` bật, và param mồ côi
// ở hàm 2 call-site là mồi cho lượt sau tưởng còn rào.
function levelFlagsPayload(e: EditLevelEntry) {
return {
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
@ -252,8 +260,8 @@ function levelFlagsPayload(e: EditLevelEntry, lockEarlyFinalize: boolean) {
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: lockEarlyFinalize ? false : e.allowApproverSkipToFinal,
allowApproverFinalize: lockEarlyFinalize ? false : e.allowApproverFinalize,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
allowApproverDelete: e.allowApproverDelete,
}
}
@ -630,13 +638,10 @@ function Designer({
const isEdit = editTarget !== null
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à
// 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
// ở backend ⇒ để ô tick sống = admin tưởng đã bật mà phiếu vẫn chạy y như cũ (cờ
// chết đánh lừa người cấu hình). Khoá ở CẢ HAI lớp: render (disabled + ghi chú) và
// payload (levelFlagsPayload ép false).
const earlyFinalizeLocked = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN
// [K3 S164 — GỠ KHOÁ] `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN: 2 ô "duyệt sớm"
// nay sống cho MỌI loại quy trình, kể cả Kế hoạch ký kết HĐ (type 10) — BE đã port
// đường finalize ở K3 cùng wave, và owner chốt TP.CCM là người quyết lên CEO hay
// kết thúc. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`).
const initialSteps: EditStep[] = useMemo(
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source],
@ -780,7 +785,7 @@ function Designer({
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
// 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
// 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.
...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>
</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.
Approver tick = skip thẳng Cấp cuối khi đang ChoDuyet. */}
<label
className={
earlyFinalizeLocked
? '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-slate-700'
}
title={earlyFinalizeLocked ? KHKK_EARLY_FINALIZE_NOTE : undefined}
>
Approver tick = skip thẳng Cấp cuối khi đang ChoDuyet.
[K3 S165 — gate-K3 F-2] KHKK (type-10) CHỈ mở ô "KẾT THÚC" (dưới):
service KHKK CHƯA port nhánh SkipToFinal + BE CREATE vẫn ép-false cờ
này cho type-10 ⇒ ô này DISABLE cho KHKK kẻo tick bị nuốt im lặng
(class "UI nói một đằng DB một nẻo" — đúng thứ W1 S161 cảnh báo). */}
<label className={`col-span-2 flex items-center gap-1 text-[11px] ${applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'text-slate-400' : 'text-slate-700'}`}>
<input
type="checkbox"
className="h-3 w-3"
// [W1 KHKK S161] Khoá thì hiện KHÔNG tick — khớp đúng cái
// payload gửi lên (levelFlagsPayload ép false), không để màn
// hình nói một đằng dữ liệu một nẻo.
checked={earlyFinalizeLocked ? false : entry.allowApproverSkipToFinal}
disabled={earlyFinalizeLocked}
checked={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? false : entry.allowApproverSkipToFinal}
disabled={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}
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}
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
/>
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
</label>
{/* [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). */}
<label
className={
earlyFinalizeLocked
? '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}
>
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương).
[K3 S164] Với Kế hoạch ký kết HĐ đây CHÍNH LÀ ô owner cần: tick cho
Cấp TP.CCM (anh Chương) ⇒ anh ấy quyết kết thúc hay trình tiếp CEO. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-emerald-700">
<input
type="checkbox"
className="h-3 w-3"
checked={earlyFinalizeLocked ? false : entry.allowApproverFinalize}
disabled={earlyFinalizeLocked}
checked={entry.allowApproverFinalize}
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>

View File

@ -4,9 +4,17 @@
// 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.
// ĐƠ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 /
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
// KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là 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.
// 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")]
public async Task<ActionResult<ContractSigningPlanTransitionResult>> Transition(
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 =========================
// 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" —
// 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).
// [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(
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,
// [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
// server-side (FE đã ép ở levelFlagsPayload — đây chặn curl bypass). PUT
// sửa-tại-chỗ đã có echo-so-BẰNG chặn đổi cờ ⇒ chỉ cần guard ở CREATE.
// 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155, sweep vá-3] GỠ ép-false
// 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,
AllowApproverFinalize = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverFinalize,
AllowApproverFinalize = l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).ToList(),
})

View File

@ -406,6 +406,19 @@ public class CreateContractSigningPlanCommandHandler(
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
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
// (`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

View File

@ -26,6 +26,7 @@ public interface IContractSigningPlanWorkflowService
Guid planId,
string action,
string? comment,
bool applyLevelFinalize = true,
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 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)
// đã 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ũ
@ -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}
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
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"];

View File

@ -164,6 +164,12 @@ public static class DbInitializer
await SeedSampleTravelRequestWorkflowV2Async(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);
}
@ -517,6 +523,169 @@ public static class DbInitializer
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:
// 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)
@ -1808,6 +1977,18 @@ public static class DbInitializer
(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.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 —
// 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.

View File

@ -34,16 +34,17 @@ namespace SolutionErp.Infrastructure.Services;
//
// 3 ĐIỂM ADAPT:
// (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**
// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy
// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN).
// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số
// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`
// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point).
// Đó chính là write-path 2 mà test T3 đo.
// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`)
// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án
// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔄 [K3 S165] service NAY có **HAI** nhánh
// set DaDuyet (đường-thường Cấp-cuối + level-finalize dưới đây), CẢ HAI qua choke-point;
// vẫn KHÔNG có site gán-qua-BIẾN kiểu nguồn copy (`ContractWorkflowService.cs:190-193`):
// hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số `targetPhase`
// ⇒ Admin kết thúc phiếu vẫn đi đúng đường `approve` (miễn vế "có tên trong Cấp",
// KHÔNG min choke-point). Tự-kiểm 2 lệnh grep ở khối LUẬT phải cùng khớp.
// (b) 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155] Owner @S164 đảo phán quyết (verbatim:
// "chỗ budget chốt chặn là a Chương… bài toán trần budget xem như đóng") ⇒ ĐÃ PORT
// nhánh `AllowApproverFinalize` (level-finalize, OPT-OUT khuôn PE S96 — cờ seed cho
// 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ấ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.
@ -87,6 +88,7 @@ public class ContractSigningPlanWorkflowService(
Guid planId,
string action,
string? comment,
bool applyLevelFinalize = true,
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,
@ -121,7 +123,7 @@ public class ContractSigningPlanWorkflowService(
break;
case "approve":
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct);
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, applyLevelFinalize, ct);
break;
case "return":
@ -242,7 +244,8 @@ public class ContractSigningPlanWorkflowService(
// approve — approver Cấp đang chờ duyệt (OR-of-N)
// =====================================================================
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)
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;
// ---- [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);
// hết Bước → FINALIZE.
if (currentLevelOrder < maxLevelOrder)

View File

@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
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.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
using SolutionErp.Application.ContractSigningPlans.Services;
@ -307,7 +308,7 @@ public class ContractSigningPlanApprovalTests
{
await using var db = f.NewDb(actor);
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)
@ -794,4 +795,347 @@ public class ContractSigningPlanApprovalTests
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á");
}
// ###################################################################
// [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);
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)
@ -821,4 +821,83 @@ public class ContractSigningPlanGroupAndCatalogTests
new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup),
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);
}
}