[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
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:
@ -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).
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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 lần 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 là KẾT THÚC quy trình (không trình tiếp CEO)</span>
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -26,6 +26,7 @@ public interface IContractSigningPlanWorkflowService
|
||||
Guid planId,
|
||||
string action,
|
||||
string? comment,
|
||||
bool applyLevelFinalize = true,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 miễn 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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user