# SUB-REVIEW-2 (Fable) — lens HOÀN-CHỈNH-VẬN-HÀNH + tổng hợp WAVE-PLAN FINAL > Run: 2026-08-08-S182-khkk-awf-mirror-ncc · Reviewer #2/2 (`/fable-real reviewer`, YC-013) > Trạng thái: ĐANG ĐỔ RUỘT — skeleton viết lúc spawn, Edit liên tục. ## VERDICT **`REVIEW-2: PASS_WITH_FIXES — 1C/5M` (+7 minor).** Spec DRAFT nền TỐT — 3 tiền đề lớn (0-mig · W1a per-Code P1 · synthetic slice + lockedCode) đều **re-verify ĐỨNG** trên cây; không có finding nào đảo hướng thiết kế. Toàn bộ fix đã được vá thẳng vào **§8 WAVE-PLAN FINAL** — executor cầm §8 chạy, không cần đọc lại spec DRAFT. | # | Mức | Finding | Vá ở | |---|---|---|---| | C-1 | 🔴 C | Spec không cấm ship W2 (FE) trước W1a (BE) — 8 nút "Tạo phiên bản mới" nối thẳng bom deactivate-per-type (`:339-343`); bom đã armed sẵn qua panel `/ContractSigningPlan` hôm nay | §8 R-1 | | M-1 | M | DELETE hết version 1 nhóm → seeder TÁI SINH v01 roster-snapshot lúc restart (verified `:610` AnyAsync-per-Code) — spec không khai; giữa 2 restart nhóm bị CHẶN tạo phiếu | §3 · §8 W1d-(iv) D5 | | M-2 | M | D3=C có bẫy thi công: pattern SkipToFinal ép `checked=false` — áp nguyên cho 6 ô còn lại thì ô #4 (seed default TRUE, runtime LUÔN trả về Drafter) hiển thị nói dối chiều ngược | §6 · §8 W2d | | M-3 | M | Spec chép số baseline "645" ⟂ STATUS canonical "644" — vi phạm B1 + measured-label gate | §5 · §8 R-3/W0.3 | | M-4 | M | Acceptance #3 (tạo v02) không nói chạy đâu; trên PROD = mutation MỘT CHIỀU (verified: controller 0 endpoint re-activate) | §4 · §8 R-2 | | M-5 | M | Test W1 thiếu 3 ca: Submit chốt-cuối pin-lệch pre-existing · DELETE nhánh opinions-only · 2 ca negative (changelog phiếu terminal = 0 row; code ngoài họ vẫn PASS) | §5 · §8 T1.5/T1.8/T1.10/T1.6 | | m-1..m-7 | m | W0 "local rỗng người" không chắc · comment `:641-644` drift · cicd thiếu marker byte-level · thiếu pre-deploy prod-state check · acceptance thiếu phép đo UI-ghi · DELETE id-không-tồn-tại = 500 (KeyNotFoundException không map) · `DEFAULT_CODE_BY_TYPE[10]` panel cũ vẫn gợi mã ngoài-họ | §2-§7 · §8 | **Còn CHẶN fan-out:** anh chốt **D3** (khuyến nghị **C** — tiền lệ sống ngay trang này, A = mìn-ngủ khi port sau, B = scope ×3-5) + **D5** (rào delete-last-version nhóm, khuyến nghị **CÓ** — 1 nhánh if rẻ hơn viết doc giải thích tái sinh). D4 chốt rồi. **Verify độc lập đã chạy (không tin invest chay):** seeder `:549-687` · deactivate `:339-343` + ép-false `:387` · DELETE `:1193-1205` + middleware `:32-39` · controller 5-action/0-reactivate · KhkkCreatePage `:97-116` 2 app · TypePanel/Designer/payload `:255-267/:286-459/:620-978/:1225-1354` · rào (v) `:479-486` + UpdateDraft `:603-607` + Submit `:160-210` · enum Phase (`DaDuyet=3` ⇒ 3 phiếu prod là terminal, KHÔNG phải treo) + enum `ChangelogEntityType.Workflow=4` sẵn · khuôn changelog PE `:863-893` · deploy.yml paths-ignore + build-2-FE · test-homes 2 file tồn tại · STATUS row test 644. ## 1. Thứ tự wave + phụ thuộc **Topology W0→W5 về logic là ĐÚNG** (prep → BE → FE → [W3 điều kiện] → test → ship), nhưng spec **THIẾU câu ràng buộc ghép-deploy** — đây là finding nặng nhất của review này: - **[C-1] Spec không cấm ship W2 (FE) trước W1a (BE).** Executor là hmw fan-out per-wave; nếu wave FE commit + CI chạy TRƯỚC wave BE, thì 8 nút "Tạo phiên bản mới" trên 8 panel mới = 8 cò súng nối thẳng vào bom deactivate-per-TYPE còn nguyên (`ApprovalWorkflowV2AdminFeatures.cs:339-343` — đã verify tận mắt: `Where(w.ApplicableType == typeEnum && w.IsActive)` tắt CẢ 8). Chú ý thêm 2 sự thật làm C-1 nặng hơn spec tưởng: 1. **Bom đã armed TỪ HÔM NAY** — panel `/system/approval-workflows-v2/ContractSigningPlan` (TYPE_CODE_TO_INT có key này, `:171`) đã cho bấm "Tạo quy trình mới" vào type-10 từ K3. W1a vì thế không chỉ là "điều kiện cho W2" mà là **vá nợ prod đang hở** — càng sớm càng tốt, độc lập với FE. 2. Chiều ngược (W1 trước, W2 sau) **an toàn tuyệt đối**: 4 fix BE đều backward-compatible với FE cũ (deactivate hẹp hơn, guard thêm 409, changelog thêm row, DELETE 409 thay 500). **Cách vá spec:** thêm vào MỤC 2 dòng ràng buộc: *"W1+W2 = MỘT commit code duy nhất (mặc định); nếu buộc tách thì thứ tự BẮT BUỘC W1→CI xanh→W2; CẤM tồn tại commit nào chứa W2 mà thiếu W1a."* — điều-kiện-chặn đã đưa vào WAVE-PLAN FINAL §8. - W1 nội bộ: a/b/c/d độc lập nhau (2 file + 1 helper), làm chung 1 wave là đúng. W1b phụ thuộc helper extract từ rào (v) `ContractSigningPlanFeatures.cs:479-486` (đã verify site) — Create + UpdateDraft (`:603-607` chỉ có `EnsureWorkflowTypeAsync`, đã verify thiếu group-match) + Submit (`ContractSigningPlanWorkflowService.cs` sau `:167 EnsureCanSubmitAsync`, cạnh rào hạng-mục `:169-187` — điểm chèn tự nhiên, đã verify). - W2 phụ thuộc W0 (local có 8 row để nhìn panel) — đúng như spec. - W4 (test-specialist đóng suite) phải chạy SAU khi D3 chốt (số test W3 thay đổi theo phương án) — spec đã ngầm đúng, wave-plan final ghi tường minh. - W5 phụ thuộc TẤT CẢ — đúng. ## 2. Deploy-safety - **1 commit hay tách?** → **1 commit BE+FE** (khuyến nghị chốt trong spec, hiện spec để trống). Lý do: 0 migration, diff nhỏ (~2 file BE + 1 file FE + tests), revert nguyên tử, và triệt tiêu C-1. Nếu lead muốn tách theo wave để dễ bisect: BẮT BUỘC W1 trước (điều kiện chặn ở §8). - **cicd path-filter (#41):** đã verify `.gitea/workflows/deploy.yml` — `paths-ignore` gồm `docs/**`, `**/*.md`, `.claude/workflows/runs/**`. Diff này chạm `src/Backend` + `fe-admin` + `tests` ⇒ **trigger đủ**. Artifact run-folder (`sub-*.md`) không trigger CI — commit spec/artifact riêng = 0s, đúng thiết kế. - **Bundle rotate:** deploy.yml build CẢ 2 FE mỗi run (`:111-125`) + copy đè cả 2 dist (`:163-169`), nhưng Vite hash theo content ⇒ fe-user không đổi source thì hash **giữ nguyên**. Chỉ W2a-d ⇒ **admin rotate, eoffice GIỮ** (đúng spec); làm W2e (chạm `fe-user/src/lib/menuKeys.ts`) ⇒ eoffice rotate theo. Khớp spec W5. - **Phiếu đang treo trên prod:** đã đối chiếu enum `ContractSigningPlanPhase` (`Domain/ContractSigningPlans/ContractSigningPlanPhase.cs:10-14`): 3 phiếu prod "phase=3" (đo S168) = **DaDuyet — TERMINAL**, không phải phiếu treo. ⇒ W1b Submit-guard và W1c changelog không đụng phiếu sống nào tại thời điểm đo. Deploy giữa giờ = app-pool restart vài giây (mỗi deploy đều vậy) + seeder idempotent skip (§3). Rủi ro THẤP. **Điều kiện:** chạy lại query 2 (GROUP BY ApprovalGroup, Phase) TRƯỚC deploy để re-confirm 0 phiếu ChoDuyet/TraLai — nếu có phiếu treo pin lệch nhóm thì nó sẽ kẹt submit sau vá W1b (by design, message chỉ việc); phải biết trước con số. - **[m-4] Pre-deploy prod-state check thiếu trong spec:** chạy query 1 (Code, Version, IsActive) TRƯỚC deploy — nếu ai đó đã lỡ bấm tạo version trên panel ContractSigningPlan từ K3 tới nay thì có nhóm đang Archived-oan; đường chữa KHÔNG cần SQL tay: sau khi W1a live, tạo version mới cho đúng nhóm đó qua UI (POST tự re-activate per-Code). - **Seeder chạy mỗi restart:** verify §3 — skip per-Code, không re-activate, an toàn. ## 3. Seeder tương tác (wipe-durability #75/#76) — VERIFIED-BY-CODE Mở `DbInitializer.cs:549-687` đọc tận mắt (không tin invest): - **Idempotency = `AnyAsync(w => w.Code == code)` (:610)** — match BẤT KỲ row nào cùng Code, không phân biệt Version/IsActive. ⇒ admin tạo v02 cho N1 (v01 archived): restart → `AnyAsync("KHKK-N1")` = true → **SKIP. KHÔNG re-seed, KHÔNG re-activate.** An toàn lớp #75/#76. ✅ - **Nhưng chiều ngược:** admin XOÁ **hết** version của 1 Code (v01 lẫn v02, khi chưa pin) → boot kế `AnyAsync` = false → seeder **TÁI SINH v01** `IsActive=true, IsUserSelectable=true` với **roster snapshot email cứng** (:554-585). Đây là hành vi lớp #75/#76 CÓ THẬT nhưng chiều "resurrection-as-safety-net" — nhóm 1..8 là cấu trúc (ApprovalGroup int trên phiếu + 8 menu leaf) nên tái sinh có lợi, SONG spec **không khai** ⇒ admin xoá nhóm rồi thấy nó sống lại sau restart = ticket "xoá không được". → finding **M-1** (kèm đề xuất rào delete-last-version ở §5/§8 — có rào thì đường UI không bao giờ chạm được ca tái sinh, mental-model sạch hơn viết doc giải thích). - Version tái sinh = `Version=1` — nếu v01 cũ đã xoá thì UNIQUE (Code,1) trống → insert sạch, không 2601. ✅ - Roster local: seeder resolve email qua `userManager.FindByEmailAsync` (:593) — local CÓ 14 user Solutions thật (CLAUDE.md "30 demo user"), nên **local KHÔNG chắc rỗng người** như W0 spec khẳng định; rỗng hay đủ đều không phải bug → finding **m-1** (chỉnh chữ W0). ## 4. Acceptance prod — đủ đo chưa - **[M-4] Acceptance #3 ("Tạo phiên bản mới nhóm N1 ⇒ v02") chạy Ở ĐÂU — spec không nói, và chạy trên PROD là mutation MỘT CHIỀU.** Đã verify controller (`ApprovalWorkflowsV2Controller.cs`): đúng 5 action, **KHÔNG có endpoint re-activate**. Tạo v02 ⇒ v01 archived vĩnh viễn; xoá v02 KHÔNG tự re-activate v01 ⇒ nhóm rơi vào trạng thái 0-active (panel hiện empty-state "Chưa có quy trình..." `:405-409`; KhkkCreatePage vẫn pin được v01 vì lọc `isUserSelectable` không lọc `isActive` — `fe-admin/src/pages/khkk/KhkkCreatePage.tsx:104`, đã verify — nhưng badge/slice sai). **Vá:** acceptance #3 chạy **LOCAL**; trên prod chỉ đo ĐỌC (UI render 8 panel + 3 query SQL). Nếu anh muốn demo #3 trên prod thật: chấp nhận v02 (bản sao y v01) tồn tại vĩnh viễn — khai rõ trước khi bấm. - **Account đo:** spec không nêu. Chốt: `admin@solutions.com.vn` (role Admin: bypass mọi policy + menu auto-grant `SeedAdminPermissionsAsync`). 8 leaf sidebar hiện cho role nào khác = tầng menu-grant per-role (display) — ĐỘC LẬP tầng API (`Workflows.Create` cho verb ghi, authenticated-only cho GET); acceptance thêm 1 dòng: *"role thường không có grant menu ⇒ không thấy leaf — đúng thiết kế, không phải bug"*. - **3 query SQL — chạy bằng đường nào:** spec DB §4 giao query nhưng không giao ĐƯỜNG. Khuôn đã chứng S177: `ssh vietreport-vps` + `sqlcmd -E` (integrated auth, KHÔNG cần credential; env password TRỐNG là bình thường). Ghi vào wave-plan để executor không đi tìm password. - **[m-5] Thiếu 1 phép đo UI-ghi trên local:** acceptance #2 chỉ MỞ dialog. Thêm: sửa-tại-chỗ 1 nhóm (đổi tên 1 NV slot / tick 1 cờ Return) → 204 → panel refresh giữ `v01` + không đổi Version — chứng PUT đường nhóm sống thật (máy PUT là generic by-Id nên gần chắc chắn PASS, nhưng "gần chắc" ≠ đo). - Acceptance #1/#2/#4 (8 slice + dialog khuôn + regression 4 route cũ): ĐỦ và đo được. Bổ sung điều kiện #4: "645 test" → **số đo tươi tại W0** (xem M-3 §5). ## 5. Test coverage W1 Spec W1 liệt 4 test — **đúng nhưng THIẾU 3 ca** (tính chốt-cuối + nhánh phủ): - **[M-5a] Submit chốt-cuối group-mismatch KHÔNG có test.** W1b vá 2-site (UpdateDraft + Submit) mà test list chỉ có ca UpdateDraft. Ca thiếu: phiếu ĐÃ pin lệch nhóm từ trước vá (seed data trực tiếp qua DbContext, bypass Create-guard) → `SubmitAsync` ⇒ 409 message chỉ việc. Đây chính là lý do tồn tại của chốt-cuối — không test thì không biết chốt có đóng. - **[M-5b] DELETE 409 nhánh ĐỦ 2 chân:** (i) phiếu pin (đã có trong spec) + (ii) **workflow hết pin nhưng còn `ContractSigningPlanLevelOpinions` trỏ Levels** (FK Restrict `:6297-6301` — cascade Steps→Levels sẽ vấp) ⇒ 409 kể tên. Chân (ii) chưa có trong test list. - **[M-5c] 2 ca NEGATIVE giữ hành-vi-cũ:** (i) changelog W1c: phiếu DaDuyet/TuChoi pin workflow bị PUT ⇒ **0 row** mới (mirror filter PE `:867-870`); (ii) workflow type-10 code NGOÀI họ `KHKK-N*` (vd `QT-KHKK-V2-001`) ⇒ Create/UpdateDraft/Submit **CHO QUA** như cũ (rào (v) comment `:477-478` khai chủ-đích) — helper extract xong mà siết nhầm cả code ngoài họ là regression lặng. - **Concurrent create 2 admin cùng nhóm (câu hỏi lead):** UNIQUE (Code,Version) đỡ được — kẻ thua 2601 → 500 thô. ĐỒNG Ý với DB-invest: chấp nhận cho màn quản trị, KHÔNG mở scope map DbUpdateException đợt này (đổi GlobalExceptionMiddleware = blast radius mọi module) — nhưng phải ghi thành **accepted-risk** trong spec final, đừng im lặng. - **Bỏ-ghim cả 8 nhóm = CHẶN TẠO PHIẾU toàn cục** (KhkkCreatePage lọc `isUserSelectable` — đã verify `:104`): hành vi CŨ có sẵn, không phải regression của wave này; acceptance đã nêu răng nút Ghim per-nhóm — thêm nửa câu "bỏ ghim cả 8 = đóng cửa tạo phiếu KHKK toàn hệ thống (không có rào BE chặn bỏ-ghim-cái-cuối)" để anh biết nút này sắc tới đâu. - **[M-3] Baseline "645 test" trong spec ⟂ STATUS.md canonical "644 test PASS (45D+599I, CI #444)"** — lệch 1 và spec đang CHÉP SỐ (vi phạm B1 + measured-label gate: con số mang nhãn đo mà không kèm phép đo làm nó trượt). Vá: spec đổi thành *"baseline = `dotnet test` đo TƯƠI tại W0, ghi số vào run.md; mọi so sánh về sau so với số W0"*. (Nếu 645 là đo tươi thật thì STATUS stale — cũng chỉ có phép đo W0 phân xử được.) - **Nơi đặt test:** homes có sẵn — `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs` (PUT/create-deactivate/delete-guard) + `ContractSigningPlanApprovalTests.cs` (submit chốt-cuối) — đã verify 2 file tồn tại. ## 6. D3/D4 — chấm phương án **D3 (7 ô chưa-wire) — chấm từng phương án theo hệ quả VẬN HÀNH:** - **A (giữ 9 ô tick được):** rẻ nhất hôm nay, đắt nhất về sau. 2 hệ quả spec CHƯA nêu đủ: (i) ticket-support loại "tôi tick Trả-về-1-Cấp mà phiếu vẫn về Drafter" — lỗi class #78 đúng nghĩa, khó debug vì DB nói một đằng máy chạy một nẻo; (ii) **mìn-ngủ khi port W3 sau này**: cờ đã tick từ nhiều tháng trước đột nhiên BẬT hành vi ngày port — surprise nguy hiểm hơn cả config-lie, và lúc đó phải audit toàn bộ tick tồn kho trước khi bật máy. A = tôi KHÔNG khuyến nghị. - **B (port cả 7):** đổi hành vi duyệt (4 return-mode chạm state-machine TraLai-toàn-phần + con-trỏ-đôi reset; F3/F4 mở đường ghi lúc duyệt; F6 thêm rào 3-lớp) — mỗi cờ 1 quyết định nghiệp vụ thuộc owner + test RED→GREEN riêng; scope ước gấp 3-5 lần toàn bộ W1+W2 cộng lại. Chỉ đáng khi anh THẬT SỰ cần semantics đó ngay. Ghi chú: kể cả B thì `CeoApprovalThreshold` vẫn không port (OG-3 NĐUQ human-gate) ⇒ B cũng không đạt "9/9 sống" tuyệt đối. - **C (mờ ô chưa-wire + tooltip):** ĐÚNG tiền lệ đã sống ngay trong trang này (`SkipToFinal :1292-1302` — checked-ép-false + disabled + title, đã verify) và trung thực #78. Khuôn giữ nguyên (9 ô cùng vị trí — "không lệch" ở nghĩa cấu trúc; trạng thái disabled là sự thật per-type, prod ĐANG hiển thị đúng kiểu này cho SkipToFinal). **→ KHUYẾN NGHỊ C**, port dần per-cờ theo lệnh owner sau (mỗi lần port = gỡ 1 ô mờ, đúng khuôn K3-F5: port + gỡ khoá CÙNG wave). **[M-2] BẪY THI CÔNG của C mà spec chưa ghi** (đã verify cơ chế payload): pattern SkipToFinal ép cả `checked={type10 ? false : state}`. Ép-checked AN TOÀN với F2 **chỉ vì** DB type-10 F2 luôn false (BE ép-false từ W1 S161 trước khi 8 nhóm seed). Áp NGUYÊN pattern cho 6 ô còn lại là SAI 2 chỗ: 1. `AllowReturnToDrafter` seed **default TRUE** trên mọi slot — ép checked=false ⇒ dialog hiển thị "không trả về Drafter" trong khi runtime LUÔN trả về Drafter khi Trả lại (`ContractSigningPlanWorkflowService.cs` ReturnOrRejectAsync không đọc cờ) — display nói dối theo chiều NGƯỢC. 2. Ô #4 nói riêng phải hiển thị **tick-thật + disabled** + tooltip *"KHKK hiện luôn trả về Drafter khi Trả lại — cờ chưa phân nhánh"*. **Luật thi công C:** 6 ô (4 Return + EditDetails + EditBudget) chỉ thêm `disabled` + `title`, **GIỮ `checked={entry.}` state thật, KHÔNG ép false**; payload `levelFlagsPayload` (`:255-267`) không đổi (echo state — đã verify PUT gửi state không gửi display). SkipToFinal giữ nguyên như đang có. **+ ô "Ngưỡng CEO" (`:944-961`)**: POST-mode disable khi type-10 + helper-text "KHKK không áp ngưỡng CEO (NĐUQ)" (PUT-mode đã khoá sẵn `disabled={isEdit}`). Điều kiện disable dùng `applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN` (không phải chỉ khkkGroup) ⇒ panel `ContractSigningPlan` cũ đồng bộ luôn — nhất quán, đúng. **D4 (title panel):** ĐỒNG Ý phương án lead: title = menu-label "Duyệt KHKK — Nhóm {g}" (khớp chữ user vừa bấm ở sidebar — nguyên tắc least-surprise) + phụ đề nghiệp vụ `khkkGroupMenuLabel(g)` (helper 2-app identical có sẵn `types/khkk.ts:159-168`). 1 dây nhỏ executor hay quên: khuôn hiện chỉ render `applicableTypeLabel` (PageHeader `:309-317`) — phụ đề cần thêm ~1 prop/1 dòng ở panel; ghi vào wave-plan. ## 7. Rollback + monitoring - **Revert code = sạch phần MÁY, không sạch phần DATA — và đó là CHẤP NHẬN ĐƯỢC, nhưng phải khai:** 1. `ContractSigningPlanChangelogs` rows do W1c ghi (nếu có PUT sau deploy) — bảng có sẵn từ Mig 69, row là audit-history hợp lệ, revert KHÔNG cần xoá. Vô hại. 2. Version v02+ do admin tạo sau deploy — tồn tại vĩnh viễn (ratchet §4). Revert FE chỉ làm nó "tàng hình" khỏi panel nhóm (quay về landing), KHÔNG hỏng runtime (phiếu pin by-Id). 3. 🔴 Revert W1a ⇒ **bom deactivate-per-TYPE TÁI VŨ TRANG** — lần POST type-10 kế tiếp tắt cả 8 nhóm. Rollback-note bắt buộc: nếu revert, coi panel ContractSigningPlan + 8 panel nhóm là NO-GO cho "Tạo phiên bản mới" tới khi re-apply. - **cicd-monitor 3-chân — spec nói khuôn nhưng thiếu MARKER cụ thể [m-3]:** 1. **Chân CI:** Run #N success + test-gate xanh (đối chiếu số W0). 2. **Chân bundle admin (byte-level, khuôn #77):** curl `admin.solutions.com.vn` lấy tên bundle mới ≠ bundle cũ (STATUS `:479` canonical) + grep trong js mới chuỗi chỉ-có-sau-W2: `KhkkN1` (regex parse) — control-âm: bundle cũ 0-hit. eoffice: KHÔNG đổi hash nếu bỏ W2e (đo để chứng, không suy). 3. **Chân BE DLL (khuôn S177 2-encoding):** ssh + grep `SolutionErp.Application.dll` token mới W1b (tên helper, vd `EnsureWorkflowGroupMatch`) — UTF-8 lẫn UTF-16, control-dương bằng token cũ đã biết có. 4. **Chân data (post-deploy):** chạy lại query 1 — kỳ vọng 8 row `IsActive=1` y trước deploy (deploy KHÔNG tự đổi data; lệch = có người bấm gì đó giữa chừng). - **Post-deploy smoke KHÔNG-GHI trên prod:** login admin → 3/8 panel bất kỳ render đúng slice + mở dialog Sửa-tại-chỗ rồi Hủy. KHÔNG bấm "Tạo phiên bản mới" trên prod trong smoke (M-4). ## 8. WAVE-PLAN FINAL — bản executor cầm chạy (hmw Opus MAX) > Đã vá đủ C-1 + M-1..M-5 + m-1..m-7 của review này vào thân plan. **2 điểm quyết còn CHẶN trước khi fan-out:** **D3** (khuyến nghị **C**) + **D5** (rào delete-last-version, khuyến nghị **CÓ**). D4 đã chốt (title menu-label + phụ đề nghiệp vụ). Mọi `file:line` dưới đây đã re-verify trên cây S182 (HEAD nền `936d1aed`). ### Ràng buộc toàn cục (đọc trước khi chạy bất kỳ wave nào) - **R-1 (từ C-1):** W1+W2 = **MỘT commit code duy nhất** (mặc định). Nếu lead tách: thứ tự BẮT BUỘC W1 → CI xanh → W2. **CẤM mọi commit chứa W2 mà thiếu W1a** — nút "Tạo phiên bản mới" trên panel nhóm khi BE còn deactivate-per-type = tắt IsActive cả 8 nhóm prod. - **R-2 (từ M-4):** trên PROD chỉ đo ĐỌC. Mọi acceptance có GHI (tạo version, sửa-tại-chỗ, xoá) chạy **LOCAL**. Không có endpoint re-activate — POST version mới là ratchet một chiều. - **R-3 (từ M-3):** baseline test = **số đo tươi tại W0** ghi vào `run.md`. Không chép 644/645 từ bất kỳ doc nào. - **R-4:** 0 migration. Nếu wave nào phát hiện cần mig ⇒ DỪNG, báo lead (premise 3-lens vỡ). - **R-5:** commit scope `[CLAUDE] Workflow: ...`; artifact run-folder không trigger CI (paths-ignore đã verify). ### W0 — prep + đo nền (blocking mọi wave sau) | # | Task | Lệnh/site | Đạt khi | |---|---|---|---| | 0.1 | Áp mig local ×2 DB (Dev+Design kẹt Mig 71, đĩa Mig 72) | `dotnet ef database update --project src/Backend/SolutionErp.Infrastructure --startup-project src/Backend/SolutionErp.Api` (×2 connection) | `sqlcmd` top MigrationId = `20260806070242` cả 2 DB | | 0.2 | Boot API 1 lần → seeder đẻ 8 row local | run SolutionErp.Api | SQL: 8 row `KHKK-N1..8` type-10 `IsActive=1`. Roster local **tùy user-seed — rỗng/đủ đều KHÔNG phải bug** (m-1) | | 0.3 | Đo baseline test TƯƠI | `dotnet test SolutionErp.slnx` | Số PASS ghi vào `run.md` — mọi so sánh sau so với số này (R-3) | | 0.4 | Prod-state check ĐỌC-only (m-4) | `ssh vietreport-vps` + `sqlcmd -E` chạy 3 query (spec DB §4) | Kỳ vọng: 8 row IsActive=1 · phiếu KHKK đều Phase=3 (DaDuyet) · opinions đếm được. **LỆCH kỳ vọng (nhóm Archived-oan / phiếu ChoDuyet-TraLai đang treo) ⇒ báo lead TRƯỚC khi W1** — phiếu treo pin lệch nhóm sẽ kẹt submit sau W1b (by design), phải biết trước con số | ### W1 — BE: 4 fix + 12 test, 2 file + 1 helper, 0-mig **File:** `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` + `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` (+`ContractSigningPlanScope` helper) + `src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs` (1 call-site). | # | Fix | Site (verified) | Cách | Test (RED→GREEN cho a/b; after cho c/d) | |---|---|---|---|---| | a | 🔴 Deactivate-per-Code cho type-10 | `ApprovalWorkflowV2AdminFeatures.cs:339-343` | `typeEnum == ContractSigningPlan` ⇒ `Where(w.Code == request.Code && w.IsActive)`; type khác GIỮ per-type (P1) | **T1.1** create v2 `KHKK-N1` ⇒ N2..N8 `IsActive` bất biến + N1 v01→false v02→true · **T1.2** regression type-1: Code MỚI cùng type vẫn tắt Code cũ · **T1.3** type-10 code ngoài họ (vd `QT-KHKK-V2-001`) chỉ tắt chính Code nó | | b | Group-match guard 3-site | extract rào (v) `ContractSigningPlanFeatures.cs:479-486` → `ContractSigningPlanScope.EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)`; gọi: Create (chỗ cũ) + UpdateDraft `:603-607` (sau `EnsureWorkflowTypeAsync`) + `SubmitAsync` (`ContractSigningPlanWorkflowService.cs` sau rào hạng-mục `:169-187`, trước `LoadStepsAsync :189`) | Code khớp `^KHKK-N([1-8])$` mà n ≠ `ApprovalGroup` ⇒ 409 message chỉ việc ("đổi quy trình trong nháp về đúng nhóm N{x} / hoặc đổi nhóm phiếu"); **code NGOÀI họ ⇒ PASS như cũ** (:477-478 chủ-đích, M-5c.ii) | **T1.4** UpdateDraft re-pin lệch ⇒ 409 · **T1.5** phiếu pin-lệch PRE-EXISTING (seed thẳng DbContext bypass guard) Submit ⇒ 409 · **T1.6** code ngoài họ: Create/UpdateDraft/Submit OK | | c | Changelog PUT type-10 | `ApprovalWorkflowV2AdminFeatures.cs:863-893` thêm nhánh `else if type-10` mirror block PE | Query `ContractSigningPlans` (`ApprovalWorkflowId == def.Id && Phase != DaDuyet && Phase != TuChoi`) → add `ContractSigningPlanChangelog { EntityType = Workflow /*=4, enum SẴN — verified*/, Action = Update, EntityId = def.Id, PhaseAtChange, UserId, UserName, Summary = "Sửa quy trình duyệt tại chỗ: {Code} v{NN} (giữ nguyên phiên bản)", ContextNote = note (kèm Reason) }`. Dialog PUT đang HỨA nguyên văn "ghi vào lịch sử phiếu đang chạy" (`ApprovalWorkflowsV2Page.tsx:963-977`) — fix này làm lời hứa thành thật cho KHKK | **T1.7** PUT workflow nhóm có phiếu ChoDuyet ⇒ 1 row đúng shape · **T1.8** phiếu DaDuyet/TuChoi ⇒ 0 row | | d | DELETE usage-check → 409 + 404 đúng | `ApprovalWorkflowV2AdminFeatures.cs:1193-1205` | (i) pre-count pin: grep `ApprovalWorkflowId` trong Domain để LIỆT ĐỦ module có cột pin (đếm từ grep, không chép số spec) + (ii) count LevelOpinions 3 bảng theo def.Id qua Levels (khuôn `CountLiveOpinionsAsync :961-965` đổi trục) ⇒ >0 = 409 đích danh "N phiếu đang pin / M chữ ký đã ký" · (iii) đổi `KeyNotFoundException` → `NotFoundException` (m-6: hiện id-không-tồn-tại = 500 thô vì middleware không map BCL exception — verified `GlobalExceptionMiddleware.cs:32-39`) · (iv) **D5 (chờ chốt, khuyến nghị CÓ):** code họ `KHKK-N[1-8]` && version CUỐI của Code ⇒ 409 "Nhóm N{n} phải còn ≥1 version" (chặn ca seeder-tái-sinh M-1 khỏi đường UI) | **T1.9** delete có pin ⇒ 409 · **T1.10** hết pin nhưng còn opinions ⇒ 409 · **T1.11** id không tồn tại ⇒ 404 · **T1.12** (nếu D5) xoá version cuối nhóm ⇒ 409 | **Test homes (verified tồn tại):** `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs` (T1.1-T1.3, T1.7-T1.12) · `ContractSigningPlanApprovalTests.cs` (T1.4-T1.6). **Đạt khi:** build 0-err + FULL suite = baseline W0 + 11-12 test mới, 0 fail. ### W2 — FE: fe-admin `src/pages/system/ApprovalWorkflowsV2Page.tsx` (file 1355 dòng) | # | Task | Site (verified) | Cách | |---|---|---|---| | a | Parse kind | `:164-172` | `typeCode?.match(/^KhkkN([1-8])$/)` → `khkkGroup`; `selectedTypeInt = khkkGroup ? TYPE_INT_CONTRACT_SIGNING_PLAN : TYPE_CODE_TO_INT[typeCode]`; query overview giữ `applicableType=10` | | b | Synthetic slice per-Code | `:299-301` | từ `types.find(t=>t.applicableType===10)`: `versions = history.filter(d => d.code === khkkGroupWorkflowCode(g))` · `active = versions.find(v=>v.isActive) ?? null` (🔴 KHÔNG đọc ô `Active` per-type — nó là 1-trong-8) · slice truyền vào `TypePanel` giữ shape `TypeSummaryDto` (`history` = versions ĐỦ cả active — panel tự filter `!isActive` `:419/:426`, verified) · label = "Duyệt KHKK — Nhóm {g}" | | c | `lockedCode` xuyên suốt | `TypePanel:357` → `Designer:620` / defaultCode `:650-651` / POST `:798` / ô Mã `:920-928` | prop `lockedCode?: string` = `khkkGroupWorkflowCode(g)`; ô Mã `disabled={isEdit || !!lockedCode}` + helper-text; POST gửi `lockedCode ?? code`. Thiếu = version tự gõ mã RƠI khỏi slice + không được rào (v) nhận | | d | **D3 thi hành (mặc định C — luật M-2 BẮT BUỘC)** | 6 ô `:1232-1285` (4 Return + EditDetails + EditBudget) + CEO `:944-961` | 6 ô: CHỈ thêm `disabled={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}` + `title` tooltip — **GIỮ `checked={entry.}` state THẬT, CẤM ép false** (payload `levelFlagsPayload :255-267` không đổi — echo state, verified). Ô #4 Trả-về-Drafter tooltip RIÊNG: "KHKK hiện luôn trả về Drafter khi Trả lại — cờ chưa phân nhánh" (hiển thị tick-thật, thường TRUE). Ô Ngưỡng CEO: POST-mode `disabled` khi type-10 + helper-text "KHKK không áp ngưỡng CEO (NĐUQ human-gate)" (PUT-mode đã khoá sẵn). SkipToFinal `:1292-1302` GIỮ NGUYÊN | | e | (optional — kéo eoffice rotate) mirror menuKeys | `fe-{admin,user}/src/lib/menuKeys.ts:31-32` | +8 const `AwV2_KhkkN1..8` — 0 hệ quả runtime | | f | (m-2) vá comment lệch | `:641-644` | comment "2 ô duyệt sớm nay sống cho MỌI loại" ⟂ thực tế SkipToFinal vẫn disable type-10 `:1286-1302` — sửa 1 dòng cho khớp | | g | (D4) phụ đề nghiệp vụ | PageHeader `:305-318` + TypePanel | khuôn hiện CHỈ render `applicableTypeLabel` — thêm ~1 prop `subtitle = khkkGroupMenuLabel(g)` (`types/khkk.ts:159-168`, 2-app identical sẵn) | **Acceptance LOCAL (R-2):** **A1** 8 leaf → 8 slice khác nhau (URL/header/card `KHKK-N{n} v01`+badge+3 Bước+4 nút+Lịch sử) hết landing · **A2** dialog Sửa-tại-chỗ đúng khuôn ảnh theo D3 (title `KHKK-N{n} v01 (giữ nguyên phiên bản)`, Drafter tick sẵn, rule-box) · **A2b** sửa-tại-chỗ THẬT 1 nhóm (đổi tên NV / tick 1 cờ Return) ⇒ 204, panel refresh giữ v01 · **A3** tạo v02 N1 (local) ⇒ nằm ĐÚNG panel N1 + badge 7 nhóm khác KHÔNG mất (răng W1a) · **A4** regression 4 route cũ render như cũ + fe-user tạo phiếu auto-pin không đổi API. **Đạt khi:** `npm run build` fe-admin (+fe-user nếu e) + tsc 0-err. ### W3 — CHỈ KHI anh chốt D3=B: port semantics per-cờ vào `ContractSigningPlanWorkflowService` (giữ nguyên nội dung spec DRAFT W3; mỗi cờ test RED→GREEN riêng; `CeoApprovalThreshold` KHÔNG port — OG-3). Mặc định **C ⇒ W3 = N/A**. ### W4 — test-specialist đóng suite Full `dotnet test SolutionErp.slnx` = baseline-W0 + N-test-mới, 0 fail · chạy SAU khi D3/D5 chốt · nếu số canonical đổi ⇒ update DUY NHẤT `docs/STATUS.md` row test (B1). ### W5 — ship + verify (sau reviewer pre-commit) 1. **Commit MỘT phát** BE+FE+tests (R-1) `[CLAUDE] Workflow: KHKK 8 panel nhóm mirror DuyetNcc + 4 BE guard (0-mig)`. 2. **cicd-monitor 4-chân (m-3):** (i) CI Run success + test-gate = số W4 · (ii) bundle **admin** rotate + grep js mới marker `KhkkN1` (control-âm: bundle cũ 0-hit; **eoffice: hash GIỮ nếu bỏ W2e / đổi nếu làm** — đo, không suy) · (iii) BE DLL: ssh grep `SolutionErp.Application.dll` token `EnsureWorkflowGroupMatch` 2-encoding UTF-8/UTF-16 (khuôn S177) + control-dương token cũ · (iv) query-1 lại: 8 row `IsActive=1` bất biến qua deploy. 3. **Acceptance PROD đọc-only (R-2):** login `admin@solutions.com.vn` → ≥3/8 panel render đúng slice + mở dialog rồi Hủy · 3 query SQL khớp · **KHÔNG bấm "Tạo phiên bản mới" trên prod** · note: role thường chưa grant menu-row ⇒ không thấy leaf = đúng thiết kế (2-layers). 4. Đóng sổ: STATUS (test + mô tả) / HANDOFF / WAL / tick `YC-013` + khai **accepted-risk**: race 2-admin-cùng-Code → 500 thô (UNIQUE đỡ data, không silent-dup) + nợ-ngoài-scope giữ nguyên khối spec (GET 0-policy · roster snapshot · `Khkk_WfView` aggregate · `DEFAULT_CODE_BY_TYPE[10]='QT-KHKK-V2-001'` panel cũ vẫn gợi mã ngoài-họ (m-7) · comment "7 bảng" `:1120`). ### Rollback 0-mig ⇒ revert commit sạch phần máy. Data ở lại (hợp lệ, khai rõ): changelog rows W1c = audit-history · v02+ đã tạo = ratchet (vô hại runtime, pin by-Id). 🔴 Revert W1a = **bom per-type TÁI VŨ TRANG** — sau revert, CẤM "Tạo phiên bản mới" mọi panel type-10 tới khi re-apply.