# SPEC FINAL — YC-013: 8 panel "Duyệt KHKK — Nhóm 1..8" mirror trang Duyệt NCC (S182/L13, 2026-08-08) > **Nguồn:** spec DRAFT + merge 2 review Fable — `sub-review-1-fable.md` (`PASS_WITH_FIXES 2C/5M`, F1-F12) + `sub-review-2-fable.md` (`PASS_WITH_FIXES 1C/5M+7m`, §8). Mọi file:line đã được ÍT NHẤT 1 reviewer re-verify trên cây (HEAD nền `936d1aed`). > **Owner-decisions:** D3 = **C** (YC-014, anh chốt qua AskUser) · D4 = title menu-label + phụ đề nghiệp vụ · **D5/D6 = lead chọn nhánh an toàn theo bậc-2 luật tự-trị, anh veto 1 câu là gỡ** (khai ở MỤC 3). > **Executor:** hmw fan-out, worker **Opus 5 MAX** (worker-tier-pin sẵn). Cầm ĐÚNG file này chạy. ## MỤC 1 — Ràng buộc toàn cục (R-1..R-6, đọc trước mọi wave) - **R-1 (C-1):** W1+W2 = **MỘT commit code duy nhất**. Nếu buộc tách: 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. (Bom đã armed sẵn hôm nay qua panel `/ContractSigningPlan`.) - **R-2 (M-4/F10):** trên PROD chỉ đo ĐỌC. Mọi acceptance có GHI chạy **LOCAL**. Không có endpoint re-activate — POST version mới là ratchet một chiều. - **R-3 (M-3/F9):** baseline test = **số đo tươi tại W0** ghi vào `run.md`. KHÔNG chép 644/645 từ doc nào. *(Tham chiếu: lead đo tươi @bootstrap S182 = 645 PASS (45D+600I) — vẫn phải đo lại tại W0.)* - **R-4:** 0 migration. Wave nào phát hiện cần mig ⇒ DỪNG, báo lead (premise 3-lens vỡ). - **R-5:** commit `[CLAUDE] Workflow: KHKK 8 panel nhóm mirror DuyetNcc + BE guards (0-mig)`. Artifact run-folder không trigger CI (paths-ignore verified). - **R-6 (F4):** 🔴 **CÔNG THỨC W1a KHOÁ** = `typeEnum == ContractSigningPlan ⇒ Where(w => w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive)`. **CẤM biến-thể regex `^KHKK-N[1-8]$`** (công thức FE-invest D1 — code lạ type-10 như `QT-KHKK-V2-001` sẽ rơi nhánh per-type → NUKE cả 8 nhóm). ## MỤC 2 — WAVE ### W0 — prep + đo nền (blocking) | # | Task | Đạt khi | |---|---|---| | 0.1 | `dotnet ef database update` ×2 DB (Dev + Design — kẹt Mig 71, đĩa Mig 72; đo lại drift tại chỗ) | `sqlcmd` top MigrationId = `20260806070242` cả 2 | | 0.2 | Boot API 1 lần → seeder đẻ 8 row local | SQL: 8 row `KHKK-N1..8` type-10 IsActive=1. Roster local **rỗng/đủ người đều KHÔNG phải bug** (seeder resolve email; local có 14 user Solutions thật) | | 0.3 | Đo baseline test TƯƠI → ghi `run.md` | R-3 | | 0.4 | Prod-state check ĐỌC-only: `ssh vietreport-vps` + `sqlcmd -E` (integrated — KHÔNG cần password) chạy 3 query DB-invest §4 + query audit pin-lệch (F11): `SELECT p.MaKeHoach, p.ApprovalGroup, w.Code FROM ContractSigningPlans p JOIN ApprovalWorkflows w ON w.Id=p.ApprovalWorkflowId WHERE w.Code LIKE 'KHKK-N%' AND w.Code <> CONCAT('KHKK-N', p.ApprovalGroup)` | Kỳ vọng: 8 row IsActive=1 · phiếu KHKK đều Phase=3 (DaDuyet=TERMINAL, đã verify enum) · audit = 0 row. **LỆCH ⇒ báo lead TRƯỚC W1** (nhóm Archived-oan → recovery = tạo version mới từ panel sau W2; phiếu treo pin lệch → sẽ kẹt submit sau W1b by-design, phải biết số trước) | ### W1 — BE: 5 fix + 14 test, 2 file + 1 file MỚI, 0-mig Files: `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` · `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` · **MỚI** `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs` · `src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs` (1 call-site). | # | Fix | Site | Cách | |---|---|---|---| | a | 🔴 Deactivate-per-Code type-10 | `AdminFeatures.cs:339-343` | Công thức R-6. Type khác GIỮ per-type. **Sửa comment `:339` "only ONE active per type" CÙNG COMMIT** (F12d). Ghi chú nhất quán: `nextVersion :334-337` đã per-Code sẵn | | b | Group-match guard 3-site (F1) | Create `:479-486` · UpdateDraft `:603-607` · `SubmitAsync` (Service, cạnh rào hạng-mục `:169-187` trước `LoadStepsAsync :189` — executor verify điểm chèn tại chỗ) | 🔴 **File MỚI `ContractSigningPlanWorkflowGuards.cs` — `public static class`** (KHÔNG nhét vào `Scope` internal — Submit ở Infrastructure không gọi được; KHÔNG `InternalsVisibleTo(Infrastructure)`; KHÔNG inline-duplicate). Method `EnsureWorkflowGroupMatchAsync(IApplicationDbContext, Guid awId, int approvalGroup, CancellationToken)` — move NGUYÊN logic rào (v), **GIỮ semantics cho-qua-code-ngoài-họ** (`:477-478` chủ-đích). 3 site cùng gọi. Message 409: *"Phiếu nhóm N{g} đang gắn quy trình N{k} — vào phiếu (Nháp/Trả lại) đổi lại quy trình đúng nhóm trước khi trình."* | | b2 | Pin-check IsActive ∧ IsUserSelectable (F5 — **D6**) | Create (sau rào iv) + UpdateDraft (CHỈ trong nhánh `awId != plan.ApprovalWorkflowId` `:603`) | Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147` — 2 message tách "đã ngừng hoạt động"/"chưa ghim". Giữ-nguyên-pin KHÔNG check (phiếu cũ pin bản archived vẫn sửa GhiChu) | | c | Changelog PUT type-10 | `AdminFeatures.cs:863-893` | Nhánh `else if type-10`: query `ContractSigningPlans` (`ApprovalWorkflowId==def.Id && Phase != DaDuyet && Phase != TuChoi`; query-filter tự loại phiếu xoá-mềm — ĐÚNG cho changelog) → add `ContractSigningPlanChangelog { EntityType=Workflow /*=4 enum SẴN*/, 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 (kèm Reason) }` mirror block PE. *(Dialog PUT đang HỨA nguyên văn "ghi vào lịch sử phiếu đang chạy" `Page.tsx:963-977` — fix này làm lời hứa thành thật)* | | d | DELETE usage-check → 409/404 (F2+F3+M-1) | `AdminFeatures.cs:1193-1205` | (i) đếm pin **8 module** có cột `ApprovalWorkflowId` (grep Domain để liệt, đừng chép): PE/Contract/KHKK **BẮT BUỘC `IgnoreQueryFilters()`** (soft-deleted vẫn chặn FK) · Proposal/Leave/Ot/Vehicle/Travel đếm thẳng — 🔴 5 module này pin **LOOSE 0-FK** ⇒ guard này là **RÀO DUY NHẤT chống dangling** (rationale 2 vế: FK-modules = UX 547→409; office = DATA-INTEGRITY) · (ii) đếm opinions **8 bảng `*LevelOpinion`** theo level-ids của workflow, `IgnoreQueryFilters()` → 409 "N phiếu đang gắn + M chữ ký" · (iii) `KeyNotFoundException` → `NotFoundException` (id lạ = 404, hết 500 thô — middleware không map BCL) · (iv) **D5:** 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) | **Test W1 — 14 ca (test-before a/b/b2 · test-after c/d).** Homes verified: `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs` + `ContractSigningPlanApprovalTests.cs`: T1 create v2 `KHKK-N1` ⇒ N2..N8 bất biến + N1 v01→false v02→true · T2 regression type-1 Code-mới-cùng-type vẫn tắt Code cũ · T3 POST type-10 **code lạ** `QT-KHKK-V2-001` ⇒ 8 nhóm bất biến (F4) · T4 UpdateDraft re-pin lệch nhóm ⇒ 409 · T5 phiếu pin-lệch PRE-EXISTING (seed thẳng DbContext) Submit ⇒ 409 · T6 code ngoài họ: Create/UpdateDraft/Submit PASS như cũ (negative M-5c.ii) · T7 re-pin bản archived ⇒ 409 (D6) · T8 PUT workflow nhóm có phiếu ChoDuyet ⇒ 1 row changelog đúng shape · T9 phiếu DaDuyet/TuChoi ⇒ 0 row (negative) · T10 DELETE có pin KHKK ⇒ 409 · T11 **phiếu KHKK ĐÃ XOÁ MỀM pin ⇒ vẫn 409** (F2) · T12 **phiếu Leave (office, 0-FK) pin ⇒ 409** (F3 — hôm nay là 200-xoá-êm) · T13 id không tồn tại ⇒ 404 · T14 xoá version CUỐI nhóm ⇒ 409 (D5); version 0-usage không-phải-cuối ⇒ xoá OK. **Đạt khi:** build 0-err + FULL suite = baseline-W0 + 14, 0 fail. ### W2 — FE: fe-admin `src/pages/system/ApprovalWorkflowsV2Page.tsx` (~70-100 dòng) + `KhkkCreatePage` ×2 app | # | Task | Site | 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` (queryKey dùng chung — invalidate prefix tự tươi cả panel nhóm lẫn panel type-10, đã verify `:292/:348`) | | b | Synthetic slice per-Code | `:299-301` | `versions = type10.history.filter(d => d.code === khkkGroupWorkflowCode(g))` (🔴 `History` = FULL list kể cả active — verified BE `:206-211`; panel tự filter `!isActive` khi render) · `active = versions.find(v=>v.isActive) ?? null` (KHÔNG đọc ô `Active` per-type — 1-trong-8) · giữ shape `TypeSummaryDto` | | c | `lockedCode` xuyên suốt | `TypePanel:357` → `Designer:620/:650-651/:798/:920-933` | prop `lockedCode = khkkGroupWorkflowCode(g)`; ô Mã `disabled={isEdit \|\| !!lockedCode}` + hint "Mã khoá theo nhóm — version tự tăng" (F12a); POST gửi `lockedCode ?? code` | | d | **D3=C thi hành — danh-sách CHỐT (F6+M-2 merge)** | `:1235-1328` | Gate `applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN` (áp cả panel ContractSigningPlan cũ — nhất quán). **6 ô** (#1 `allowReturnOneLevel` · #2 `allowReturnOneStep` · #3 `allowReturnToAssignee` · #5 EditDetails `:1268-1276` · #6 EditBudget `:1277-1285` · #9 Delete `:1320-1328`): thêm `disabled` + `title` tooltip "Chưa áp dụng cho KHKK" — 🔴 **GIỮ `checked={entry.}` state THẬT, CẤM ép false** (trung thực với DB; payload `levelFlagsPayload :255-267` echo state, không đổi). **Ô #4 `allowReturnToDrafter`**: disabled + hiển thị **state thật (thường TRUE)** + tooltip RIÊNG *"KHKK hiện luôn trả về người soạn khi Trả lại — cờ chưa phân nhánh"* (KHÔNG mờ-kiểu-chưa-có — hành-vi thật là LUÔN-CHO). **#7 SkipToFinal GIỮ NGUYÊN** như đang có (`:1292-1302`) · **#8 Finalize SỐNG** | | 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 | **Ô Ngưỡng CEO — NGOÀI D3, disable type-10 VÔ ĐIỀU KIỆN** (F7) | `:944-961` | `disabled={isEdit \|\| applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}` + helper-text "KHKK không dùng ngưỡng máy — NĐUQ quyết" + POST type-10 gửi `null`. Tiền lệ `:1292`. (`CeoApprovalThreshold` KHÔNG port VĨNH VIỄN — OG-3) | | g | Auto-pin lọc isActive ×2 app (F5/D6) | fe-user `KhkkCreatePage.tsx:114` + fe-admin `:113-116` | `find(w => w.isActive && code-match)` — giữ filter `isUserSelectable :104`. Mirror LOGIC 2 app (2 file vốn khác nhau — không cần byte-equal) | | h | Comment lệch + D4 phụ đề | `:641-644` + PageHeader `:305-318` | sửa comment "2 ô duyệt sớm sống cho MỌI loại" cho khớp thực tế (m-2) · 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, hết landing · A2 dialog đúng khuôn ảnh theo D3=C (title `KHKK-N{n} v01 (giữ nguyên phiên bản)`, ô #4 tick-disabled, 6 ô disabled-state-thật, rule-box) · A2b sửa-tại-chỗ THẬT 1 nhóm ⇒ 204 + panel giữ v01 (M-5/m-5) · A3 tạo v02 N1 ⇒ nằm ĐÚNG panel N1 + badge 7 nhóm KHÔNG mất · A4 regression 4 route cũ + fe-user auto-pin. **Đạt khi:** `npm run build` ×2 app + tsc 0-err. 🔸 **Răng nút Ghim (sửa theo F5):** sau D6, bỏ-ghim **bản active** = chặn tạo phiếu nhóm; bỏ ghim cả 8 = đóng cửa tạo phiếu KHKK toàn cục (không có rào chặn bỏ-ghim-cái-cuối — khai, không rào đợt này). ### W3 — **N/A** (D3=C đã chốt; port sau theo lệnh owner = gỡ mờ per-cờ CÙNG wave, khuôn K3-F5) ### W4 — test-specialist đóng suite: FULL `dotnet test SolutionErp.slnx` = baseline-W0 + 14, 0 fail. Số canonical đổi ⇒ update DUY NHẤT `docs/STATUS.md` row Tests (B1). ### W5 — ship + verify 1. **Commit MỘT phát** BE+FE+tests (R-1, R-5) → push. 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: đổi nếu làm W2e/W2g-fe-user, GIỮ nếu không — ĐO đừ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:** login `admin@solutions.com.vn` → ≥3/8 panel render đúng slice + mở dialog rồi Hủy · 3 query SQL + audit pin-lệch (F11) · **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-tầng (#82). 4. Đóng sổ: STATUS/HANDOFF/WAL + tick `YC-013` + khai **accepted-risk**: race 2-admin cùng Code → 500 thô (UNIQUE đỡ data — không mở scope middleware). ### Rollback 0-mig ⇒ revert commit sạch phần MÁY. Data ở lại hợp lệ (changelog rows = audit-history; v02+ = ratchet vô hại, 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. ## MỤC 3 — Quyết định + khai | # | Quyết | Ai | Nội dung | |---|---|---|---| | D3 | ✅ **anh** (YC-014) | **C** — mờ ô chưa nghe, thi hành theo danh-sách-chốt W2d (6 ô disabled-state-thật + #4 forced-true-disabled + #7 giữ + #8 sống) | | D4 | ✅ lead (reviewer đồng ý) | title = menu-label + phụ đề nghiệp vụ | | D5 | 🟡 **lead chọn CÓ** (bậc-2 an toàn — reviewer-2 M-1 khuyến nghị CÓ) | rào delete-last-version nhóm ⇒ 409 (chặn seeder-tái-sinh khỏi đường UI). **Anh veto 1 câu là gỡ** | | D6 | 🟡 **lead chọn CÓ** (bậc-2 an toàn — reviewer-1 F5 khuyến nghị; tiền lệ vá-3 bridge CÙNG LỖ cho type-3) | pin-check IsActive∧IsUserSelectable + FE lọc isActive ×2 app. **Anh veto 1 câu là gỡ** | **Khai mâu-thuẫn lens (F8):** (a) công thức W1a: BE-invest thắng, FE-invest regex BỊ CẤM (R-6) · (b) D3: FE-invest nghiêng A, BE-invest nghiêng C — anh đã chốt C · (c) DB-invest W-DB-2 (param `code=` phía BE) = **KHÔNG LÀM** — chọn client-side slice (payload nhỏ, 0-đổi-BE); disposition khai tại đây. **Nợ NGOÀI scope (khai, không lặng):** GET Overview 0-policy lộ roster+flags cho mọi user authenticated (owner ngó sau) · roster 8 nhóm = snapshot email lúc seed, không tự sync theo QT-DN-V2-001 · `Khkk_WfView` panel aggregate giấu 7/8 nhóm (re-point = việc riêng) · sửa-workflow không notify approver phiếu treo (parity PE) · `DEFAULT_CODE_BY_TYPE[10]` panel cũ vẫn gợi mã ngoài-họ (sau W1a vô hại — per-Code) · comment "7 bảng" `:1120` · race 2-admin 2601→500 (accepted).