Compare commits

...

22 Commits

Author SHA1 Message Date
706134daa9 [CLAUDE] Workflow: KHKK 8 panel nhóm mirror DuyetNcc + BE guards (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s
YC-013 (S182/L13). 8 leaf "Duyệt KHKK — Nhóm 1..8" ra đúng khuôn trang
DuyetNcc: slice per-Code + lockedCode + Sửa-tại-chỗ/Tạo-version/Ghim/Xoá.
D3=C (YC-014): 6 ô chưa-wire disabled-giữ-state-thật + tooltip trên label
(Chromium không hiện title trên input disabled — MAJOR-1); ô #4 thêm chữ
nhìn thấy được; #7 giữ; #8 Finalize 0-chạm (prod 8/88 dựa nó); ô CEO
disable type-10 + POST null.

BE 5 guard (0 migration):
- W1a deactivate-per-Code cho type-10 (Code+Type+IsActive — cấm regex);
  Code normalize Trim().ToUpperInvariant() tại đường ghi + IgnoreCase belt
  trong GroupCodePattern (MAJOR-2: SQLite test case-sensitive nguoc prod
  CI_AS collation)
- W1b ContractSigningPlanWorkflowGuards (public static, Application) —
  rào nhóm 3 cửa Create/UpdateDraft/Submit, giữ cho-qua-code-ngoài-họ
- W1b2 (D6) pin-check IsActive+IsUserSelectable 2 cửa, 2 message tách
- W1c changelog PUT type-10 vào ContractSigningPlanChangelogs
- W1d DELETE usage-check 409 đích danh (8 module pin, IgnoreQueryFilters
  cho PE/Contract/KHKK; 5 module office pin loose-0FK — guard là rào duy
  nhất chống dangling) + 404 NotFoundException + D5 rào version-cuối nhóm
  (+ ApplicableType — MINOR-3)

Test 645 → 660 (+14 spec + T15 D6-Create MAJOR-3); MINOR-1 pin nguyên câu
thứ-tự 1 ca. File test thứ 3 (GroupAndCatalog) đổi 1 assertion theo câu
409 mới của W1b — spec-change, không phải nới bar.

Khai accepted (reviewer PASS_WITH_FIXES 0C/3M+6m, đã vá 3M+2m):
- MINOR-4: panel nhóm tạo-version-mới ⇒ 6 cờ khoá đóng băng default FE
  (vô hại — BE không đọc 6 cờ đó cho type-10)
- MINOR-5: bỏ ghim/bỏ active bản cuối nhóm = đóng cửa tạo phiếu nhóm đó
  (răng thật của nút Ghim — không rào đợt này)
- race 2-admin cùng Code → 2601/500 thô (UNIQUE đỡ data, chấp nhận)

Evidence: .claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
(3 fable-invest + 2 fable-review + spec FINAL + 4 lane + precheck prod
12 phiếu/pin-lệch-0 + reviewer pre-commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:14:37 +07:00
18f8f76bac wal: flush 20260808T1211 2026-08-08 12:11:52 +07:00
1642c89912 wal: flush 20260808T0224 2026-08-08 02:24:47 +07:00
affaf66c61 wal: flush 20260808T0200 2026-08-08 02:00:54 +07:00
27d5cd1356 wal: flush 20260808T0158 2026-08-08 01:58:49 +07:00
c688c1bba4 wal: flush 20260808T0154 2026-08-08 01:54:54 +07:00
13b0038f78 wal: flush 20260808T0147 2026-08-08 01:47:30 +07:00
220ddc8fc8 wal: flush 20260808T0132 2026-08-08 01:32:32 +07:00
8d6ab2cd8e wal: flush 20260808T0122 2026-08-08 01:22:52 +07:00
6a2f5f02c9 wal: flush 20260808T0118 2026-08-08 01:18:20 +07:00
11bb2b9d11 wal: flush 20260808T0108 2026-08-08 01:08:01 +07:00
b7140ea9c7 wal: flush 20260808T0101 2026-08-08 01:01:12 +07:00
d6975e2dfd wal: flush 20260808T0057 2026-08-08 00:57:41 +07:00
ba95a68e3d wal: flush 20260808T0052 2026-08-08 00:52:43 +07:00
99df7788ed wal: flush 20260808T0033 2026-08-08 00:33:40 +07:00
a035f75a30 wal: flush 20260808T0018 2026-08-08 00:18:41 +07:00
cf25ea53c3 wal: flush 20260808T0012 2026-08-08 00:12:51 +07:00
1ff2fea3b9 wal: flush 20260808T0001 2026-08-08 00:01:19 +07:00
de62c3634a wal: flush 20260807T2356 2026-08-07 23:56:28 +07:00
ca9e7e1ad6 wal: flush 20260807T2351 2026-08-07 23:51:28 +07:00
42fd3d51f9 wal: flush 20260807T2349 2026-08-07 23:49:05 +07:00
79281d16a7 wal: flush 20260807T2348 2026-08-07 23:48:04 +07:00
44 changed files with 4394 additions and 103 deletions

View File

@ -1,11 +1,25 @@
# WAL — auto-generated, không sửa tay
updated: | session: | branch:
updated: 2026-08-08T01:08+07:00 | session: S182/L13 | branch: main
goal: (trống — không có mạch dở)
goal: YC-013 — 8 workflow "Duyệt KHKK — Nhóm 1..8" mirror ĐỦ chức năng trang Duyệt NCC (UI không lệch); pipeline anh chỉ định 3-invest-Fable → wave-plan → 2-review-Fable → hmw fan-out Opus 5 MAX
chain:
(rỗng)
[x] bookend @open 5 vòng + ctx-audit — runs/2026-08-07-S182-bookend-open/
[x] YC-013 ghi sổ (so-yeu-cau:46) + run-folder
[x] B1 3× fable-real invest XONG-TRỌN-ĐĨA: BE 21KB (generic ~85%, 4 site sửa) · DB 18KB (VERDICT 0-MIG, 3 mìn M1/M2/M3) · FE 14KB (1 file chính ~60-90 dòng, SHA-mirror KHÔNG áp dụng)
[x] B2 spec DRAFT ghi (spec-khkk-awf-mirror-ncc-DRAFT.md — W0..W5)
[x] D3 anh chốt = C "Mờ ô chưa nghe" (YC-014, sổ + owner-decisions-08-08-2026.md) ⇒ W3 HỦY
[x] B3 2× fable-real reviewer — R1 PASS_WITH_FIXES 2C/5M (F1-F12) · R2 PASS_WITH_FIXES 1C/5M+7m (§8 FINAL)
[x] B4 spec FINAL land (spec-khkk-awf-mirror-ncc-08-08-2026.md — merge 2 review, R-1..R-6 + D5/D6 bậc-2 lead-chọn-CÓ)
[x] B5 ĐỢT-1 wf_a07b53c8-47e — PRECHECK trả trọn (LỆCH-Q2-nhưng-GO: pin-lệch=0, bom chưa nổ 8/8; T11/D5 có răng THẬT trên prod; #8 Finalize=8/88 CỜ CHỊU LỰC cấm đụng; A2 cần seed local mới có răng) · W0 XONG (baseline 645, mig×2, seed 8 row) · 3 lane code ĐỨT GIỮA (BE ~40% AdminFeatures-only · FE ~85% chưa compile · TEST skeleton)
[x] B5 ĐỢT-1bis XONG 3/3 — BE 5-fix land (Guards.cs mới + 3 site + D5/D6, Application build 0-err) · FE compile sạch (tsc 0-err ×2, SHA-pair 2/2, FLAG-B spec-tự-mâu-thuẫn đã RATIFY state-thật) · TEST 14 ca land → 🎯 FULL SUITE 659 PASS / 0 FAIL (=645+14 đúng từng số)
[!] B5 ĐỢT-2: npm build ×2 (nền) ‖ reviewer pre-commit (chạy) → lead COMMIT MỘT PHÁT (R-1) → push → cicd 4-chân → acceptance prod đọc-only
next: (trống)
wf: run-id=wf_a07b53c8-47e (đợt-1 đóng) + 3 Agent-lẻ đợt-1bis | trạng-thái=1bis-đang-chạy | artifact=sub-implementer-backend-0.md (Edit tiếp) · sub-implementer-frontend-1.md (Edit tiếp) · sub-test-specialist-2.md (Edit tiếp) | relaunch=đọc 3 artifact + git diff → spawn tiếp phần thiếu; KHÔNG resumeFromRunId (cache-hit vô dụng với lane empty)
verify: (trống)
next: 3 lane 1bis xong → dotnet build + tsc + npm build ×2 + FULL suite (kỳ vọng 659 = 645+14) → reviewer pre-commit → commit 1 phát → push → cicd 4-chân
lưu ý đợt-2: T8 cần seed phiếu ChoDuyet local (prod 0 mẫu) · A2 chỉ có răng khi seed 1 level cờ=true local · eoffice bundle SẼ rotate (W2e menuKeys fe-user đã chạm)
verify: dotnet build exit-0 ; npx tsc --noEmit ×2 exit-0 ; dotnet test = 659/0fail ; git status codefiles đổi CHƯA commit (đúng — chờ đợt-2)
treo-chờ-anh (từ bookend @open, KHÔNG chặn YC-013): slot(67) upload-khi-duyệt · VK-5 nhánh A/B · (72) mfe-eval · ring5-audit slot(41)

View File

@ -26,11 +26,13 @@
13. **Muốn ĐẾM cái UI hiển thị thì DỰNG LẠI dữ liệu thật rồi chạy chính bộ lọc đã ship** — đừng đếm bằng mắt/suy luận. Và cho phép đo **tự kiểm**: cây menu dựng lại được đối chứng bằng invariant T6 (mọi key `MenuKeys.All` phải có menu-row) — dựng sai thì chỉ số đó vỡ trước.
14. **Route-resolver có nhánh ≠ thứ đó tồn tại.** `resolvePath` nhận code `'Contract'` không có nghĩa menu-row `AwV2_Contract` tồn tại (0 hit repo). Cùng họ với "bịa field": đọc nhánh code rồi suy ra dữ liệu là suy ngược.
15. 🔴 **Khuôn chỉ port được khi VỎ TRANG cùng hình dạng — đo BỀ NGANG bằng SỐ trước khi mirror.** Khuôn cột-trái `19rem` sinh ra cho trang GIẤY-CUỘN; trang 3-PANEL cao-cố-định (`h-[calc(100vh-4rem)]` + `grid-cols-[320px_1fr_360px]`, grid KHÔNG `gap`) không có 304px dư: sidebar `xl:w-80`@1366 panel chi tiết 346px→**42px**. Mirror-y-hệt ở đây = hỏng CÂM (build xanh). Cộng bề ngang ra số TRƯỚC, và nhớ cơ chế thu gọn của component có thể là `lg:hidden` = chỉ sống ở dải mà panel kia đã ẩn ⇒ vô dụng ở desktop.
16. 🔴 **`title` trên control `disabled` = CÂM ở Chromium** (Chrome/Edge nuốt sự kiện chuột của form-control disabled ⇒ tooltip không bao giờ bật; `<label>` bao ngoài KHÔNG tự thừa kế). Chỗ cắm đúng = `<label>`/wrapper **không disabled**. Hệ quả nặng hơn: **thông tin PHÂN BIỆT không được nằm sau hover** — 1 ô khoá "vì lý do KHÁC" mà chỉ nói bằng tooltip thì trông y hệt N ô khoá "chưa áp dụng" = **ngược nghĩa**, phải có chữ nhìn-thấy-được. Thêm chữ vào ô con của `grid grid-cols-2`: sibling auto-place rơi sang **cột 1** (đọc nhầm sang ô bên trái + đẩy lệch các ô `col-span-2`) ⇒ ghim `col-start-2`.
---
## 📌 Nhật ký (móc 1 dòng — verbatim → `archive/2026-H2.md`, cũ hơn → `archive/2026-H1.md` + `_INDEX.md`)
- **S182 (08-08) vá MAJOR-1** (reviewer pre-commit bắt) — 8 `title` dời `<input disabled>``<label>``ApprovalWorkflowsV2Page` (admin-only, 0 cặp mirror). Reviewer liệt 7 site, grep cùng-lớp ra **site thứ 8 `:1443` (K3, có sẵn)** — vá kèm. Ô #4 thêm **chữ hiện** `col-start-2` vì tooltip là thông-tin-phân-biệt. Vùng cấm đo lại sau khi vá: 9/9 `checked=` byte-identical · #8 `Finalize` 0 `disabled`/0 `title`. Control-âm: tiêm lại 1 title vào input ⇒ checker nổ đúng 1. **FLAG-C treo:** nút "+ Thêm NV" `:1226` cùng bệnh, ngoài scope ⇒ khai không chạm. `[s182, title-tren-disabled-la-cam, grep-cung-lop-ra-site-thu-8]`
- **S167 (08-01) GĐ3** cây 4-folder lên cụm Duyệt-HĐ → **BLOCKED @§0, 0 file chạm**. 7 loại HĐ = 1 component trang (`?type=`), KHÔNG 7 file. 3 lệch tiền-đề: vỏ 3-panel không nhận khuôn cột-19rem (@1366 còn 42px) · fe-user có 3 ứng viên trang (`MyContracts`/`Inbox`/`WfMatrix`) vs admin 1 · cặp trang KHÁC FILE (`ContractsListPage``MyContractsPage`) ⇒ SHA-pair identical bất khả thi (đối chứng dương: 4 cặp component/hook/HardCopies BẰNG nhau). +K6 đã ẩn `Contracts` khỏi sidebar admin ⇒ nửa admin là trang deep-link-only. `[s167, khuon-can-cung-vo-trang, sub-implfe-gd3.md]`
- **S166 (08-01) K6** admin thu gọn về vai NƠI-CONFIG (fe-admin ONLY 1 file, fe-user 0-diff). `filterForAdmin` lọc ĐỆ QUY ⇒ chỉ liệt 12 ROOT; **dựng lại cây seed 200 node rồi chạy bộ lọc đã ship ⇒ 166 lá → 15 lá liệt đủ tên**, tự-kiểm bằng invariant T6 (113 key `All` → 0 thiếu row). Comment vá lineage **2 lần đảo**. `[s166, k6-admin-thu-gon, route-resolver-khac-menu-row]`
- **S166 (08-01) K4c** WfView lọc nhóm (chốt cụm-2, fe-user ONLY 1 file, fe-admin 0-diff). Lọc `wf.code === khkkGroupWorkflowCode(n)` client-side ⇒ **không đổi queryKey** (khai rõ); rỗng-vì-lọc tách câu riêng. 21/21 proof + 2 control-âm. `[s166, khkk-k4c-wfview-loc-nhom]`

View File

@ -8,6 +8,7 @@ Read-only INTERNAL audit SOLUTION_ERP. Tools: Read/Grep/Glob/Bash + 5 RAG MCP. O
- NOT: external docs/CVE/lib investigator-api · write implementer · test test-specialist · architecture decision em main
## 📌 Recent invest (1-line digest; verbatim → archive + runs)
- **S182 (08-08) ×Fable KHKK 8-nhóm AWF mirror DuyetNcc [`runs/2026-08-08-S182-khkk-awf-mirror-ncc/sub-invest-be-fable.md`]:** BE generic ~85% 8 workflow seed `KHKK-N{n}` CÙNG ApplicableType=10, phân biệt bằng CODE (`DbInitializer.cs:549-687`, idempotent per-Code CỐ Ý khuôn per-type) bất biến only-1-active-per-type VỠ TỪ SEED + POST version mới deactivate CẢ 8 (`ApprovalWorkflowV2AdminFeatures.cs:339-343`); 9-checkbox = 9 cột per-Level dùng chung mọi type nhưng KHKK runtime CHỈ đọc F5 (`ContractSigningPlanWorkflowService.cs:276`) 6/9 tick cho type-10 = #78-class cờ-nằm-im; bypass rào-(v) 2-bước (UpdateDraft re-pin `ContractSigningPlanFeatures.cs:603-607` chỉ check type, không re-check group-match); DELETE version 0 usage-check + `DbUpdateException` KHÔNG được middleware map FK Restrict nổ 500 không phải 409 (`GlobalExceptionMiddleware.cs:39`); 32 policy `AwV2_KhkkN*` 0 consumer BE (0-hit Api + control-dương `KeHoachKyKet` 29-hit cùng path) mirror y 2 key `AwV2_DuyetNcc*` ; FE 8 route KhkkN đổ landing chung (`TYPE_CODE_TO_INT` thiếu key). #53 cắt return giữa phiên artifact đổ-ruột-liên-tục cứu trọn, 0 mất. Tag `[s182, seed-code-family-cung-type, khkk-runtime-chi-F5, policy-menu-only, dbupdate-500]`
- **S180-W1a (08-07) kiểm-kê MÁY-ĐỌC-TÊN [`runs/2026-08-07-S180-adap-upgrade-pack-phased/sub-w1a-mayd-octen.md` 44.9KB]:** 14 đoạn máy (11 vỡ-CÂM · 3 ồn · **0 tự-thích-nghi**) · BÁC 2 (`detectors:1716`+`:1679` = CHÚ THÍCH không phải ; `applied-eval-nokey.ps1` = 0 file-IO, control-dương mfe-eval=14). **Luật rút ra: nhóm tên sạch CHÍNH XÁC tới độ sâu máy KIỂM, không sâu hơn 1 nấc** STOP-HARD KHÔNG cần (`detectors:1639` chỉ INFORM `session-<N>` sạch 12/12); cần **máy TỪ CHỐI**, *đọc-rồi-liệt* (`$unmapped:1109`) + *chấp-2-tên* (`-or :1660`) + *[skip] not found* đều để tên trôi. 🔴 `memory-selfimprove-audit.ps1:298` còn dùng `-Filter 'sub-*.md'` = ĐÚNG bug C11(b) đã @S159 `detectors:1102` **vá tệp A quên tệp B**. Bất biến ngầm 0-máy-kiểm: `agents/<role>.md`.BaseName `agent-memory/<role>/`. Miễn-trừ 5 vùng đóng băng nuốt **73,2% kho .md (1131/1545) + 90,7% lệch đã biết (225/248)** máy soi quét .md trả 0-hit nghĩa; đường thoát = quét **231 tên vật-bộ-máy, 0 miễn trừ**. `CLAUDE.md:151` khớp 64/159=40,3% đĩa, 0 máy chặn, sống nhiều tháng. Tag `[s180, sach-toi-do-sau-may-kiem, va-tep-A-quen-tep-B, mau-so-sau-mien-tru]`
- **S180 (08-07) ×Fable fit-map 4-thư upgrade-pack 7GĐ [`runs/2026-08-07-S180-adap-upgrade-pack-phased/sub-invest-fitmap-S180.md`]:** PHỦ-ĐỦ 7GĐ/12TC/9LỖI. Nghề: hash mesh CHỈ tin máy canonical `scripts/stamp_verify.py` (tự chế MISMATCH cả 4 = suýt phán tamper sai) · TÔI = vai 7/7 dính #53 + bị bắt LỚP-7 tự-giẫm (202K đọc/7KB đĩa) "đổ ruột TRƯỚC đo SAU" cứu; tỉ-lệ byte-đĩa/token-đọc = tín hiệu giám sát · xung đột duy nhất gói: SE exit-0-advisory (`governance-detectors.ps1:1963`) hub cổng-chặn-mã-lỗi VƯỢT-KHUNG · GĐ-5 gần xong nhất / GĐ-4 hổng nhất (0 sổ-yêu-cầu-anh, 0 chấm-điểm) · bác-một-phần lead: `pull_warn_days` trống = CỐ Ý owner khai (`check-email.md:48`) ghost-wire. Tag `[s180, stamp-verify-canonical-only, lop7-tu-giam, exit0-vs-gate]`
- **S175 (08-05) ×Fable cột DỰ ÁN ma-trận NS [seed on-behalf; `runs/2026-08-05-S175-duan-budget-column/`]:** BÁC 2 tiền-đề lead cột = CHỖ-CHỪA-SẴN (`e33481e` cùng-commit comment `PeWorkItemBudget.cs:19`, KHÔNG phải di-chứng Mig50) · `—` = 11 ô/app không phải 3; phả-hệ đo bằng commit-sinh-ra + comment cùng commit, đừng suy từ khoảng-thời-gian-gần. `Project.BudgetTotal` sống từ Mig-2 nhưng Dev 61/69 NULL = "cơ-chế đúng dữ-liệu không có" lần 7. Spawn TASK-RỖNG cứu bằng run.md trên đĩa. Tag `[s175, pha-he-commit, artifact-dia-cuu]`

View File

@ -1,5 +1,7 @@
# Reviewer Agent — Persistent Memory
- **[→ `archive/2026-08.md` §@S182 — verbatim 2 lượt, probe 7/7 + control-âm 0] S182 (08-08) YC-013 KHKK 8-panel — lượt SPEC `2C/5M`, lượt DIFF pre-commit `0C/3M`+6m:** 🟢 **3/3 finding 🔴 lượt-1 được đóng ĐÚNG BẢN CHẤT** (assembly-ranh → file `public static class` riêng · `IgnoreQueryFilters` **2 CHIỀU NGƯỢC NHAU trong cùng 1 diff**: DELETE bỏ filter vì FK còn thật ⟂ changelog GIỮ filter vì ghi vết phiếu-đã-xoá là rác · 8 module pin verified bằng grep Domain, loại +1 hit FK nội bộ) — dữ liệu hiếm, ghi để đừng phá. 🔴 **tooltip đặt trên `<input disabled>` ⇒ Chromium KHÔNG hiện** (label bao ngoài không mang `title`) — quyết-định-owner nằm trong tooltip mà tooltip không có cửa hiện = **máy dựng không ai gọi**; ô "hành-vi-LUÔN-BẬT" câm ⇒ đọc thành NGƯỢC NGHĨA. 🔴 **3 thành phần bất đồng HOA/thường** (regex CultureInvariant **case-SENSITIVE**`Code ==` theo collation SQL Server **CI** ⟂ FE `.toUpperCase()`) và **test MÙ THEO CẤU TRÚC vì SQLite = BINARY case-sensitive ⇒ tầng DB trong test có ngữ nghĩa NGƯỢC prod** — không assertion nào bịt được. 🔴 **guard mới ở write-path CHÍNH (Create) 0 test** — lỗ của SPEC, implementer đúng spec, **KHÔNG hạ thước theo spec**. **m:** assert `Contain("N5")∧Contain("N3")` **mù THỨ TỰ toán hạng** (3 call-site 2 tham số kề — phải đối chiếu TAY) · **BOM `ef bb bf`+CRLF lẫn vào commit code**`git add` từng file. **FLAG-A đo được:** `npx tsc --noEmit` trần biên dịch **0 file** (`files: []`+references) = xanh THEO CẤU TRÚC; `--listFiles|wc -l` 0 ⟂ 225/214 ⇒ **acceptance "lệnh X xanh" phải kèm phép chứng thước KHÔNG RỖNG**.
- **[→ `archive/2026-08.md` §@S180 — verbatim, probe 10/10 + control-âm 0] S180 (08-07) LANE A/6 GĐ-0+GĐ-1 (đĩa `runs/…-S180-adap-upgrade-pack-phased/sub-n3-lane-a-gd01.md` 57 KB):** 🔴 **"0 hit" ĐÚNG THEO LUẬT vẫn là LỖ THẬT** — `_index` thiếu **24/69** thư (lead giao "4"); `to=se` **23/23 sạch**`to=all-fit` 22 có/20 thiếu; luật `:7` trỏ `COMMS-LEDGER``find "*COMMS*"`=**0 trong SE** (control `*ledger*`=4) ⇒ **sổ mà luật trỏ tới KHÔNG TỒN TẠI**; S153 phán dương-giả = đúng cho câu-hỏi CŨ. ⇒ gặp 0-hit phải hỏi thêm **"sổ đó có tồn tại không"** · **phân-họ regex 160/159, lệch 1 vì `S57bis`** ⇒ luôn cộng lại đối-chiếu tổng · **`CLAUDE.md` quy-ước nhật-ký khớp ≤64/159 đĩa** · **"single source" tự-xưng ≠ chuẩn** (bảng-dịch; 3 phép thử tiên-đoán/phủ-sóng/hướng) · **số-trong-chú-thích trôi** (`hmw.js` "11 class" ⟂ đĩa **12**).
- **[→ `archive/2026-08.md` §@S179 — verbatim] S179 (08-07) C7 re-review sau vá — `PASS-WITH-8`, 3 MAJOR:** **2/3 MAJOR là lỗi TRONG CHÍNH BẢN VÁ vừa pass 12/12 acceptance** — vì acceptance đo **HÌNH-DẠNG chuỗi, không đo HÀNH-VI**. `cut -c4-` đẻ path RÁC (rename 1 dòng `R old -> new`) · ba-chấm→hai-chấm = **regression có điều-kiện** (thủ phạm thật = `git diff` mù untracked, lead gán sai tội) · mỏ-neo `:112` **tự-vô-hiệu ngay lúc viết** ⇒ neo TÊN SELECTOR không neo số dòng · census "8/9" thiếu 3 ý đóng im lặng.
@ -79,20 +81,16 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
- Hook-cap **>17.1KB** (24.4KB read-limit) → move recent → L2 `archive/<period>.md` (additive) + `_INDEX.md` pointer. Stale >3mo → remove.
- **Trình tự BẮT BUỘC (S102 recovery, cut-not-moved ×5 `f229b07`):** append archive TRƯỚC → probe moved-not-cut **bằng token lấy từ THÂN BÀI, không lấy từ digest** (digest là paraphrase ⇒ 0-hit GIẢ; @S168 suýt giữ 938 B vô ích) + control-âm → mới cắt.
- **3 bài đo:** số trong sổ phải là số **ĐO** không ước (`wc -c`, baseline thừa-kế cũng stale) · script sinh chữ **đẻ chữ hỏng câm** (byte-count không bắt, chỉ ĐỌC LẠI mới thấy) · hook đếm **KÝ TỰ** còn `wc -c` đếm **BYTE** — tiếng Việt đa-byte ⇒ 2 số lệch nhau là bình thường, đừng truy "ai sai".
- **Prev:** S180 (probe 10/10 + control-âm) · S168 (22.2→17.1) · S166-K5 (3/3) · S162 (2/2) · S145 · S140 · S134 · S128 · S102 · S116 · S113 · S109 · S80 · S71 · S70 · S40.
- ⚠️ **NỢ CÒN LẠI (đo, không ước):** curate S182 đưa **22.518 → 20.292 B** (2.226, đồng thời THÊM entry S182) — **vẫn trên trần mềm 17.1KB**, còn dưới trần đọc 24.4KB nên chưa truncate. Lượt sau cắt tiếp: 2 bullet dài nhất là **S182 (dòng 3)****digest gộp S167-K6… (dòng ~17)**, cả hai đã có verbatim trong `archive/`. 🔴 **2 bài THƯỚC phiên này:** probe archive bằng token lấy từ **DIGEST L1** cho **3/7 = 0-hit GIẢ** (archive dùng chữ khác) ⇒ luôn lấy token từ **THÂN BÀI** · **`grep -icF` trong MSYS trả CHUỖI RỖNG thay vì `0`** ⇒ đếm bằng `python -c "s.count(...)"`, đừng tin `$(grep -c)`.
- **Prev:** S182 (22.5→20.3, probe python 7/7 + control-âm 0) · S180 (probe 10/10 + control-âm) · S168 (22.2→17.1) · S166-K5 (3/3) · S162 (2/2) · S145 · S140 · S134 · S128 · S102 · S116 · S113 · S109 · S80 · S71 · S70 · S40.
- **[→ `archive/2026-08.md` §@S152-R1 — verbatim] S152 R1 ranh NEVER:** verdict-only; NEVER áp cho ĐÍCH (code-tree·commit·push — chưa từng vỡ); Write/Edit runtime hợp lệ cho sub-file run-folder + diary (AS-10); containment thật = git-diff lead.
- **[→ archive/2026-07.md @S165-curate] S159 đợt-1 (07-29) diff pipeline — 7 FLAG:** rút TOÀN BỘ chuỗi hiển-thị MỚI từ diff rồi soi từng chuỗi = phép đo 100%-tiếng-Việt · 2 queryKey cùng resource = 2 ảnh-chụp khác thời-điểm · a11y nút TRÙNG tên đọc-được · bác-claim cũng phải có chứng.
- **[→ `archive/2026-08.md` §@S176 — verbatim] S176 (08-06) SETUP-REVIEW đội STYLE 3 vai — `SỬA-6`:** chấm **THIẾT-KẾ một đội sub-agent**, không phải diff code (trục mới của vai). 🔴 **thước dispatch MÙ WORKING-TREE** (`git diff origin/main...HEAD` ba-chấm trả RỖNG ⇒ đội gate pre-commit **chết câm** đúng use-case chính) · **đo TREE ≠ đo HISTORY** (thước tree tự lành ca revert ⇒ lo-nghi "revert phá thước" = lo hão) · **miễn-trừ chỉ-con-trỏ = dấu cao-su** ⇒ đòi TRÍCH NGUYÊN VĂN ≤1 dòng, khép vòng bằng owner-visibility · **thách-CLEAN bẻ được nếu không bắt neo `file:line` cả 2 bờ** · **3/5 lo wiring của lead là LO HÃO** (script enumerate ĐỘNG — đừng giả-định hardcode, mở ra đo) · **chép-luật-vào-persona vi phạm chính B1 viết trong CÙNG file** ⇒ viết luật cấm-chép xong phải grep chính file mình. *(🔄 @S180: khoản "3 vai thiếu dir agent-memory" ĐÃ ĐÓNG — 26⟂26 khớp.)*
- **[→ `archive/2026-08.md` §@S176 — verbatim] S176 (08-06) SETUP-REVIEW đội STYLE 3 vai — `SỬA-6`:** chấm **THIẾT-KẾ một đội sub-agent**, không phải diff code. 🔴 **thước dispatch MÙ WORKING-TREE** (`git diff origin/main...HEAD` ba-chấm trả RỖNG ⇒ gate pre-commit chết câm đúng use-case chính) · **đo TREE ≠ đo HISTORY** · **miễn-trừ chỉ-con-trỏ = dấu cao-su** ⇒ đòi TRÍCH NGUYÊN VĂN ≤1 dòng · **thách-CLEAN bẻ được nếu không bắt neo `file:line` cả 2 bờ** · **3/5 lo wiring của lead là LO HÃO** (script enumerate ĐỘNG — mở ra đo) · **chép-luật-vào-persona vi phạm chính B1 viết trong CÙNG file**.
- **[→ `archive/2026-08.md` §@S180-LANE-B — verbatim, probe 3/3 + control-âm 0] S180 (08-07) N3 LANE-B GĐ-2 (digest):** NGHI-THỨC ≠ MÁY đọc CỘT KIỂU error-ledger (AS-17 `procedural`, AS-19 tự khai chưa-cơ-khí-hoá) · guard-byte MÙ skeleton ⇒ vị-từ nhãn-treo-heading (use⟂mention, 0 dương-giả) · sổ RETIRED-có-khai vẫn làm điểm-danh-2-nguồn MẤT VẾ PHẢI (73/88); nguồn-2 = taskList snapshot run.md, KHÔNG phải `run-chua-gom` (đọc đĩa = nguồn-1) · secrets_sweep chỉ rglob sessions/ + runs/ excluded ⇒ vật chứng đắt nhất ngoài 2 lưới cùng lúc · tỉ lệ không mẫu số = vô nghĩa (42%/72%/25% cùng 1 sự thật) · lead-stale-auditor 0-hit runs/ (control FLAG=4).
- **[→ `archive/2026-08.md` §@S180-LANE-B — verbatim, probe 3/3 + control-âm 0] S180 (08-07) N3 LANE-B GĐ-2 (digest):** NGHI-THỨC ≠ MÁY (đọc CỘT KIỂU error-ledger) · guard-byte MÙ skeleton ⇒ vị-từ nhãn-treo-heading (use⟂mention) · sổ RETIRED-có-khai vẫn làm điểm-danh-2-nguồn MẤT VẾ PHẢI · **secrets_sweep bỏ sót đúng thư-mục chứa vật chứng đắt nhất** (2 lưới hụt cùng lúc) · **tỉ lệ không mẫu số = vô nghĩa** (3 số cùng 1 sự thật) · auditor 0-hit vì không quét `runs/` (control FLAG=4).
- **[→ `archive/2026-08.md` §@S180-ba-khối — verbatim, probe 6/6 + control-âm 0] S180 (08-07) N4 wave-plan · W0 lane-a fitmap · W0 lane-b `_index` (digest gộp):** **ô-đòi-MÃ không nhận CÂU-VĂN** (wave không trỏ mã TC = thoát ràng buộc nghiệm thu) · tách trục **CHỮA ⟂ NGHIỆM-THU** (thiếu THƯỚC ≠ thiếu THUỐC) · 2 hệ đánh số trong 1 artifact phải khác TIỀN-TỐ · **ghi-đĩa-4-nhát qua Edit-append anchor-đuôi** (13 vai dính #53, 4 nhát land sạch).
- 🔴 **acceptance ĐO HÌNH-DẠNG-DÒNG, KHÔNG ĐO GIÁ-TRỊ-Ô**`grep -c '^| GĐ-[0-6]'` vẫn ra 7 dù ô điền sai từ-vựng (từ-vựng Phần C nhét vào ô Phần A ⇒ đẻ giá-trị-thứ-tư, đúng lỗi file sinh ra để đóng, nghiệm thu vẫn xanh) ⇒ thêm phép đo GIÁ-TRỊ · **2 tệp anh em cùng bảng 7 dòng = dương-giả thật** ⇒ chạy chéo 2 chiều + control-dương · **`grep -c` exit 1 khi 0-hit CẮT ĐỨT chuỗi `&&`** · **1 phép đo 3 nguồn 3 số** ⇒ chép PHẦN LIỆT KÊ, đừng chép số tổng.
- 🔴 **W0-b, 3 bài đắt nhất:** (a) **lệnh lead sai theo chiều PHÁ chính mục-đích lệnh***"backfill `se` cho 22 dòng cũ"* nhưng sổ có **45 dòng** (23 `se` + 22 `all`) ⇒ gán hàng loạt dán nhãn sai lên đúng 22 dòng fan-out mà P6 sinh ra để hợp-thức-hoá; gốc = **hai số 22 khác nghĩa va nhau trong CÙNG bản giao việc** ⇒ trị bằng + nghĩa ngược = con số thôi làm chứng, dẫn giá-trị **từng dòng từ đĩa**. (b) **`grep -c <substring>` có TRẦN CỨNG** — lead đọc `=4` thành *"mới bù 4"* trong khi **17 dòng đã land****biết tên bẫy KHÔNG đủ, phải khai mẫu-số NGAY CẠNH con số**. (c) **glob `*/` bỏ trọn thư-mục GỐC** ⇒ mù đúng nơi thư *chưa xử lý* hạ cánh; 69/24 khớp chỉ vì gốc chỉ có `README.md` = trùng hợp, không phải tương đương.
- 🟢 **Khuôn tái dùng:** thêm cột vào sổ bằng-chứng thì đặt **CUỐI hàng***"không sửa dòng cũ"* thành **phép kiểm chạy được** (dòng cũ = **tiền-tố nghiêm ngặt** của dòng mới; 45/45, khối kia byte-identical). 🔸 `status` **định-nghĩa-bằng-thư-mục** ⇒ gộp 2 kênh làm cột chỉ đo đúng NỬA số dòng (fan-out `/adap-apply` không đụng thư mục ⇒ `processed` = *đã move*, KHÔNG phải *đã áp*). 🔸 **mốc nước nói về VẮNG-KHỎI-HỘP**, không miễn cho thư ĐÃ NẰM TRONG hộp mà thiếu dòng — **lead nhận lập luận, phán bù nốt 7** ⇒ đợt-2: 24/24 dòng bù, `stamp_verify.py` **24/24 exit 0 · 0 tamper**, vắng-mặt **24→0**, 5/5 bất-biến đo LẠI vẫn đạt. `status: DRAFT` ⟂ đòi-dấu-đã-duyệt = **lệch trạng-thái, KHÔNG tamper**. 🔸 2 số 69 (đĩa ⟂ dòng) **KHÔNG phải phép đo thứ hai** — là hệ quả của (0 vắng ∧ 0 mồ-côi ∧ 0 trùng).
- **2 thước hỏng:** **heredoc `<<'EOF'` gãy** với tiếng Việt + backtick (lead dính y hệt) ⇒ tệp dài dùng **Write/Edit** · **probe `grep -F` sai HOA/thường ⇒ 0-hit GIẢ** (`ô-``Ô-`) ⇒ 0-hit phải thử lại **đúng ca chữ TRƯỚC** khi kết luận mất nội dung.
- Tag `[s180, w0-lane-b, colliding-22, grep-substring-ceiling, glob-star-slash-blind, prefix-preserving-column, status-defined-by-folder, shape-vs-value-acceptance, heredoc-broken, probe-case-trap]`
- **[→ `archive/2026-08.md` §@S180-ba-khối — verbatim, probe 6/6 + control-âm 0] S180 (08-07) N4 wave-plan · W0 lane-a/b (digest gộp):** **ô-đòi-MÃ không nhận CÂU-VĂN** · tách trục **CHỮA ⟂ NGHIỆM-THU** · 2 hệ đánh số trong 1 artifact phải khác TIỀN-TỐ · 🔴 **acceptance ĐO HÌNH-DẠNG-DÒNG chứ không ĐO GIÁ-TRỊ-Ô** (`grep -c '^| GĐ-…'` vẫn xanh dù ô điền sai từ-vựng) · **2 tệp anh em cùng hình-dạng = dương-giả thật** ⇒ chạy chéo 2 chiều + control-dương · **`grep -c` exit 1 khi 0-hit CẮT ĐỨT chuỗi `&&`** · **lệnh lead có thể sai theo chiều PHÁ chính mục-đích lệnh** (2 số cùng trị khác nghĩa va nhau trong CÙNG bản giao việc) ⇒ dẫn giá-trị **từng dòng từ đĩa** · **`grep -c <substring>` có TRẦN CỨNG** ⇒ khai mẫu-số NGAY CẠNH con số · **glob `*/` bỏ trọn thư-mục GỐC** · 🟢 thêm cột thì đặt **CUỐI hàng** ⇒ "không sửa dòng cũ" thành phép kiểm chạy được (tiền-tố nghiêm ngặt) · `status` **định-nghĩa-bằng-thư-mục** đo đúng NỬA số dòng · **2 số bằng nhau từ 2 nguồn KHÔNG phải phép đo thứ hai** nếu số sau là hệ-quả số trước · thước hỏng: **heredoc `<<'EOF'` gãy** với tiếng Việt+backtick ⇒ dùng Write/Edit · **probe `grep -F` sai HOA/thường ⇒ 0-hit GIẢ**.

View File

@ -93,3 +93,40 @@
- `stamp_verify.py` **17/17 OK, exit 0, 0 tamper**; 4 thư gói khai `content_sha256` **8-hex** (script so tiền-tố). 4 thư mang `status: DRAFT`+`reviewer_gate: PENDING``thu-chinh` đòi dấu đã-duyệt ⇒ **lệch trạng-thái, KHÔNG tamper**.
- Thước hỏng phiên này: **heredoc `<<'EOF'` gãy với nội dung tiếng Việt + backtick** (lead dính y hệt) ⇒ ghi tệp dài dùng **Write/Edit**, đừng `cat >>`.
- Tag `[s180, w0-lane-b, broadcasts-index, p6-additive, colliding-22, grep-substring-ceiling, glob-star-slash-blind, prefix-preserving-column, status-defined-by-folder, watermark-scope, heredoc-broken]`
---
## @S182 — YC-013 KHKK 8 panel nhóm mirror DuyetNcc (2 lượt review: SPEC rồi DIFF)
**Đĩa:** `runs/2026-08-08-S182-khkk-awf-mirror-ncc/sub-review-1-fable.md` (lượt SPEC) · `.../sub-reviewer-precommit.md` (lượt DIFF).
### Lượt 1 — review SPEC (`PASS_WITH_FIXES 2C/5M`)
Chấm SPEC = đo lại claim của spec trên code. Đa số HELD, nhưng:
- 🔴 **spec-unbuildable vì ASSEMBLY-RANH.** Spec bảo "extract helper rồi gọi 3 site" — chết ở site-3 (`SubmitAsync`) vì site đó nằm ở **Infrastructure** còn helper định để `internal` trong **Application**, và `InternalsVisibleTo` chỉ mở cho Tests. ⇒ **Luật rút ra:** mọi đề-xuất extract-helper đa-assembly phải kiểm ACCESSIBILITY trước khi phán "vá rẻ".
- 🔴 **usage-count trên bảng có `HasQueryFilter` mà thiếu `IgnoreQueryFilters()`** = đếm sót phiếu xoá-mềm trong khi FK vẫn chặn ⇒ test hẹp xanh, ca phổ biến vẫn 500. Claim "hết 500" MẠNH HƠN bản vá.
- 🔴 **"FK chặn nên data không mất" phải grep FK THẬT.** 8 cột pin ⟂ chỉ 4 `HasForeignKey` hit ⇒ 5 pin loose-Guid hard-delete êm + dangling. Suy từ 3 module đã biết là suy sai.
- **2 invest cùng dùng chữ "per-Code" nhưng KHÔNG cùng công-thức** (`typeEnum==10` ⟂ regex-họ-mã) — khác hành vi ở code ngoài-họ. Spec khai "hội tụ" ⇒ che mất chỗ lệch. Phải khoá công thức + test ca phân-kỳ.
- **Cờ ignored-nhưng-hành-vi-luôn-bật**: gray-out kiểu "chưa áp dụng" = nói dối theo CHIỀU NGƯỢC.
- Baseline "645" trong spec ⟂ STATUS 644 — đo tươi, đừng chép.
### Lượt 2 — review DIFF pre-commit (`PASS_WITH_FIXES 0C/3M` + 6 minor)
**Đo độc lập (không nhận số của lane):** `dotnet test` = 45 + 614 = **659 P / 0 F** (baseline 645 = 45+600 ⇒ +14 đúng ở Infrastructure) · `npm run build` ×2 exit 0 · tsc `-p tsconfig.app.json` 0-err (225 / 214 file).
**3/3 finding 🔴 của lượt 1 được đóng ĐÚNG BẢN CHẤT, không đóng bằng chữ** — đây là dữ liệu hiếm: (a) assembly-ranh → file `public static class` riêng ở Application, 3 call-site, đối số đúng thứ tự cả 3; (b) `IgnoreQueryFilters` đúng 3 bảng có filter, và **phân biệt được 2 CHIỀU** (DELETE cần bỏ filter vì FK còn thật; changelog CỐ Ý giữ filter vì ghi vết cho phiếu đã xoá là rác) — cùng một hàm-gọi, hai ngữ nghĩa ngược nhau, có khai tại chỗ; (c) 8 module pin verified bằng `grep -E "public (Guid|Guid\?) ApprovalWorkflowId"` trên Domain (đúng 8, +1 hit FK nội bộ của Step phải loại), 5 module loose-0-FK được khai là "rào DUY NHẤT" và T12 chứng minh dangling THẬT trong DB.
**MAJOR-1 — tooltip gắn trên `<input disabled>` ⇒ Chrome/Edge không hiện.** 7 `title=` đặt trên chính checkbox bị `disabled`; Chromium chặn sự kiện chuột trên form-control disabled nên native tooltip thường không bật (Firefox thì có), và `<label>` bao ngoài KHÔNG mang `title` nên rê vào chữ cũng không ra. Nặng nhất ở ô #4: câu riêng của nó là thông tin DUY NHẤT phân biệt "cờ chưa phân nhánh" với 6 ô "chưa áp dụng" ⇒ tooltip câm = ô #4 đọc thành NGƯỢC NGHĨA, đúng class lỗi mà ERRATA vừa sửa xong ở tầng `checked`. **Luật rút ra: quyết-định-của-owner nằm trong tooltip thì phải kiểm tooltip có CỬA HIỆN không — máy dựng mà không ai gọi.** Tiền lệ cùng bệnh đã tồn tại từ đợt trước ⇒ vá phải vá cùng LỚP.
**MAJOR-2 — 3 thành phần bất đồng HOA/thường, và test MÙ THEO CẤU TRÚC.** Cùng câu hỏi "code này có phải nhóm N{n}", ba luật khác nhau: regex `^KHKK-N([1-8])$` **CultureInvariant, KHÔNG IgnoreCase** (case-sensitive) ⟂ `w.Code == request.Code` chạy theo **collation SQL Server `_CI_AS`** (case-INsensitive) ⟂ FE `.toUpperCase()` (case-insensitive). Hệ quả: mã `khkk-n1` gõ tay ở panel gộp sẽ **hạ IsActive bản thật**, **vẫn được FE auto-pin**, mà **rào nhóm + rào version-cuối TẮT im lặng**. 🔴 **Test không thể thấy vì fixture chạy SQLite (BINARY = case-SENSITIVE) ⇒ tầng DB trong test có ngữ nghĩa NGƯỢC với prod.** Đây không phải test ẩu — mù theo cấu trúc, không assertion nào bịt được. **Luật rút ra: mọi bất-biến dựa trên so-sánh CHUỖI ở tầng DB đều không được test-SQLite bảo chứng; chuẩn hoá ở ĐƯỜNG GHI (`Trim().ToUpperInvariant()`) hoặc khai phụ-thuộc-collation ngay tại chỗ.**
**MAJOR-3 — guard mới ở write-path CHÍNH có 0 test.** Rào D6 cắm 2 site (Create + UpdateDraft) nhưng 4 chân của ca test đều đi qua UpdateDraft ⇒ nhánh Create (đường mọi phiếu prod sinh ra) không có răng nào. Gỡ nó đi thì 659 test vẫn xanh. Đây là **lỗ của SPEC** (spec liệt 14 ca và thiếu ca này), implementer làm đúng spec — nhưng **không hạ thước theo spec**.
**MINOR đáng nhớ:** (1) `Contain("N5").And.Contain("N3")` **mù THỨ TỰ toán hạng** — hàm mới nhận 2 tham số kề nhau ở 3 call-site, đảo là vẫn xanh (tôi phải đối chiếu TAY mới chốt được cả 3 đúng); pin nguyên câu có thứ tự mới giữ được bất-biến. (2) File governance bị thêm **BOM `ef bb bf` + CRLF** lẫn vào commit code (HEAD `7b 0a` → worktree `ef bb bf 7b 0d 0a`) ⇒ `git add` từng file, đừng `-A`. (3) rào version-cuối đếm anh-em **KHÔNG lọc `ApplicableType`**. (4) "state THẬT" của ô disabled chỉ đúng cho đường CLONE/EDIT — đường TẠO MỚI thì 6 ô khoá bị đóng băng ở DEFAULT của FE, admin không sửa được.
**FLAG-A của FE lane — đo được và ĐÚNG:** `npx tsc --noEmit` trần ở repo này **biên dịch 0 file** (`tsconfig.json` = `{"files": [], "references": [...]}`) ⇒ luôn xanh **theo cấu trúc**. Chứng bằng `--listFiles | wc -l`: **0** dòng cho dạng trần ⟂ **225 / 214** dòng cho `-p tsconfig.app.json`. 🔸 Bổ sung: `npm run build` = `tsc -b && vite build`, mà `tsc -b` CÓ đi theo project references ⇒ acceptance `npm run build` là thước THẬT. **Luật rút ra: acceptance kiểu "lệnh X xanh" phải kèm MỘT phép đo chứng thước không rỗng (`--listFiles`, `-c`, số file/ca) — exit 0 của một lệnh không đọc file nào trông y hệt exit 0 của một lệnh đọc 225 file.**
**Khuôn chống-nhiễu đáng tái dùng (từ chính bộ test này):** ca Submit có SẴN 3 cửa 409 khác ⇒ bắt được `ConflictException` là CHƯA nói lên gì; bộ test xử bằng cách **kéo dữ liệu fixture đi theo biến đang đo** (hạng-mục lấy `ApprovalGroup` của phiếu) để 2 cửa nhiễu im, rồi thả **một phiếu chứng-nhân khác đúng một biến** trong CÙNG lượt.
- Tag `[s182, yc-013, khkk, assembly-boundary-extract, ignorequeryfilters-two-directions, tooltip-on-disabled-input, collation-vs-sqlite-blind, guard-untested-write-path, argument-order-blind-assert, vacuous-tsc-noemit, bom-in-code-commit]`

View File

@ -0,0 +1,57 @@
# _context-s-13 — SESSION-MODEL narrative durable
<!-- SCAFFOLD-META: sID=13 | ts-moc=2026-08-07T23:37:36+07:00 | nguon-ts=git log -1 --format=%cI @ HEAD 936d1aed | generator=scripts/session-scaffold.ps1 -->
> **sID (LOGIC-session) = 13.** 1 phiên-logic (bootstrap → work → sweep) trải nhiều window vật-lý nối bằng `/pause`+`/tiep`. Window = 0-ID governance; **sID = đơn-vị LOGIC**. Folder pin: `.claude/sessions/session-13/`.
> ts-mốc khởi-tạo = **2026-08-07T23:37:36+07:00** (nguồn: `git log -1 --format=%cI @ HEAD 936d1aed`) — mốc git-committer, KHÔNG phải wall-clock tuỳ-tiện.
>
> 🔴 **CẤM rewrite / tự-tóm block FLOW** (chống self-summary lossy). FLOW = append-only @`/pause`-only; vượt cap `session_ctx_kb` → distill SECTION CŨ thành pointer/gist, TUYỆT ĐỐI KHÔNG sửa entry verbatim đã ghi.
---
## (a) STOCK-map — reference-not-copy
> Bảng pointer tới nguồn durable. **KHÔNG copy nội-dung** (chống drift). Điền @bootstrap; row session-specific append dưới các row stable.
| Tên | Path | 1-dòng |
|---|---|---|
| WAL | `.claude/WAL.md` | mạch-việc-dở máy-state (H-22) — nối bằng `/tiep` |
| AI-context | `CLAUDE.md` | AI agent context SOLUTION_ERP (scope + quick-rules) |
| Trạng-thái | `docs/STATUS.md` | canonical mọi con-số (mig · bảng · test · roster) |
| Bàn-giao | `docs/HANDOFF.md` | brief 5 phút + bảng số chờ-anh + carry |
| Sổ đếm H24 | `.claude/governance/.session-counter.json` | counter + nhịp lead-self-audit |
| Thư cross-project | `broadcasts/_index.md` | INBOUND directed (`outbox/se`) — fan-out `outbox/all` KHÔNG vào đây |
| _(session-specific)_ | _(path)_ | _(1-dòng — append @bootstrap phiên này)_ |
---
## (b) FLOW — append-only @pause-only
> 🔴 **CẤM rewrite FLOW.** Mỗi `/pause` APPEND đúng 1 entry theo SCHEMA dưới (đặt DƯỚI dòng `FLOW-START`). Entry cũ = immutable. FLOW = SOURCE durable của "đang-đến-đâu"; bảng-đầu-việc = VIEW derive từ đây (single-source).
<!-- ENTRY-SCHEMA (guidance, KHONG phai entry that — /pause command dien; parser real-entry match `^### PAUSE-\d+`, placeholder `<k>` khong false-match):
### PAUSE-<k> <ts ISO-8601>
> anh: <tin-nhan anh VERBATIM — copy nguyen-van, KHONG paraphrase>
**(1) quyet-dinh da CHOT** / **(2) delta con SONG** / **(3) con-tro**
```json
{ "machine_block": "python scripts/session_ctx.py machine-block --session <N> --json" }
```
-->
<!-- FLOW-START — entries append bên dưới dòng này, mới nhất ở CUỐI -->
_(chưa có PAUSE — entry đầu append @`/pause` đầu tiên của phiên)_
---
## (c) STOCK-touched — máy-derive
> Bảng file-đã-đụng trong phiên, **máy-derive** từ `git diff --name-only <anchor>..HEAD` @pause/snapshot. KHÔNG điền tay.
| File | Δ | Ghi-chú |
|---|---|---|
| _(máy-derive @pause/snapshot)_ | | |

View File

@ -0,0 +1,116 @@
# _mind-s-13 — SESSION-MODEL lớp MỀM (trí-nhớ suy-nghĩ 4 tầng)
<!-- SCAFFOLD-META: sID=13 | ts-moc=2026-08-07T23:37:36+07:00 | nguon-ts=git log -1 --format=%cI @ HEAD 936d1aed | render=lead Write @session-start Phase 3.5 (session-scaffold.ps1 KHONG render tep nay) -->
<!-- MIND-RULES-START -->
> **sID (LOGIC-session) = 13.** Tệp này giữ 4 thứ KHÔNG nằm ở WAL / HANDOFF / `_context`: **(A)** gói-turn · **(B)** hướng-tiếp · **(C)** kế-hoạch delta-suy-nghĩ · **(D)** đang-thảo-luận (+ **(E)** dòng-sống spawn). Nhà: `.claude/sessions/session-13/_mind-s-13.md`.
> ts-mốc khởi-tạo = **2026-08-07T23:37:36+07:00** (nguồn: `git log -1 --format=%cI @ HEAD 936d1aed`) — mốc git-committer, KHÔNG phải wall-clock tuỳ-tiện.
>
> **GHI = lead (single-writer)** @4 nhịp: block-0 cuối bootstrap · refresh @`/pause` · đọc + đối-chiếu @`/tiep` · mục E gom MỌI spawn/engine-run. **KIỂM = máy + vai** (tách khỏi ghi): `python scripts/session_ctx.py mind-check --session 13` mỗi cửa (~$0) + vai `ctx-audit` @2 bookend (INFORM-only, quyền phán TRƯỢT).
> **Trần tệp** = khoá `mind_ctx_kb` trong `.claude/agent-memory/memory-budget.json` — **B1: trỏ khoá, CẤM chép số vào tệp này.**
### 🔴 CHIỀU CHÈN — NGƯỢC với `_context` (đọc trước khi chèn block đầu tiên)
- **`_mind` = MỚI-NHẤT-Ở-TRÊN.** Block mới chèn NGAY DƯỚI **dòng marker điểm-chèn** (dòng comment nằm ngay trên block top, dưới khối luật này).
- **3 dòng comment marker của tệp** (mở khối luật · đóng khối luật · điểm-chèn) xuất hiện **ĐÚNG 1 LẦN** mỗi cái. Vì vậy phần chữ ở dưới CHỈ gọi chúng bằng **mô-tả**, KHÔNG viết lại tên marker — máy/người tìm-chuỗi sẽ không bắt nhầm anchor rồi chèn vào giữa khối luật. Máy khớp anchor bằng **token** trên dòng bắt đầu `<!--` (KHÔNG kèm `-->`, vì marker có chú-thích viết cùng dòng — mirror `FLOW-START` của `session-context-template.md`).
- **`_context` FLOW = MỚI-NHẤT-Ở-CUỐI** (`.claude/templates/session-context-template.md`, dòng `FLOW-START`). **HAI TỆP NGƯỢC CHIỀU NHAU** — CẤM "adopt verbatim" khuôn bên kia sang đây. Lý-do: `_mind` đọc để **nạp lại trạng-thái** ⇒ thứ mới nhất phải ở màn hình đầu; `_context`**biên-niên-sử durable** ⇒ đọc xuôi thời-gian.
- **Số hiệu = ĐẾM số block hiện có TRƯỚC KHI chèn** (block-0 chiếm số 0 ⇒ đang có n block thì block mới là `MIND-n`). Máy chỉ đòi *số duy-nhất* + *block top ≥ mọi số dưới* — KHÔNG neo công-thức đếm, nhà đổi công-thức vẫn kiểm được.
- **Block cũ BẤT BIẾN** (append-only, không rewrite / không tự-tóm). **Ngoại lệ DUY NHẤT**: nén khi vượt trần — nén block CŨ NHẤT **trừ block-0 và block top**, verbatim vẫn còn trong git; **<3 block TỪ-CHỐI nén**, giương cờ vượt trần (nợ hiển thị, không xoá chứng-nhân).
- **Việc ĐÃ-CHỐT chỉ con-trỏ** (`.claude/WAL.md` · `docs/HANDOFF.md` · `.claude/workflows/runs/<id>/…`) **CẤM chép nội-dung** (2-sự-thật + phình trần).
- **Mục trống ghi đúng chuỗi `(trống — khai)`** trên 1 dòng riêng, KHÔNG thêm chữ, KHÔNG bọc dấu nhấn (máy so chuỗi). CẤM bỏ heading vắng-mặt trông giống ổn.
- **5 heading trong mỗi block = `### A.` `### E.`** (thứ tự AE cố định) đây hợp-đồng hình-dạng cho máy `mind-check` vai `ctx-audit`.
- **Mục D: mỗi ý 1 dòng + BẮT BUỘC nhãn**, viết **đúng chuỗi CÓ DẤU**: `{mới-nêu | đang-cãi | gần-chốt | treo-chờ-anh}`. Máy so chuỗi viết không dấu = **trượt-cấu-trúc**. (Phần chữ hướng-dẫn trong khối luật này theo khuôn nhà thể không dấu, nhưng **token máy đọc thì giữ dấu** đừng chép ngược.)
### 3 RÀO NỘI-DUNG (máy `mind-check` phép 1-3 — mỗi rào kèm LÝ-DO ĐO ĐƯỢC)
1. **Rào-1 — CẤM verbatim lời anh.** Dạng bị bắt = dòng blockquote mở đầu bằng `anh` + hai chấm, ví-dụ:
> anh: <lời anh nguyên-văn — dòng NÀY hợp-lệ vì nằm TRONG khối luật, xem mục enclosure>
*Lý-do đo được:* verbatim lời anh **nguồn-duy-nhất** = `_context` FLOW (`### PAUSE-<k>` dòng quote). `_mind` lớp mềm bị nén/diễn-giải mỗi cửa chép verbatim vào đây tạo bản-thứ-2, khi 2 bản lệch thì bản mềm (đọc trước, giàu ngữ-cảnh hơn) sẽ thắng bản durable. Trong `_mind` chỉ được TRỎ: `xem PAUSE-<k>`.
2. **Rào-2 — CẤM khoá `ts:` ở ĐẦU DÒNG** (mọi biến thể: thụt space/tab, hoa/thường, khoảng trắng trước dấu hai chấm). Mốc thời-gian sống **trong dòng heading, sau em-dash**: `## MIND-<k> — <ISO-8601> @ <HEAD>`.
*Lý-do đo được:* marker nhà (`_pause-*` · `_tiep-*` · `_snapshot-*`) mang khoá đó đầu dòng **by-design**; reader-mốc TƯƠNG-LAI (gap C10-LATENESS của `scripts/governance-detectors.ps1` C10 tự khai "no reliable source" cho ts-compare) sẽ quét cả `session-<N>/_*` **không lọc theo tên tệp** một dòng khoá trong `_mind` đủ để lấy sai "mốc gần nhất". Regex canonical = `_TS_KEY_RE` trong `scripts/session_ctx.py`; **CẤM cài bản thứ 2** bất kỳ máy nào (2-sự-thật chính thứ rào này chặn).
3. **Rào-3 — CẤM secret, KỂ CẢ dưới dạng ví-dụ.** Tập tiền-tố cấm = `SECRET_PATTERNS` trong `scripts/session_ctx.py` **trỏ nguồn, KHÔNG chép mẫu vào tệp này**. Ngoại lệ DUY NHẤT: nhãn đã che `[đã che <8-hex>]`. Bắt buộc quét NHÁP **trước** khi chèn: `mind-check --session 13 --draft <tệp-nháp-scratchpad>`.
*Lý-do đo được, 2 tầng:* (i) block BẤT BIẾN secret lọt **vĩnh viễn** (còn trong git xoá sau); (ii) **enclosure KHÔNG đối-xứng giữa 2 lưới**: `mind-check` chỉ soi DƯỚI marker đóng khối luật, còn `secrets-sweep` (cùng `session_ctx.py`, chạy auto-snapshot mỗi `/pause`) **rglob TRỌN `session-<N>/` và không biết khối luật này** một mẫu secret viết làm ví-dụ ngay tại đây sẽ **chặn commit ở MỌI cửa pause**. thế rào-2 rào-3 trên **cố ý KHÔNG có ví-dụ literal**; chỉ rào-1 , rào-1 chỉ `mind-check` đọc enclosure-aware.
### Enclosure use ⟂ mention
Máy chỉ quét phần **DƯỚI** marker đóng khối luật (dòng comment cuối cùng của phần luật này). Khối luật được phép chứa chuỗi bị cấm để **giải thích** (citation-trap: doc định-nghĩa anti-pattern tất-yếu chứa chính mẫu đó). Hệ quả 2 chiều: **đừng** dịch ví-dụ xuống dưới marker đóng, **đừng** viết nội-dung thật lên trên .
<!-- BLOCK-SCHEMA (guidance, KHONG phai block that /pause dien; parser real-block match `^## MIND-\d+`, placeholder `<k>` khong false-match).
Prose huong-dan duoi day khong dau theo khuon nha; TOKEN may so chuoi thi GIU DAU: nhan muc D + chuoi muc trong `(trống — khai)`.
## MIND-<k> — <ISO-8601> @ <HEAD ngan> (window <w>)
### A. Goi-turn
<1-3 gach dau dong: turn vua roi lam gi, ket qua gi GOI lai, khong ke lai>
### B. Huong-tiep + nhanh-da-loai
<di dau tiep VA nhanh nao da LOAI + vi sao loai (chong dao lai vong cu)>
### C. Ke-hoach (delta suy-nghi)
<phan SUY-NGHI doi so lan truoc CAM chep checklist/todo (cho o WAL/HANDOFF)>
### D. Dang-thao-luan
<moi y 1 dong + nhan CO DAU: {mới-nêu | đang-cãi | gần-chốt | treo-chờ-anh}>
### E. Dong-song spawn/engine-run
<1 dong / spawn hoac engine-run: <vai><verdict><con-tro path (run-folder / sub-file / commit)>>
<muc trong dung chuoi: (trống khai)>
-->
<!-- MIND-RULES-END -->
<!-- diem-chen: block MOI chen NGAY DUOI dong marker duoi day (moi-nhat-o-TREN -- NGUOC chieu _context FLOW).
Marker duoi day phai giu NGUYEN-VAN, KHONG viet chu-thich cung dong: may khop chuoi DAY-DU
(`_MIND_TOP_MARKER` trong scripts/session_ctx.py) -- khac khuon `FLOW-START` cua _context (khong may nao doc). -->
<!-- MIND-TOP -->
## MIND-0 — 2026-08-07T23:37:36+07:00 @ 936d1aed (window 1)
### A. Gói-turn
- Bootstrap `/session-start` trọn: Sàn-3 5/5 im · `session-13` mở · counter 55→56 · test 645 PASS · **spawn-probe 5/5 ALIVE ⇒ slot (73) đóng bằng vật, không bằng static-check**.
- Bookend @open V1+V2 đủ 6 vai. Verdict → xem `.claude/workflows/runs/2026-08-07-S182-bookend-open/bookend-open-synthesis.md`.
- Anh hỏi giữa chừng *"ring-3 và ring-4?"* — đo đĩa ra: ring3 **cố ý không tồn tại** (V3 dùng `harness-audit`), ring4 **NO-OP đúng luật**, **ring5 là GAP thật**.
### B. Hướng-tiếp + nhánh-đã-loại
**Hướng:** chờ anh chốt **slot (67) · VK-5 · deep-sweep · (72) · `ring5-audit` slot (41)** *(🔴 viết thẳng tên slot thay vì đếm "4 câu" — `ctx-audit` FLAG-5: cụm "4 câu số" khớp nhầm vào `HANDOFF:6`, nơi 4 câu **đã trả lời + thi hành @S181** mà bảng chưa lật ⇒ cửa-sổ sau sẽ hỏi lại anh 4 câu đã chết)*; rồi vá lớp tự-quy-chiếu (`session-start.md:161/:162` + `HANDOFF:58` + `ring2-audit/MEMORY.md:28`) vì cả 3 đang **lây vào brief của chính lead**.
**Nhánh đã LOẠI:**
- (a) **Lật slot 71 theo đề-xuất H2** — LOẠI. `ring1` chứng `HANDOFF:60` slot đã gạch ✅ ĐÓNG @S179, số nằm trong `🧊 nguyên-văn slot cũ:` ⇒ lật = **sơn lại ảnh chụp**, đúng lớp R-1 bắt @S181.
- (b) **Sweep `"roster 23"` ở `STATUS:478` theo dòng** — LOẠI. Dòng 15.804 ký tự chứa **2** chuỗi; (b) là ảnh-chụp S149 ĐÚNG và tự khép kín. Neo theo char/đoạn.
- (c) **Ghi "16/17 đích gist" làm baseline** — LOẠI. `ring1` chạy cùng lệnh ra **19**; 2 engine cãi nhau chưa tìm ra engine hỏng ⇒ **không chọn số nào**.
- (d) **Hạ số "12 root" ở `permission-matrix:3`** — LOẠI, đây là bẫy: 12 là **ĐÚNG SỐ**; chỗ sai là **cây `:44-77`** vẽ 8. Sửa = bồi cây, không hạ số.
### C. Kế-hoạch (delta suy-nghĩ)
Delta lớn nhất phiên này: **lỗi bề-mặt không nằm yên — nó lây vào brief mà lead viết cho vai đo.** Ba ca độc lập cùng chiều trong một phiên (enum ma ở file LỆNH → brief `lead-stale` · `HANDOFF:58` → brief `ring2` · `MEMORY.md:28` auto-inject → chính vai KIỂM). ⇒ **vá bề-mặt điều-khiển phải ưu tiên trên vá bề-mặt kể-chuyện**, vì chỉ loại đầu mới nhân bản. Và hệ quả kỷ luật: **brief lead viết ra là một artifact CẦN ĐƯỢC KIỂM**, không phải kênh miễn nhiễm — lượt này `ring2` BROKE đúng tiền-đề tao giao nó.
Delta thứ hai: **17/17 ĐẠT đọc dễ thành "sạch"**, nhưng đó là **độ chính xác ⟂ độ phủ**. `ring2` mở đúng một chỗ ngoài vùng 2 vai đã chạm là ra ngay 1 FLAG sót cùng class ⇒ mọi TOTAL lượt này phải mang nhãn **SÀN**.
### D. Đang-thảo-luận
- `{treo-chờ-anh}` slot (67) người SOẠN có được upload file khi duyệt KHKK — policy rộng hơn ý định, im 9 nhãn phiên, sắp bị archive.
- `{đang-cãi}` VK-5 — **3 bề mặt lệch nhau** (`ctx-audit` FLAG-3): `_mind` treo ⟂ `owner-decisions-…-dot-2.md:49` "✅ CHỐT GIỮ PHỤ LỤC @S181" ⟂ `sub-lead-gap-auditor.md:95` "ĐÃ CHỐT". Không tự phán bên nào: nếu lead đúng thì ledger **ngân một chữ OK quá tay**; nếu ledger đúng thì §D đang giữ câu đã trả lời ⇒ **hỏi anh lần 2 = vi phạm A5**. Fail-safe: hạ `owner-decisions:49` xuống nấc *"lead đọc là chốt, chưa có chữ chỉ nhánh"*.
- `{gần-chốt}` deep-sweep H24 — 🔴 **`ctx-audit` FLAG-4: đổi nhãn KHÔNG đổi kết quả.** @S181 lead bị bắt vì **hạ** xuống NEXT-em; @S182 lead **nâng** lên `treo-chờ-anh` — hai nước ngược chiều, **cùng kết quả: không chạy**, 15/15 → **16/15**. `treo-chờ-anh` còn tệ hơn: khoá món sau cánh cửa chỉ anh mở được ⇒ nợ trôi tiếp **với lý do trông chính đáng**. Nhãn hạ về `gần-chốt` = việc của em, không đẩy sang anh.
- `{mới-nêu}` **V-5 vị-từ mã-hoá chưa cắm**`ctx-audit` FLAG-2: RƠI THẬT, và `lead-gap` FLAG-6 **SÓT** nó ⇒ số món rơi ≥ **3**. Nhà duy nhất còn sống = `bao-cao-cuoi.md:79` = artifact **đã đóng + đã gửi hub**, không phải hàng đợi. Control dương cùng dòng `:79`: `W3b` đi tiếp tới `HANDOFF:7`, V-5 cùng câu **không đi đâu**.
- `{treo-chờ-anh}` (72) bật `mfe-eval` vào nhịp closeout thường xuyên không.
- `{mới-nêu}` `ring5-audit` build SE-form ngay hay chờ nhịp adopt chung VIPIX — slot (41), V5 đang chạy 64 FLAG mà **0 vai chấm lại**.
- ~~`{đang-cãi}` 7 baseline `-Expect`~~ 🔴 **ERRATA @open, `ctx-audit` FLAG-1 bắt — dòng này SAI cả 2 vế, giữ gạch làm vết.** Anh **ĐÃ CHỐT** phương án (b) tách-file-dữ-liệu (`so-yeu-cau-chu-du-an.md:40` `YC-007` `"6 -> b"`, trạng thái **đóng**) **VÀ đã SHIP**: `scripts/naming-baseline.json` 1.354 B + `naming-scan.ps1:63,71,90,121,177` đọc thật, land `a3776eaa`. Đường lây 3 tầng: `lead-gap` FLAG-6(a) grep **TÊN CŨ** `-Expect` → 0 hit → kết "rơi" (quyết định land dưới tên **"baseline"** trong `.json`+`.ps1`, ngoài tập grep) → `ring2` chấm `lead-gap` 6/6 ĐẠT nhưng G-6 chỉ kiểm vế tự-rút → lead promote vào §D. **Bài: 0-hit theo TÊN đọc thành "đã rơi".**
- `{mới-nêu}` đề-nghị `ring2` *"đòi phép đo trước khi vào tally"* — luật về chính bộ đếm H24; **carry từ `_mind-s-12:84`, cũng đã rơi**.
- `{gần-chốt}` W3b cắm mã thoát — phân loại 64/64 xong, chỉ chờ soak (tự tan).
- `{mới-nêu}` `ring1-audit` persona ghi *"CẤM tự chạy ở open"* mà lead giao @open 5 phiên liền — sửa persona hay sửa vận hành.
### E. Dòng-sống spawn/engine-run
- `front-end-reviewer-style` · `back-end-reviewer-style` · `database-reviewer-style` · `score-counter` · `score-count-auditor`**5/5 `ALIVE`**`.claude/workflows/runs/2026-08-07-S182-bookend-open/spawn-probe-registry.md`
- `tooling-auditor``PASS_WITH_FLAGS — 4``.claude/workflows/runs/2026-08-07-S182-bookend-open/sub-tooling-auditor.md`
- `harvest-curator``GATE-FAIL — 7``.claude/workflows/runs/2026-08-07-S182-bookend-open/sub-harvest-curator.md`
- `lead-stale-auditor``11 FLAG``.claude/workflows/runs/2026-08-07-S182-bookend-open/sub-lead-stale-auditor.md`
- `lead-gap-auditor``6 FLAG``.claude/workflows/runs/2026-08-07-S182-bookend-open/sub-lead-gap-auditor.md`
- `ring1-audit``41 ĐẠT / 7 TRƯỢT / 8 KC``.claude/workflows/runs/2026-08-07-S182-bookend-open/sub-ring1-audit.md`
- `ring2-audit``17 ĐẠT / 0 TRƯỢT` + 2 errata + 1 FLAG SÓT → `.claude/workflows/runs/2026-08-07-S182-bookend-open/sub-ring2-audit.md`
- `ring4-audit`**NO-OP có khai** (SHARD-PROBE `pending=3 / ngưỡng=15 ⇒ IM`, pin missing) → không spawn
- `ctx-audit``TRUOT — 7 FLAG``.claude/workflows/runs/2026-08-07-S182-bookend-open/sub-ctx-audit-open.md` *(🔴 dòng này thêm SAU khi vai trả — `ctx-audit` FLAG-7: block-0 ghi ở Phase 3.5 còn `ctx-audit` spawn ở 3.5(iii) ⇒ §E luôn ghi **một nhịp trước** lượt cuối nó phải kể. **Cùng HÌNH với FLAG-6** `mind-blocks`. Tiền lệ vá @S173: §E có dòng cho vai ĐANG chạy)*
- 🔴 **6/6 vai bookend garble first-return, recover 6/6 bằng resume, mất 0 byte.** Hai câu garble **chứa finding thật** ⇒ đừng vứt câu garble.

View File

@ -0,0 +1,67 @@
# bookend-open-synthesis — S182 (phiên-LOGIC L13, window 1)
**Ngày:** 2026-08-07 → 08 · **HEAD @open:** `936d1aed` *(🔴 đọc là "cây làm việc tại `936d1aed`" — xem ERRATA-2)* · **counter:** 56 · **session-logic:** `session-13`
## Đã chạy
| vòng | vai | verdict |
|---|---|---|
| **V1** tooling/harvest | `tooling-auditor` | `PASS_WITH_FLAGS — 4 finding` |
| | `harvest-curator` | `GATE-FAIL — 7 finding` |
| | → `ring1-audit` | **41 ĐẠT / 7 TRƯỢT / 8 KC** |
| **V2** soi-lead H24 | `lead-stale-auditor` | **11 FLAG** |
| | `lead-gap-auditor` | **6 FLAG** |
| | → `ring2-audit` | **17 ĐẠT / 0 TRƯỢT** + 2 errata + **1 FLAG SÓT** |
| **V3** trio-memory | — | ⚪ chưa chạy (vòng AUTO giữa 2 bookend) |
| **V4** nén-ngủ | `ring4-audit` | 🔇 **NO-OP**`SHARD-PROBE pending=3 / ngưỡng=15 ⇒ IM` |
| **V5** floor/detector | *(không có vai KIỂM)* | 🔴 **GAP** — slot (41) `[carry:ring5-audit-gap]` |
| **0.6b** registry-probe | 5 vai mới | **5/5 ALIVE** ⇒ slot (73) ĐÓNG |
## Máy đã đo (0 hardcode)
Sàn-3 **5/5 im** · test **645 PASS** (45D+600I) · `governance-detectors` **64 FLAG**, canonical mig=72 test=645 gotcha=87 table=97 menu=113 policy=452, disk cross-check khớp · H17 **2 GAP:MED** · `nhip-no-probe` `light 3/6 · deep 16/15 OVERDUE · 3 over-cap · run-chưa-gom 0 · pull-cách 2 ngày · so-yc treo 2 / nhắc2+ 1` · RAG **2449 chunk**, index stale 2026-05-29 · hot-load **726.660 B ≈ [181.665..242.220] tok**, headroom **137.780** · `h24-signal-write` **9 class FIRE**.
---
## 🔴 Kết quả CHỐT — số nào tin được, số nào không
| đại lượng | số ĐÚNG | ai chốt | ghi chú |
|---|---|---|---|
| root menu (`DbInitializer`) | **12** (cây skill vẽ 8, **thiếu 4**) | ring1 parser depth-aware, cross-check `MenuKeys` khớp | H1 tự khai thước hỏng ⇒ **đúng, không né** |
| `permission-matrix/SKILL.md:3` "12 root" | **ĐÚNG SỐ** | ring1 | lỗi chỉ còn là **vi-phạm luật-không-chép**, KHÔNG sai sự-thật |
| HANDOFF "thiếu 5 root" | **SAI** (thật 4) | ring1 | H1 **từ chối chép** ⇒ quyết định đúng |
| nợ harvest S169 | **264.049 B** | ring1 | H2 ghi 264.451 — **lỗi CỘNG +402 B**, 9/9 thành phần khớp byte |
| **TỔNG nợ harvest** | **576.255 B / 18 lượt-vai** | ring1 | H2 ghi 576.657 (kế thừa lỗi cộng) |
| "17 đích gist" | 🔴 **KHÔNG TÁI-LẬP ĐƯỢC** | ring1 ra **19** cùng lệnh | **CẤM ghi 16/17 làm baseline** |
| slot 71 "nợ MA S171" | 🔴 **BÁC** | ring1 `HANDOFF:60` | slot **đã gạch ✅ ĐÓNG @S179**; số nằm trong `🧊 nguyên-văn slot cũ:` ⇒ đề-xuất "lật slot" = **sơn lại ảnh chụp** |
| `gap-carry-aged = 0` | 🔴 **số 0 CHẾ TẠO** | lead-gap, ring2 verify **7/7 byte-exact** | vùng câm `L1178` = **42.606/186.729 = 22,82%** |
| counter | **56** (cây) / **55** (blob) | ring2 ERRATA-2 | tick trong working tree, chưa vào blob |
| tập over-cap | {`ring2-audit` 29.582 · `tooling-auditor` 29.429 · `lead-stale-auditor` 27.658} | ring2 `stat` 28/28 | `HANDOFF:40` sai 2/3; `reviewer` đã tụt **21.058** |
## 🔴 Ba lỗi tự-quy-chiếu trong CÙNG một phiên
1. **`session-start.md:161/:162` liệt 5 class, enum có 12** → lead đọc file lệnh, viết brief *"5 nhánh view-*"*, giao cho `lead-stale-auditor`**chính vai đó bắt được brief sai vì nguồn sai**. Đợt vá ghost-enum @S180 sửa `hmw.js:33` + persona nhưng **bỏ file LỆNH**.
2. **`HANDOFF:58` là NGUỒN LÂY** → lead chép số S172 vào brief giao `ring2-audit` như thể là S173 → ring2 mở artifact gốc, **BROKE tiền-đề của lead** (`lead-gap` trích ĐÚNG, **brief lead trích SAI**). Và `:58` bị **đóng ké dưới `~~(70) ✅ ĐÓNG @S179~~`** ⇒ quét "slot còn mở" không thấy.
3. **`agent-memory/ring2-audit/MEMORY.md:28`** ghi *"(5 class `view-*`)"* trong `## 🎯 Role baseline` — **auto-inject ~200 dòng đầu mỗi lượt spawn** ⇒ vai bị nạp **thước thiếu một vạch TRƯỚC khi chấm**. Vai **tự bắt @S173**, dòng trôi 25→28, **số sai vẫn nguyên 9 phiên**.
## 🔴 Cảnh báo vá — 2 bẫy
- **`STATUS.md:478` là BẪY NEO** (ring1 R-1): dòng **15.804 ký tự** chứa **HAI** chuỗi "roster 23" — (a) char ~2050 **SAI** (sinh ở `d2918790`, đúng là 28) · (b) char ~3475 **ĐÚNG** (ảnh-chụp S149, 2320=3 tự khép kín). Sweep theo DÒNG sẽ **giết luôn (b)** ⇒ tái diễn R-1 S181. **Neo theo char/đoạn, không theo số dòng.**
- **`permission-matrix/SKILL.md` tự mâu thuẫn nội-tại** (ring1 R-2): `:3` đúng (12), cây `:44-77` sai (8). Sửa đúng = **bồi cây lên 12**, KHÔNG hạ số 12.
## 🔸 Nợ mới phát sinh trong phiên
- **`contract-workflow/SKILL.md` trễ 2 migration** (Mig 71 nhóm-duyệt N1-N8 · Mig 72 MaHangMuc): 0 hit `ApprovalGroup`/`N1`/`nhóm duyệt`/`MaHangMuc`, control dương `phase`=25 hit.
- **`ring4-audit` sổ đóng băng 12 ngày** — chạy thật @S170 (`sub-ring4-audit-sleep-S170.md` 20.122 B) mà `MEMORY.md` mtime 2026-07-26, `grep -c "S170"`=0.
- **`ring1-audit` persona thối so với vận hành 5 phiên** (R-3): ghi *"KIỂM end-only… CẤM tự chạy ở open"* trong khi lead giao @open ở S152·S159·S173·S180·S182.
- **`runs/…S180…/lane-memory-proposals/MEMORY.md` 2.670 B** — có thể là delta đã soạn chưa APPEND ⇒ làm F-01 **mạnh hơn** bản H2 nộp. Cần disposition.
- **`cicd-monitor/archive/2026-08.gist.md` VẮNG** (control dương: 10 cặp anh em đủ trong cùng dir).
## 🔴 Nhắc đọc đúng nấc
**17/17 ĐẠT của ring2 = độ CHÍNH XÁC, KHÔNG phải độ PHỦ.** `lead-stale` tự khai chỉ chạm **~7% `STATUS`** (30/853 dòng) và **6/29** persona; ring2 mở đúng MỘT chỗ trong phần chưa chạm (`agent-memory/**`) là ra ngay **1 FLAG sót cùng class**. ⇒ **11 và 6 là SÀN. Cấm làm tròn sàn thành tổng.**
**`gap-carry-aged` 0→1 là do vai TỰ TÍNH TAY**, máy sinh số đó **vẫn đang mù 22,82% đầu file**. **Đừng đọc thành "detector đã khỏi".**
## #53 datum
**6/6 vai garble first-return** (tooling · harvest · lead-stale · lead-gap · ring1 · ring2). **Recover 6/6 bằng SendMessage-resume, mất 0 byte.** Khớp chuỗi S180 (6/6) và S181 (9/9). 🔸 Hai câu garble **chứa finding thật** (`"Tree shows 8 roots…"` → F-4 · `"Measured: 12 roots…"` → số load-bearing của cả phiên) ⇒ **câu garble không phải rác, đừng vứt**.

View File

@ -0,0 +1,31 @@
# run — S182 bookend @open (phiên-LOGIC L13, window 1)
- ts-moc: 2026-08-07T23:37:36+07:00 → 2026-08-08T00:35
- HEAD @open: `936d1aed` 🔴 đọc là **"cây làm việc tại `936d1aed`"** (ring2 ERRATA-2: counter blob=55, cây=56)
- session-logic: `session-13`
- counter: **56** (tick S181 → S182)
- nhịp: light 3/6 (hình-B: vô-điều-kiện mỗi bookend) | deep **16/15 OVERDUE**
## Stages
- [x] **V1** tooling/harvest → `tooling-auditor` PASS_WITH_FLAGS 4 · `harvest-curator` GATE-FAIL 7 → **`ring1-audit` 41 ĐẠT / 7 TRƯỢT / 8 KC**
- [x] **V2** soi-lead H24 → `lead-stale-auditor` 11 FLAG · `lead-gap-auditor` 6 FLAG → **`ring2-audit` 17 ĐẠT / 0 TRƯỢT** (+2 errata, +1 FLAG SÓT trong file của chính nó)
- [ ] **V3** trio-memory — chưa chạy (vòng AUTO giữa 2 bookend, `session-start §2.1.9(a)`)
- [x] **V4** nén-ngủ — `SHARD-PROBE pending=3 / ngưỡng=15 ⇒ IM` ⇒ không chạy, `ring4-audit` **NO-OP có khai**
- [x] **V5** floor/detector — `governance-detectors` 64 FLAG · H17 2 GAP:MED · 🔴 **0 vai KIỂM** (slot 41 `ring5-audit` GAP)
- [x] **BƯỚC 0.6b** registry-probe — **5/5 ALIVE** ⇒ slot (73) ĐÓNG
- [x] **Phase 3.5** ctx soft-memory — `_mind-s-13` block-0 + `mind-check` + **`ctx-audit` TRUOT 7 FLAG**
## Artifact
`spawn-probe-registry.md` · `sub-tooling-auditor.md` · `sub-harvest-curator.md` · `sub-lead-stale-auditor.md` · `sub-lead-gap-auditor.md` · `sub-ring1-audit.md` · `sub-ring2-audit.md` · `sub-ctx-audit-open.md` · **`bookend-open-synthesis.md`**
## Assertion
- **residual-write = 0** — soát TRONG CÙNG TURN với wave (Stop-hook fire ở turn-boundary; soát turn sau = soát sổ đã bị hook nuốt). Chỉ `.session-counter.json` đổi = bản ghi của lead qua `h24-signal-write.ps1`. `agent-memory/` **không bị chạm** bởi 8 vai propose-only.
- `mind-check --session 13``dat=10 TRUOT=0 co=1 exit 0`. **`co=1` GIỮ CÓ CHỦ ĐÍCH** = máy nêu đúng `ctx-audit` FLAG-7 (*"chèn 1 block refresh SAU khi vòng cuối báo xong"*) — vá ở `/pause`, không vá bây giờ.
## #53
**7/7 vai garble first-return** (tooling · harvest · lead-stale · lead-gap · ring1 · ring2 · ctx-audit). **Recover 7/7 bằng SendMessage-resume, mất 0 byte.**
🔸 **3 câu garble CHỨA finding thật**`"Tree shows 8 roots…"` (→F-4) · `"Measured: 12 roots…"` (→số load-bearing cả phiên) · `"mind-blocks=1 vs disk=2…"` (→FLAG-6). **Câu garble không phải rác.**

View File

@ -0,0 +1,25 @@
# spawn-probe registry — BƯỚC 0.6b @S182 (đóng slot 73)
> 🔴 Đây là **REGISTRY-probe**, KHÁC model-probe (0.6). Vai có trên đĩa + có tên trong `VALID_ROLES` = điều-kiện **CẦN, KHÔNG ĐỦ**.
> Bằng-chứng falsifiable DUY-NHẤT = spawn THẬT + nhận return. Static-check (`ls` + grep) không đủ.
**Bối cảnh:** roster đổi 26→28 @S181 (+2 vai score W4b) và 3 vai STYLE land @S176 nhưng **chưa probe lần nào** — slot (73) HANDOFF:32 nói anh đã "OK" @S179 mà chưa restart. Phiên S182 = phiên đầu sau restart ⇒ probe chạy được.
**Task probe:** tối-thiểu, trả đúng `ALIVE|<role>`, **cấm gọi tool**.
| # | vai | land | kết quả | tok | tool_uses | ms |
|---|---|---|---|---|---|---|
| 1 | `front-end-reviewer-style` | S176 | ✅ `ALIVE\|front-end-reviewer-style` | 39.964 | 0 | 2.070 |
| 2 | `back-end-reviewer-style` | S176 | ✅ `ALIVE\|back-end-reviewer-style` | 53.192 | 0 | 2.956 |
| 3 | `database-reviewer-style` | S176 | ✅ `ALIVE\|database-reviewer-style` | 54.604 | 0 | 2.094 |
| 4 | `score-counter` | S181 | ✅ `ALIVE\|score-counter` | 29.113 | 0 | 1.996 |
| 5 | `score-count-auditor` | S181 | ✅ `ALIVE\|score-count-auditor` | 29.214 | 0 | 2.140 |
**Kết: 5/5 ALIVE, 0 error, 0 garble.** Slot (73) ĐÓNG bằng bằng-chứng spawn thật (không phải static-check).
## 🔸 KHAI CHẶT PHẠM VI (đừng đọc thành "đã phủ hết")
1. Probe này phủ đường **Agent-tool registry**. Đường **`hmw.js VALID_ROLES``agentType`** là **BỀ-MẶT KHÁC**, chỉ chứng được khi có workflow THẬT gọi vai đó. Roster đổi = đổi **CẢ HAI** bề-mặt; 1 probe KHÔNG phủ cả 2.
2. `tool_uses=0` ở đây = **ĐÚNG THEO LỆNH** (lệnh cấm gọi tool), **KHÔNG** phải chữ-ký #53 (= `tool_uses=0` **CỘNG VỚI** bịa đã-làm-việc). Đừng nhìn cột đó rồi tưởng garble.
3. Probe chứng **spawn được**, **KHÔNG** chứng **soi được**. Cùng caveat mark `RC-pqhuy1987-15-07-2026-15-32-23` đã ghi cho 2 vai H24 khi mới land.
4. Con-số bảng này là **ảnh chụp S182**, không phải roster hiện-hành — 23 vai còn lại không probe lượt này (roster ổn-định phần đó).

View File

@ -0,0 +1,103 @@
# sub-ctx-audit-open — SOI-CHUỖI lớp trí-nhớ MỀM @open S182 (L13)
**VERDICT: `CTX-AUDIT: TRUOT — 7 FLAG`**
Ca **FALLBACK HỒI-TỐ ĐẦY-ĐỦ** (0 pause / 0 tiep ⇒ `ctx-curator` + `ctx-verifier` chưa chạy lần nào; 5-khoản + 4-khoản là **TỰ SOI**, không phải kiểm-vết).
> ⚠️ **#53:** first-return garble — câu garble chứa finding thật (`mind-blocks=1` vs đĩa 2). Recover bằng resume. **7/7 vai garble lượt này.**
---
## FLAG-1 [khoản 2+5] **HIGH** — `_mind-s-13:99` hồi sinh một quyết-định ĐÃ CHỐT + ĐÃ SHIP thành `{đang-cãi}`, kèm khẳng-định SAI
Dòng viết: `{đang-cãi}` 7 baseline `-Expect`*"đã rơi khỏi mọi sổ bền"*.
**Sự thật đo được:** `owner-decisions-07-08-2026-dot-2.md:20` anh chốt câu **6 = "b — tách ra file dữ-liệu"** · `so-yeu-cau-chu-du-an.md:40` **`YC-007`** trạng thái **đóng** · **đã thi hành**: `scripts/naming-baseline.json` **1.354 B**, header *"Owner chot phuong an (b) tach-file-du-lieu @S181 (cau 6)"*; `naming-scan.ps1:129-131` đọc baseline thay hardcode; land `a3776eaa`.
**Cơ-chế:** `lead-gap` FLAG-6(a) đo bằng **một chuỗi TÊN CŨ** `-Expect` trên 5 tệp doc → 0 hit → kết "rơi". Quyết-định land dưới **tên khác** ("baseline"/"khoản 6") và trong `.json`+`.ps1`**ngoài tập grep**; vai còn tự ghi control-dương *"`baseline` có hit ở STATUS"***không lần theo sợi đó**.
🔴 **Bài: 0-hit theo TÊN đọc thành "đã rơi".**
**Rủi ro:** §D là bề mặt cửa-sổ mới đọc TRƯỚC TIÊN ⇒ **mời revert đúng cái single-source anh chọn ngược khuyến-nghị lead**.
## FLAG-2 [khoản 2] **HIGH** — món `{gần-chốt}` **V-5 RƠI THẬT**; đây là món `lead-gap` FLAG-6 **SÓT**
`_mind-s-12:114` `{gần-chốt}` **V-5 vị-từ mã-hoá** (*"chỉ chờ chỗ cắm"*). Vắng khỏi block-0 `_mind-s-13`; `HANDOFF` 0 · `WAL` 0 · `STATUS` 0 (hit duy nhất = chữ "0-mojibake" entry cũ G-009, **khác vật**, tách bằng `grep -o` ngữ cảnh). Nhà duy nhất còn sống = `bao-cao-cuoi.md:79` = **artifact ĐÓNG + đã gửi hub**, không phải hàng đợi việc.
🔴 **Control-dương nằm TRONG CÙNG MỘT DÒNG `:79`:** *"W3b cắm mã thoát (soak, tự tan) · **V-5 vị-từ mã-hoá chưa cắm** · …"* — `W3b` đi tiếp tới `HANDOFF:7` **và** `_mind-s-13:101`; V-5 **cùng câu, cùng người ghi, không đi đâu**.
**Số món rơi thật ≥ 3, không phải 2.**
## FLAG-3 [khoản 5] **MED** — VK-5: ba bề mặt, `_mind` ngược hai cái kia
`_mind-s-13:95` `{treo-chờ-anh}``owner-decisions-…-dot-2.md:49` *"✅ VK-5 — CHỐT GIỮ PHỤ LỤC @S181"*`sub-lead-gap-auditor.md:95` *"4 câu `HANDOFF:6` — ĐÃ CHỐT … (4) VK-5 giữ phụ lục"*.
Vai **không phán bên nào đúng**: nếu lead đúng (chữ "OK" không chỉ nhánh) thì ledger đang **ngân một chữ OK quá tay** — đúng ca S179 mà `HANDOFF:64` giữ làm bài học; nếu ledger đúng thì §D đang giữ câu đã trả lời và sẽ **hỏi anh lần 2 (vi phạm A5)**.
**Hướng fail-safe:** hạ `owner-decisions:49` xuống nấc *"lead đọc là chốt, chưa có chữ chỉ nhánh"*.
## FLAG-4 [khoản 5] **MED** — deep-sweep H24: **đổi nhãn, không đổi kết quả**
`_mind-s-13:96` gán `{treo-chờ-anh}`; `HANDOFF:7` gán *"NEXT-em (tự chạy, không cần anh)"*; `HANDOFF:11` ghi `lead-gap` @S181 đã FLAG `gap-decision-sunk` vì lead **hạ** nó xuống ngang việc vặt, kèm *"Cổng kêu mà không đổi hành-vi = cổng không có răng."*
@S181 bị bắt vì **HẠ**, @S182 **NÂNG** lên treo-chờ-anh — **hai nước đi ngược chiều, cùng kết quả: không chạy**, 15/15 → **16/15**.
🔴 `treo-chờ-anh` **tệ hơn** ở một điểm: khoá món sau cánh cửa **chỉ anh mở được** ⇒ nợ trôi tiếp **với lý do trông chính đáng**.
**Máy mù trọn:** phụ-9 chỉ hỏi *"có nhãn chưa"*, không hỏi *"nhãn có chuyển chủ-thể trách-nhiệm không"*.
## FLAG-5 [khoản 5] **MED** — §B trỏ "4 câu số" = referent mơ hồ, bề mặt gần nhất của nó đã THỐI
`_mind-s-13:79` §B: *"chờ anh chốt **4 câu số**"*. §D phiên này có đúng 4 ý `treo-chờ-anh` — một cách đọc. Nhưng `HANDOFF:6` **cũng có đúng 4** câu dưới *"🔢 CHỜ ANH"*, và **cả 4 đã trả lời + thi hành @S181** (`936d1aed`), **bảng chưa lật**.
⇒ Cửa-sổ mới đọc §B rồi mở HANDOFF sẽ **khớp nhầm vào danh sách đã chết** và hỏi lại anh 4 câu đã trả lời. §B là ô dựng ra để **chống đảo lại vòng cũ**, ở đây nó **tự trỏ vào vòng cũ**.
**Sửa rẻ:** viết thẳng **số slot** thay vì đếm "4 câu".
*(`HANDOFF:6` thối là lane `lead-stale`; 11 FLAG lượt này rơi vào `:38 :40 :7 :11 :8 :9`+STATUS+persona, **không có** `:5`/`:6` — nêu vì `_mind` §B tựa vào đó, **không chấm vai kia**.)*
## FLAG-6 [cân-sổ ở chốt-kết] **MED** — `session-12/_end:7` khai `mind-blocks=1`, đĩa có **2**; lỗi **CẤU-TRÚC**, không phải gõ nhầm. **MÁY CÓ BẮT, mức `co-INFO`**
Đĩa: `grep -c '^## MIND-[0-9]' _mind-s-12.md` = **2**. `_end:7`: `markers: _pause=1 · _tiep=1 · _snapshot=0 · mind-blocks=1`.
**Cơ-chế chứng bằng mtime:** `_end` = **23:28:49**, `_mind-s-12.md` = **23:30:11** ⇒ block refresh-@closeout ghi **sau `_end` 82 giây** ⇒ dòng `mind-blocks=` **về nguyên tắc KHÔNG BAO GIỜ đếm được** block refresh-bookend.
🔴 **Hệ quả nặng:** bản vá S162 dựng vế fail-LOUD *"`|block|` không tăng thì `_end` BẮT BUỘC mang `mind-refresh: BỎ QUA — <lý do>`"* — nhưng từ `_end`, ca **đã refresh** và ca **bỏ qua refresh** in ra **CÙNG MỘT con số**. **Vế fail-LOUD mất răng ngay tại sổ nó phải kêu.**
**Phép quyết định (ĐÃ CHẠY):** `mind-check --session 12``(6) bat-bien: co` / `co-INFO lech tu-khai: '_end' mind-blocks=1 vs DIA 2 (DIA THANG)` / `verdict: dat=9 TRUOT=0 co=2 exit 0`.
⇒ Máy **có răng ở mức cờ-INFO, KHÔNG chặn** (exit 0), và cờ đó **chỉ sống ở phiên 12 — không ai đọc lại sau khi phiên đóng**. Đúng hợp-đồng §(iv): **đĩa thắng**, bất-biến chuỗi vẫn ĐẠT.
**Sửa rẻ:** đếm `mind-blocks=` **SAU** bước refresh.
## FLAG-7 [khoản 3] **LOW-MED** — §E thiếu đúng lượt spawn cuối của chính cửa-sổ mình, và thiếu CÓ HỆ-THỐNG
§E phủ **12/12** vật đã có verdict lúc ghi: 5 probe (1 dòng, trỏ registry) + 6 vai bookend + `ring4-audit` **NO-OP có khai** ⇒ ✅ **ca NO-OP KHÔNG bị bỏ im — ĐẠT, ghi công lead.** Verdict đối chiếu **4/4 khớp** sub-file + synthesis.
**Thiếu:** `ctx-audit`**0 hit** dưới marker (2 hit duy nhất `:10`, `:22` đều **trong khối luật**; control-dương `ring2-audit`=2, `ring4-audit`=1).
**Cơ-chế:** block-0 ghi ở Phase 3.5, `ctx-audit` spawn ở Phase 3.5**(iii)** ⇒ block-0 **luôn ghi một nhịp TRƯỚC lượt cuối nó phải kể** — 🔴 **CÙNG HÌNH với FLAG-6**. Tiền lệ vá được: @S173 §E có dòng cho vai **đang chạy**.
---
## Khoản 1 — block-0 có NỘI-DUNG THẬT: **ĐẠT**
Không dựa metadata. Mảnh khuôn/máy không tự sinh được: tên ý §D (*"`ring1-audit` persona ghi «CẤM tự chạy ở open»"* `:102`) + E-verdict cụ thể (`ring1-audit``41 ĐẠT / 7 TRƯỢT / 8 KC` `:111`) + §B nhánh-đã-loại (d) (*"12 là ĐÚNG SỐ, chỗ sai là cây `:44-77`"* `:84`).
🟢 **Block-0 GIÀU NỘI-DUNG NHẤT trong 5 phiên vai chấm** — khác hẳn S173 (4/4 phiên block-0 = bản sao SCAFFOLD-META).
## Khoản 4 — việc ĐÃ-CHỐT chỉ dùng con-trỏ: **ĐẠT**
§A/§E dùng path đầy đủ tới run-folder/sub-file; **0 ca chép lại nội-dung verdict đã chốt**. §A `:74` trỏ `bookend-open-synthesis.md` thay vì chép bảng.
## Khoản 6 — 🎣 MỒI-NGẦM gieo cho @close
> **MỒI = ý `_mind-s-13:102` — `{mới-nêu}` "`ring1-audit` persona ghi «CẤM tự chạy ở open» mà lead giao @open 5 phiên liền — sửa persona hay sửa vận hành".**
**Lý do chọn:** ý chưa-chốt **DUY NHẤT trong §D không có bất kỳ nhà nào**. Đo: `carry:ring1*` trong HANDOFF = **0** · `"CẤM tự chạy"` trên HANDOFF/STATUS/WAL = **0/0/0** · `"R-3 / persona thối"` trong HANDOFF = **0**; **control-dương** `carry:ring5-audit-gap` = **6 hit** (ý anh em CÓ nhà). Không slug, không slot, không sổ ⇒ **rủi ro rơi cao nhất**, đúng khuôn vừa giết V-5 (FLAG-2) và suýt giết baseline (FLAG-1).
**Cách chấm @close:** **grep NỘI DUNG**, không neo số dòng (bài S158) — tìm cụm persona/vận-hành `ring1` ở block `_mind` mới · `_tiep-*` · WAL · HANDOFF. **ĐẠT** nếu được mang tiếp HOẶC đóng-CÓ-KHAI; **TRƯỢT** nếu biến mất im lặng.
🔴 **Khai kẽ (không tô thành tính năng):** single-lead ⇒ *"bên bị chấm không biết mồi"* chỉ đúng **xuyên cửa-sổ**; trong cùng cửa-sổ này lead đọc được return ⇒ mồi **không mù**.
## checklistEvidence — số TỰ tái dựng (không tin lời khai)
- Đĩa s13: `_pause-*|pause-*` = **0** ⇒ p=0; `^## MIND-[0-9]` = **1**; kỳ vọng `[p, p+3]`=[0,3] ⇒ **ĐẠT**. Máy in y hệt ⇒ **0 lệch**.
- Đĩa s12: p=**1**, |block|=**2**, [1,4] ⇒ **ĐẠT** (tự-khai lệch ⇒ FLAG-6).
- 5 mục A-E **5/5** đúng thứ tự, 0 mục trống. Nhãn D **9/9** đúng chuỗi có dấu ⇒ **trượt-cấu-trúc = 0** (sai là **NỘI DUNG** nhãn — FLAG-1/3/4 — **máy không thấy**).
- 3 rào tự soi (dòng >68): rào-1 = 0 · rào-2 = 0 · rào-3 = 0; **control-dương** `^- ` = **25** ⇒ thước sống, 0-hit là thật. **Máy KHÔNG hở.**
- §B vs `WAL next:` — WAL rỗng 182 B, `next: (trống)` ⇒ không có lệch để "tin sổ máy"; hệ quả gộp vào FLAG-5.
- **Tự re-chạy thay vì tin PIN:** `mind-check --session 13``dat=11 TRUOT=0 co=0 exit 0`, **khớp PIN từng số**. 🟢 **Lần đầu trong 4 lượt PIN của lead còn tươi** (S162 · S169×3 · S172 đều hết hạn). Kỷ luật re-chạy giữ, nhưng lần này nó **xác nhận lead, không bắt lead**.
## Thách-CLEAN
Máy 0 trượt 0 cờ ⇒ nghĩa vụ kích hoạt. Soi 2 chỗ máy mù: (a) §E vs `runs/`+registry → FLAG-7 + xác nhận NO-OP ĐẠT; (b) ý D vs WAL/owner-decisions/sổ REQUEST → FLAG-1, 3, 4. **7/7 FLAG đều NGOÀI tầm máy** (ngữ-nghĩa + liên-phiên).
## Khai lane
FLAG-1 lộ lỗi đi qua **3 tầng** rồi vào `_mind`: `lead-gap` FLAG-6 phát → `ring2` chấm `lead-gap` **6/6 ĐẠT** (G-6 chỉ kiểm vế **tự-rút P5/P7/P8**, không đo lại 2 món còn khai "rơi") → lead promote vào §D. Vai **KHÔNG chấm** `lead-gap`/`ring2` (lane khác); chỉ nêu **đường lây** vì đích đến là §D — lane của mình. **Escalate lead phán.**
## memoryDelta đề xuất (propose-only — vai PHÁN, lead GHI)
- `mind-carry-restore-stale-form` — vớt carry mà **không re-đo trạng-thái** ⇒ hồi sinh việc đã chốt.
- `mind-selfcount-before-refresh` — FLAG-6 + FLAG-7 **CÙNG MỘT HÌNH**: sổ cân được ghi **một nhịp trước** sự kiện cuối nó phải kể.
- `carry:v5-vi-tu-ma-hoa` — món rơi cần nhà thật.

View File

@ -0,0 +1,91 @@
# sub-harvest-curator — V1 @open S182 (H2 harvest-integrity 5 trục)
**VERDICT: `HARVEST-CURATOR: GATE-FAIL — 7 finding`**
Scope: HEAD `936d1aed`, @open S182 (L13-w1). Read-only, propose-only — 0 Write/Edit.
> ⚠️ **#53 datum:** first-return = garble (*"Now the S169/S170 block. Let me verify the sleep-batch claim fresh since it swings ~237 KB."*). Recover bằng SendMessage-resume, path-2. **Mất 0 byte.**
---
## F-01 [Coverage] `runs/2026-08-07-S180-adap-upgrade-pack-phased/` — nợ **MỚI 229.823 B / 5 lượt-vai `reviewer`**, KHÔNG có trong HANDOFF slot 71
Vai `reviewer` đẻ 9 sub-file @S180; memory chỉ có 5 mục. Mồ côi: `sub-n3-lane-c-gd34.md` 72.156 + `sub-n3-lane-d-gd56.md` 43.795 + `sub-n3-lane-e-benh.md` 38.531 + `sub-n3-lane-f-maytay.md` 52.923 + `sub-review-n2-S180.md` 22.418 = **229.823 B**.
**Chứng bằng NỘI DUNG (không đo tên):** `grep -ric` trên `agent-memory/reviewer/``"bẫy A1"`=0 · `"sổ-3-khối"`=0 · `"gần xong nhất"`=0 · `"13,0/20"`=0 · `"3,0/17"`=0 · `"KHÔNG ÁP DỤNG có khai"`=0 · `"đóng-hụt"`=0 · `"máy ⟂ tay"`=0 · `"triết lý thứ hai"`=0 · `"F-10"`=0. **Control dương cùng lệnh:** `"S180"`=22, `"reviewer"`=108 ⇒ thước sống, 0-hit là 0 THẬT.
🔴 **Bẫy đã tránh:** `"lane C"`=7 / `"lane D"`=3 / `"lane E"`=2 hit — nhưng đọc ra thì đó là **trích dẫn hạng-hai bên trong mục N4** (`:71` · `:73`), không phải bài học của chính lane. **Đo TÊN ⇒ tưởng 3/5 đã về; đo RUỘT ⇒ 0/5.**
## F-02 [Coverage] `runs/2026-08-01-S168-bookend-close/` — nợ **82.383 B / 3 vai**, ĐỨNG YÊN từ S173
`sub-harvest-curator-close-S168.md` 29.299 + `sub-lead-gap-close-S168.md` 29.820 + `sub-lead-stale-close-S168.md` 23.264 = **82.383 B** (`stat -c%s`).
0 vai nào có mục mở đầu S168 (`grep -n "^[-*#]{1,4}\s*\**S168\b"` = **0 dòng**), trong khi cùng lệnh trả 1 dòng cho mỗi vai `ctx-audit` `ring1-audit` `ring2-audit` `tooling-auditor` `harness-audit` `harness-eval` `harness-refine` `cicd-monitor` (**control dương 8 vai**). 7/10 vai đã đóng, đúng 3 vai này thủng.
🔴 Con số **82.383 khớp TỪNG BYTE** với số S173 ghi 2 phiên trước ⇒ **hai phiên có tuyên bố uỷ-quyền-gom mà 0 byte được gom.**
## F-03 [Coverage] `runs/2026-08-01-S169-bookend-open/` — nợ **264.451 B / 10 lượt-vai** — khối LỚN NHẤT, HANDOFF slot 71 **HOÀN TOÀN VẮNG**
`harness-audit-return.md` 36.119 + `harness-eval-return.md` 24.015 + `harness-refine-return.md` 26.596 + `sub-harvest-curator-open-S169.md` 26.137 + `sub-lead-gap-open-S169.md` 18.318 + `sub-lead-stale-open-S169.md` 22.503 + `sub-ring1-audit-open-S169.md` 34.806 + `sub-ring2-audit-open-S169.md` 32.197 + `sub-tooling-auditor-open-S169.md` 23.236 = 243.927 **+ `sub-ring4-audit-sleep-S170.md` 20.122** = **264.451 B**.
**Đã về nhà, KHÔNG tính nợ:** `sub-ctx-audit-open-S169.md` 34.793 · `sub-reviewer-sleep-fidelity-S170.md` 38.167.
## F-04 [Fidelity] 🔴 TỰ BÁC con số của CHÍNH VAI NÀY — sleep-batch **237.251 B KHÔNG phải nợ**; nhưng "14/16" trong sổ cũng SAI, đo tươi ra **16/17**
`sub-harvest-curator-sleep-batchA-S170.md` 113.797 + `batchB` 123.454 = 237.251 B là **bản nháp gia công**, không phải delta cần gom.
Đếm đích thật: `grep -h "^## "`**17 đích gist**. Kiểm tồn tại: **16 LANDED** (vd `cicd-monitor/archive/2026-05-runs.gist.md` 31.779 · `test-specialist/archive/baseline-history.gist.md` 28.437 · `implementer-frontend/archive/2026-H2.gist.md` 30.144); mtime đích **08-03 10:51/11:03** > mtime nháp **09:51/09:52** ⇒ nháp đã thi hành.
**1 MISSING:** `.claude/agent-memory/cicd-monitor/archive/2026-08.gist.md` **không tồn tại** (thư mục có `2026-08.md` 5.912 B mà **không có** `.gist.md` anh em) ⇒ nợ THẬT còn lại của khối S170, đúng 1 đích.
🔴 **Khai giới hạn thước:** vai đo **tồn-tại + thứ-tự mtime**, KHÔNG đo **byte-exact**. Sổ ghi "14/16 byte-exact"; vai ra "16/17 tồn-tại". Hai phép đo **KHÁC CÂU HỎI****không chọn bừa một số**. Mẫu-số 16 trong sổ **SAI** (đích thật 17, đếm được). Tử-số 14 vs 16 thì thước **không đủ mạnh để bác****`CHƯA ĐO`** cho vế byte-exact, cần md5.
## F-05 [Coverage] `S171-khkk-ui-mirror-pe` = **ĐÃ ĐÓNG** — HANDOFF slot 71 đang mang **NỢ MA 143.682 B**
4 vai / 6 tệp đều có mục thật: `implementer-backend/MEMORY.md:9` · `implementer-frontend/MEMORY.md:3` · `investigator-codebase/MEMORY.md:14` + `archive/2026-08-curate-s180.md:15` · `reviewer/archive/2026-08.md:36` — mục sau ghi thẳng *"cả 3 CHẾT hết-giới-hạn nhưng ĐĨA CỨU 74.070 B"*, và 26.461+23.177+24.432 = **74.070 khớp từng byte**. Cộng 11.650+13.132+44.830 ⇒ **143.682 B** đúng bằng số HANDOFF đang ghi là nợ.
**Cùng lớp, cũng đã đóng:** S172 10/10 · S173 10/10 · S174 · S176 · S181 (9 vai). `S159-bookend-open` KHÔNG tính nợ (theo lệnh).
## F-06 [Placement] HANDOFF nói **8** dir rỗng mis-land class-S54 — đo tươi còn **0**
`find . -type d -empty -not -path "./.git/*"`**5 dòng, 0 dòng class-S54**: `.vs/sd` · 2× `node_modules/.vite-temp` · 2× `obj/{Debug,Release}/net10.0/staticwebassets` — toàn rác IDE/build.
7 dir dưới `runs/2026-08-01-S169-bookend-open/``fe-user/.claude` mà HANDOFF liệt: **không còn**.
**Kiểm chéo lệnh khác chiều:** `find . -type d -name ".claude" | grep -v "^./.claude$"`**rỗng** ⇒ 0 stray lồng.
⇒ Trục Placement **PASS**, nhưng số trong HANDOFF là **neo thối cần lật**.
## F-07 [Completeness] **5 vai** có thư mục memory mà chưa từng có delta của chính mình
- 🔴 **`ring4-audit` THỦNG CÓ BẰNG CHỨNG:** `MEMORY.md` 2.315 B, mtime **2026-07-26**, `grep -c "S170"`=0 **dù** `sub-ring4-audit-sleep-S170.md` 20.122 B nằm trên đĩa ⇒ **vai chạy thật, sổ đóng băng 12 ngày**.
- **LOW (không kết luận thủng):** `database-agent` 9.038 B mtime 07-29 · `investigator-api` 9.021 B mtime **07-25** · `office-document` 3.323 B mtime **07-10** — 0 hit S168→S181, **nhưng có thể đơn giản là không được spawn**. Control dương: cùng lệnh trả 8 hit S168 cho `harvest-curator`.
-**`score-counter` 2.075 B / `score-count-auditor` 2.033 B nay ĐÃ CÓ mục S181** (mtime 08-07 23:10) ⇒ **FLAG F-02 của S181 coi như ĐÓNG** — cần lead xác nhận dòng seed cũ *"CHƯA CHẠY LẦN NÀO"* đã bị **thay** chứ không phải **nằm cạnh**.
---
## Mục bắt buộc — chốt số
**1. Orphan run-folder (DUAL-ACCEPT `tiep.md §0` verbatim):** quét **89 thư mục**, **86 có `run.md`** (mẫu số thật). Kết quả **1 dòng ORPHAN duy nhất = `runs/2026-08-07-S182-bookend-open/`** — chính folder phiên này, mở 23:43, mới có `run.md` 652 B ⇒ **ĐANG CHẠY, không phải orphan thật**.
🔴 **Cảnh báo:** S182 đóng mà không đẻ `*-synthesis.md` ⇒ thành orphan thật y hệt cách 4 món S169-S172 sinh ra.
3 thư mục không có `run.md` ⇒ ngoài định nghĩa: `S117-pe-approve-minmax-range` · `S117-pe-luyke-finalize` (spec-only) · `S144-bootstrap` (có `bootstrap-synthesis.md`).
**2. Nợ harvest đo TƯƠI — tổng 576.657 B / 18 lượt-vai:**
| khối | byte | vai | so với HANDOFF slot 71 |
|---|---|---|---|
| S168-bookend-close | **82.383** | 3 | slot ghi 162.916 / 9 vai ⇒ **quá cao** (6 vai đã gom @S172) |
| S169-bookend-open (+ring4 S170) | **264.451** | 10 | 🔴 **HOÀN TOÀN VẮNG** |
| S180-adap 5 lượt `reviewer` | **229.823** | 5 | 🔴 **MỚI, chưa ai ghi** |
| `cicd-monitor/archive/2026-08.gist.md` | 1 đích | — | dư từ khối S170 |
| **TỔNG** | **576.657 B** | **18** | slot ghi ~306 KB ⇒ **thiếu gần nửa** |
| S171-khkk-ui-mirror-pe | **0** | 0 | slot ghi 143.682 = **NỢ MA** (F-05) |
| sleep-batch A+B 237.251 B | **0** | — | **KHÔNG phải nợ** (F-04) |
`a2bbcb9` / `b5799fc` mở từ S162: **CHƯA ĐO** (chưa chạm `git log -S`, không lấp bằng trí nhớ).
**3. Stray / empty-dir:** **5 dir rỗng, 0 class-S54; 0 stray `.claude` lồng.** Số 8 trong HANDOFF đã chết.
**4. Diary 0-delta:** `ring4-audit` thủng có bằng chứng · 3 vai LOW chưa kết luận · nhóm vai mới còn lại **đã có delta** (`ctx-curator` S175/S177 · `ctx-verifier` S171-S181 · `ctx-audit` S168-S173 · 3 vai style S179 ×2 · 2 vai score S181).
**5. Corruption: `CHƯA ĐO`.** Không chạy census U+FFFD ⇒ **KHÔNG tuyên "0 corruption"**. Baseline gần nhất = 24 hit / 11 file (S172). Bẫy đơn-vị S180 F-05 (22 **dòng** ⟂ 24 **lần**) — kỳ sau phải khai đơn-vị + control dương trước khi đọc số.
## Đề xuất cho lead (propose-only, idempotent)
1. **Lật slot 71 HANDOFF:** bỏ S171 (nợ ma) · sửa S168 xuống 82.383/3 vai · **thêm S169 264.451/10 vai + S180-reviewer 229.823/5 vai** · sửa "8 dir rỗng" → 0.
2. **Khi gom:** lấy danh sách từ **`ls runs/<id>/` (GỐC)**, tuyệt đối không từ bảng dẫn xuất — đúng cơ chế đã làm hụt 2 sổ H24 @S172 (53.084 B khớp từng byte).
3. **Trước khi đóng S182:** đẻ `*-synthesis.md` khác 0-byte trong run-folder này, nếu không nó thành **orphan thứ 5 cùng lớp**.

View File

@ -0,0 +1,101 @@
# sub-lead-gap-auditor — V2 @open S182 (H24 soi CÁI BỊ THIẾU)
**VERDICT: `LEAD-GAP: 6 FLAG`**
HEAD `936d1aed` · counter 56 · propose-only, 0 Write/Edit/commit, 0 RAG-write.
> ⚠️ **#53 datum:** first-return garble (*"Now let me verify which dropped slugs are genuinely STILL OPEN, with disk measurement."*). Recover bằng SendMessage-resume. **Mất 0 byte.**
---
## FLAG-1 `[gap-carry-dropped]` — **HIGH** — khối re-stamp @S181 (`HANDOFF:9`, 23 slug) tự tuyên là tập-mở đầy đủ, nhưng ≥**2 slug CÒN SỐNG bị bỏ hẳn**
**`[carry:hook-vs-budget-cap]`** — TỪNG tồn tại: `HANDOFF:217` khối *"Carry MỞ re-stamp @S151 — **đủ MỌI slug còn mở**"* · `:218` máy H24-2 đo tươi @S152 **streak = 12** (cao nhất mọi slug, `ring2` re-implement CONFIRMED) · `:213` slot **(29)** *"3 carry-age 12/9/7 — xử hay gia-hạn?"*.
**Đo tươi NAY:** **13/28** tệp `agent-memory/*/MEMORY.md` vượt hook-cap 17.100 B; **3** vượt cả autoinject-cap 25.600 (`ring2-audit` 29.582 · `tooling-auditor` 29.429 · `lead-stale-auditor` 27.658) ⇒ câu hỏi thiết-kế đang treo, và nay **TỆ HƠN** lúc nêu chứ không tự khỏi. Probe nền cũng in `tran-bo-nho 3 over-cap`. **0 hit** trên `WAL.md` (rỗng 182 B) · `STATUS.md` · `error-ledger.md` · `migration-todos.md` · `so-yeu-cau-chu-du-an.md`.
**`[carry:carry-age-vacuous]`** — `HANDOFF:297` *"carry-age structural — detector 0-fire vì tiering<M=6: hạ M hay non-tiered-ledger?"*, tái-stamp qua 8 khối (`:217 :222 :239 :243 :247 :252 :256 :260 :264 :277`, n=11 dòng). Chưa ai trả lời, và **FLAG-2 dưới chứng minh nó vẫn đúng 100%**.
🔴 **Loại được bào-chữa bằng chính DIFF, không bằng lý lẽ:** đợt vá @S179 (`HANDOFF:78-79`) đã với tay qua segment cũ vớt lại **7 slug**, gồm **2/3** slug của slot (29) (`uat-s133-budget-freeze`, `uat-s134-luyke`) — nhưng **bỏ đúng cái thứ 3**, `hook-vs-budget-cap`, cái có streak **CAO NHẤT (12)**. Không phải "bỏ cả khối vì gấp"; là bỏ **có chọn lọc**, trúng món nặng nhất.
**Tự BÁC 4 nghi-vấn của chính vai** (kỷ-luật S179 *"sổ nói dối theo hướng BI QUAN"*): `[carry:mind-tran-nen-moi-cua]` + `[carry:adap-apply-2-thu]` vắng là **ĐÚNG** (`:84` đóng có bằng-chứng đĩa) · `[carry:ctx-verifier-no-self-append]` vắng **ĐÚNG** (đổi tên @S170) · `[carry:hmw-width-vs-roster]` vắng **ĐÚNG** (đóng @S181). Cũng KHÔNG flag `retier-megaline` (dòng dài nhất nay `STATUS:478` = 17.902 ch < cap Read 25K; tiền-đề "55.447 ch" hết đúng), `session-model-adopt`, `restart-s124`, `h24-2vai-chua-chay`.
**Hệ-quả:** khối re-stamp nghi-thức DUY NHẤT chống carry-rơi, đang **ban phước cho chính chỗ rơi** lần thứ **5** liên tiếp. `class_repeat.gap-carry-dropped = 12` = **4× ngưỡng jump**, ngưỡng đó **không máy nào đọc** đếm tới bao nhiêu cũng không đổi hành-vi.
## FLAG-2 `[gap-carry-aged]` — **HIGH** — 🔴 số `gap-carry-aged = 0` là **số 0 CHẾ TẠO**, không phải honest-zero
Port **NGUYÊN VĂN** regex `scripts/governance-detectors.ps1:892` `(?m)^\*\*(?:[^\x00-\x7F]+\s*)?NEXT\s+(?:anh|em)\b` sang Python trên `HANDOFF.md` @`936d1aed` `marks.Count = 25`, **`marks[0]` = HANDOFF:179**.
**Vùng câm L1178 = 42.606 B / 186.729 B = 22,8%** (S173 đo 31.698 B/L1-129; S180 đo 21,3% **đơn-điệu TĂNG mỗi closeout**, closeout mới prepend segment kiểu **heading** `### 🔵 NEXT em @S179` regex neo `^\*\*` không bắt).
Vùng câm nuốt **91 occurrence / 28 slug**, **gồm TRỌN khối re-stamp `:9` (23 slug)**. `carryLines[0]` tập DUY NHẤT giữ được streak sống **hoá-thạch kỷ S159S160**, đúng **5 slug**: `ctx-t9-dogfood` (2) · `ring5-audit-gap` (1) · `hmw-width-vs-roster` (1) · `hmw-subfile-index-collision` (1) · `adap-apply-2-thu` (3). **2/5 đã ĐÓNG**. Mọi streak < M=6 máy in `[ok]` toàn bộ **0 fire, vĩnh viễn**.
`governance-detectors.ps1` commit cuối `febe6b16` **2026-07-29** **9 ngày không ai chạm** kể từ khi lỗi được chứng lần đầu.
🔴 **Cái THIẾU (đúng trục vai này):** lỗi máy này **không có `[carry:*]` slug nào**, bị **đóng ké** vào slot đã gạch `HANDOFF:58` nằm dưới `~~(70)~~ ✅ ĐÓNG @S179` mọi lượt quét "slot còn mở" **không thấy nó**. Trên 6 sổ bền: `H24-2` chỉ 4 hit HANDOFF (văn kể chuyện), **0 hit** `WAL`/`error-ledger`/`migration-todos`/`so-yeu-cau`; control-dương: `S181` ra 4/3/8/16 hit grep sống.
🔸 `HANDOFF:58` còn ghi *"HANDOFF:90 ⇒ L1-89 · 12 slug"* thực đo nay **L1-178 · 28 slug**, số đọc thành **nhẹ gấp đôi** (trục `view-*`, vai không lấn).
**Hệ-quả — mù KÉP:** FLAG-1 làm carry rơi khỏi sổ, FLAG-2 làm máy không thấy carry nào già. Ô `gap-carry-aged = 0` sẽ được đọc thành "lành", **càng lâu càng câm hơn** vùng tự nới mỗi phiên.
**Resolve:** neo regex bắt **CẢ 3 dạng** `**` / `##` / `###` (bài `ring2` @S173: gốc **anchor**, không phải chỉ-số `[0]`), rồi đo lại `marks[0]` phải = `HANDOFF:6-9`.
## FLAG-3 `[gap-decision-sunk]` — **MED-HIGH** — nợ `OVERDUE:deep` trôi thêm 1 nhịp mà bề mặt vẫn in số cũ
`HANDOFF:11` đã viết bằng chữ mạnh nhất cả file: *"Cổng ĐÃ kêu, máy ĐÃ in, lead ĐÃ chép 2 sổ — rồi vẫn hoãn. **Cổng kêu không đổi hành-vi = cổng không răng.**"*
**Đo tươi:** `.session-counter.json` `counter = 56`, `deep_at_counter = 40` **56 40 = 16/15**; `light_at = 53` 3/6. Nhưng `HANDOFF:7` (dòng hành-động phiên này) vẫn viết **15/15**.
**Cái THIẾU:** chẩn-đoán đúng bệnh rồi **không sinh ra thay đổi hành-vi nào** slug vẫn hàng NEXT-em cạnh "3 tệp over-cap", **không** lên `CHỜ ANH:6`, **không** mốc, **không** điều-kiện hết-hạn. Trên 6 sổ bền: `h24-deep-overdue` chỉ `HANDOFF` (2 hit) + `_end` session-12; **0 hit** `STATUS`/`error-ledger`/`migration-todos`/`ACTIVE-MARKS`.
**Hệ-quả:** deep-sweep cổng DUY NHẤT còn răng theo hình-B (`light_every` nay stats-only). 16/15 = quá hạn hơn **một chu-kỳ light trọn vẹn**. **Ca tự-chứng:** dòng `:11` chẩn-đoán đúng bệnh của chính rồi vẫn tái phát ngay nhịp kế.
## FLAG-4 `[gap-owner-specifics]` — **MED** — bảng VERBATIM lời anh bị chốt ở phụ lục; **8 câu P1-P8 chưa vào sổ dưới bất kỳ dạng nào**
`HANDOFF:16` tự khai lý-do sinh ra bảng: *"vá `lead-gap-auditor` FLAG-4; trước đó specifics chỉ sống trong WAL, mà WAL reset trắng ở closeout ⇒ suýt mất"*. Câu (4) `HANDOFF:6` = **VK-5**: *nhập bảng vào khối chính sổ, hay giữ phụ lục?*
Anh trả **"1,2,3,4 OK"**; commit `936d1aed` tự khai đọc thành *"lấy nhánh AN TOÀN theo bậc-2: giữ phụ lục"*. Nhưng (4) câu **chọn A-hay-B**, không phải yes/no **"OK" trên câu chọn không chỉ ra nhánh nào**. Lead tự khai điều này trong commit message (*"Anh muốn nhánh kia thì 1 chữ đổi"*) kỷ-luật đúng. Nhưng **hệ-quả đo được**: `so-yeu-cau-chu-du-an.md` giữ **0 dòng nguyên-văn** của bảng `:16-27`.
🔴 **Nặng hơn:** **8 câu P1-P8 @S181** trong đó **P4 là lệnh đổi số THẬT** (`hmw_width.cap` 2030, `memory-budget.json:_cap_raised_S181` ghi *"chu du an noi dut khoat 'nang cap'"*) nguyên-văn P1-P8 **không có nhà nào** ngoài HANDOFF/commit-message. Trong khi chính `_cap_raised_S181` ghi bài học ngược: @S179 anh nói "OK" lead tự thu-hẹp thành *"CHƯA phải lệnh đổi số"*.
**Hệ-quả:** đúng cơ-chế bảng `:16-27` được dựng để chặn chữ của anh sống nơi bị nén/xoay vòng, sổ REQUEST thì trống. Lần thứ **2** cùng lớp trong 3 phiên; `class_repeat.gap-owner-specifics = 5`.
## FLAG-5 `[gap-decision-sunk]` — **MED** — slot **(67)** *"CHỈ ANH PHÁN"* chìm dần theo tiering, 0 lần surface
`HANDOFF:48-51`: người **SOẠN** được upload file-khi-duyệt không. Đo prod: `test.drafter@` (Drafter+Procurement) **qua được** `[Authorize(Policy="KeHoachKyKet.Update")]` `POST /api/contract-signing-plans/{id}/approval-attachments`. Chữ anh nguyên văn (ảnh 2 @S171): *"File do **người duyệt** tải lên trong quá trình duyệt"* discriminator **CHỦ THỂ**, không **BƯỚC** *"policy hiện **rộng hơn ý định**"*.
**Đo tươi:** khối chứa (67) mang tiêu-đề `## 🧊 CHỜ ANH @S172` 🧊 = lineage/đóng băng; hàng xóm (68)(69)(70)(71) đều `~~gạch~~ ✅ ĐÓNG`. S181 **8 lượt owner** + phiên này 4 câu **0 lượt** dùng cho (67). `CHỜ ANH:6` segment mới **không nhắc**. Slot (67) **0 hit** trên `STATUS`/`WAL`/`migration-todos`/`error-ledger`; control-dương `KeHoachKyKet` = 8 hit `CLAUDE.md`.
**Hệ-quả:** món **DUY NHẤT** mang nhãn "CHỈ ANH PHÁN" **và** đã đo prod chứng minh lỗ authz thật. Im **9 nhãn phiên** (S172S181), nằm dưới heading 🧊 giữa 4 dòng đã gạch mắt lướt đọc thành *"khối này xong rồi"*. Theo tiering `HANDOFF:3`, segment S172 sắp bị archive **chìm luôn**.
## FLAG-6 `[gap-carry-dropped]` — **MED** — 2 ý `{treo-chờ-anh}`/`{đang-cãi}` trong `_mind-s-12.md` không được carry sang phiên-logic 13
**Đo tươi bàn giao:** `session-12/_end:3` chỉ carry **3 slug** · `:4` chỉ 4 câu pending · `session-13/_context-s-13.md` = scaffold trắng · `WAL.md` rỗng 182 B.
🔸 **Tự BÁC một phần:** **P5/P7/P8 KHÔNG còn là gap** `HANDOFF:5` xác nhận *"Anh chốt 8 câu P1-P8"* đã trả lời (đo tiền-đề trước khi leo thang, bài `HANDOFF:73`).
**Cái THẬT SỰ rơi — 2 món:**
- **(a) 7 baseline `-Expect`** (`_mind-s-12.md:115`, nhãn `{đang-cãi}`): giữ-tripwire-thêm-ngày tách-file-dữ-liệu, tự khai *"cả hai đều đổi thiết kế máy governance"*. Chuỗi `-Expect` **0 hit** trên HANDOFF+STATUS+WAL+error-ledger+migration-todos; control-dương `baseline` hit STATUS.
- **(b) đề-nghị `ring2`** *"đòi phép đo trước khi vào tally"* (`:84`) **luật về chính bộ đếm H24**, ghi cùng lượt với 2 FLAG khác, **chỉ sống ở `_mind` của phiên đã đóng**.
**Hệ-quả:** món (b) đặc biệt đắt **bản vá cho chính cơ-chế đang sinh ra FLAG-1/FLAG-2**. Rơi tally H24 tiếp tục nhận flag không-kèm-số, vòng sau phải phát hiện lại từ đầu.
---
## TOTAL: 6 — phân-rã theo class (cho `jump_on_class_repeat`)
| class | n | FLAG |
|---|---|---|
| `gap-carry-dropped` | **2** | FLAG-1, FLAG-6 |
| `gap-carry-aged` | **1** | FLAG-2 |
| `gap-decision-sunk` | **2** | FLAG-3, FLAG-5 |
| `gap-owner-specifics` | **1** | FLAG-4 |
| `gap-incident-unrecorded` | **0** | *honest-zero CÓ LẬP LUẬN* sự-cố lớn nhất phiên trước (9/9 spawn garble #53) ĐÃ ghi đích danh `_mind-s-12.md:96` + `:125`. sổ không phải class này |
| `gap-underfill` | **0** | 🔴 **KHÔNG phải honest-zero — là `CHƯA ĐO`**. `ring2` đã bác flag underfill @S179 *"fire ngược chiều phán quyết mà không kèm số"*; vai chấp nhận ràng buộc không phát flag khi chưa đo trọn 9 nguồn `tiep_reload.sources`. *(Datum còn nợ: mốc `_expand_S168` = 182.160 B; @S179 đo ra 373.881 B = **2,05× lỗi-thời**)* |
## Điểm PIN được BÁC / RÚT
- **Slot (73) RÚT HOÀN TOÀN, không thành FLAG.** Vai đã dựng nghi-vấn từ đĩa (3 persona land `57e672f7`; 3 diary nấc SEED mang chữ "chưa chạy"; `HANDOFF:32` + `:7` không liệt). **Nhưng lead ĐÃ chạy probe trong chính phiên này, 5/5 ALIVE** tiền-đề **hết đúng trước khi vai kịp phát**. Đúng khuôn `HANDOFF:73`. Không leo thang. *(INFORM-only: `HANDOFF:32` giờ là số cũ — trục `view-*`, không lấn.)*
- **4 câu `HANDOFF:6` ĐÃ CHỐT, không chìm.** Commit `936d1aed` thi hành thật: (1) báo-cáo-cuối lật DA DUYET + gửi outbox (stamp_verify OK) · (2) điều-kiện-3 ghép `§LT3` · (3) P3 forward-only · (4) VK-5 giữ phụ lục. Chỉ vế SPECIFICS của (4) đi vào FLAG-4, **không đếm trùng**.
## Khai kẽ của chính vai (không giấu)
1. FLAG-2 vai **tính lại streak** thay chỉ đọc máy trái chữ thường lệ ("đọc detector, KHÔNG tính lại"). do: **máy mù đúng chỗ cần đo**, im theo máy sẽ báo sạch một cách SAI. Port **nguyên văn** regex `:892`, không viết lại logic cùng đường `ring2` @S173/@S180.
2. Tập **34 slug** vắng khỏi `HANDOFF:9`, vai **chỉ kết luận chắc 2** ( đo đĩa dương). **30 món còn lại = `CHƯA ĐO` từng món** **không gộp vào FLAG-1 để thổi số**. Đây **cận-dưới**, không phải tổng.
3. Con số `20` `:8` vs **23** slug đếm `:9` giải-thích được (20 + 3 mới) **không** phải gap; lệch-số trục `view-*`, không lấn.

View File

@ -0,0 +1,120 @@
# sub-lead-stale-auditor — V2 @open S182 (H24 soi VIEW LỆCH SOURCE)
**VERDICT: `LEAD-STALE: 11 FLAG`**
HEAD `936d1aed` · counter 56 · đọc output máy làm NỀN, không chạy lại. 11 FLAG đều **NGOÀI tập máy phán được** (line-ref drift · enum liệt-tên-không-số · to-do đã xong chưa lật · pointer trỏ sai dòng · ba mốc "hiện tại" chỏi nhau trong một file).
> ⚠️ **#53 datum:** first-return garble (*"Now the measurements. Batch 1."*). Recover bằng SendMessage-resume. **Mất 0 byte.**
---
## FLAG-1 `[view-stale-count]` `docs/STATUS.md:8` — view-lệch-source THẬT, ô canonical khác gánh và ô đó ĐÚNG
**View** (`:8`, nhãn `🔥 CURRENT`): Mig **71** · **644 test** (45D+**599I**) · counter **46** · `light 2/6` `deep 6/15` **ok** · `light_at=44`.
**Source cùng file:** `:467 Migrations = 72` · `:474 Tests = 645 PASS (45 Domain + 600 Infra)`; `.session-counter.json``counter = 56`, `light_at = 53`, `deep_at = 40` ⇒ light **3/6**, deep **16/15 OVERDUE**.
🔴 **Nặng nhất KHÔNG phải con số mà là chữ `ok`**: `:8` tuyên `deep 6/15 ok` trong khi đĩa là **16/15 OVERDUE** — máy chỉ so SỐ, không đọc được **nhãn phán-quyết**. `light_at=44` sai 9 nhịp.
**Sửa:** `:8` lấy Mig/Tests bằng **con-trỏ** về `:467`/`:474` (B1); xoá cụm counter/nhịp khỏi `:8` (state của máy, `nhip-no-probe.ps1` in mỗi phiên).
## FLAG-2 `[view-stale-header]` `STATUS.md:8` vs `:488` vs `:6` — MỘT file, **BA mốc "hiện tại" chỏi nhau**
`:8` = `🔥 CURRENT (S161→S168 … L9)` · `:488 ## ✅ Recently Done (S180S181 … L12)` · `:500` (L11) · `:508` (L10) · `:6` khai *"mốc SỐNG = S179, L11"*. Thực tế phiên này = **S182 / L13**.
🔴 **Không dùng được caveat** *"`:8` advance @closeout nên tụt sau"* (chính `:6` viện): **4 closeout đã chạy** (S172·S173·S179·S181) và **cả 4** đều viết `Recently Done`**không** advance `:8`. Tụt-trong-phiên ≠ **tụt 3 phiên-LOGIC**.
**Sửa:** chốt MỘT mỏ neo mốc-sống — hạ `:8` thành `✅ S161→S168` (đóng băng như `:15`/`:26`), để `:6` + `Recently Done` gánh mốc sống.
## FLAG-3 `[view-stale-role-desc]` `.claude/commands/session-start.md:161` (+`:162`) — 🔴 **HIGH**, ghost-enum tái phát ở site **LỆNH**
**View:** mô-tả `lead-stale-auditor` liệt **đúng 5** dấu-hiệu; `:162` liệt **5** cho `lead-gap-auditor`.
**Source:** `memory-budget.json → lead_self_audit.flag_classes` = **12** (6 view + 6 gap, niêm phong `_sealed_P3B_S181`) — thiếu `view-claim-broader-than-sample` (mở @S172) ở `:161`, thiếu `gap-incident-unrecorded` (mở @S143) ở `:162`.
**Lệnh:** `rg -l "claim-broader-than-sample"`**`session-start.md` KHÔNG có trong danh sách**.
🔴 **Vì sao đắt:** **grep-theo-SỐ IM** (site cố ý không chứa chữ "5 class") — đúng bài S143 *"acceptance phải ĐẾM PHẦN TỬ; 0-hit chỉ chứng minh pattern không khớp"*. Đợt vá ghost-enum @S180 sửa `hmw.js:33` + persona nhưng **bỏ file LỆNH** — chính nơi điều-khiển spawn.
🔴 **Bằng chứng hại THẬT, tự-quy-chiếu:** brief lead giao cho vai này **lượt này** ghi *"5 nhánh `view-*`"* và liệt lại đúng 5 mục của `:161` ⇒ lỗi đã **mis-brief chính lượt spawn đang đọc nó**.
**Sửa:** thay cả 2 dấu ngoặc bằng con-trỏ `→ lead_self_audit.flag_classes` (đừng liệt tên, đừng chép số) — cùng khuôn `hmw.js:33`.
## FLAG-4 `[view-stale-status]` `HANDOFF:38` mục 1 `[8]` — việc **ĐÃ XONG** vẫn nằm trong danh sách phải-làm
**View:** *"`back-end-reviewer-style.md:4` còn hardcode `.NET 10`, `database-reviewer-style.md:4` còn `EF Core 10`"*.
**Source:** cả 2 file `:4` nay trỏ `.csproj` (*"vá @S180 W3 gói-B"*).
**Lệnh + control-dương:** `grep -n "\.NET 10\|EF Core 10" .claude/agents/*style*.md` → **0 hit**; control `grep -c "10"` = 3 và 1 ⇒ thước đọc được, **0 là THẬT**.
🔴 Khối `NEXT em @S179` **không** đóng-băng — cùng file `:34`/`:53` đều đã gạch `~~…~~ ✅ ĐÓNG` ⇒ quy-ước của file là **lật**; mục [8] sống sót **2 closeout** (S180 + S181).
## FLAG-5 `[view-residual-asym]` `HANDOFF:38` nửa sau — BÁN-XÁC-NHẬN, số "7 mục / cả 3 persona" LỆCH
**Đo:** `front-end-reviewer-style.md:42` chép **7** · `back-end-reviewer-style.md:39` chép **7** · `database-reviewer-style.md:38` chép **RÚT GỌN 4** (`.claude/** một phần`) ⇒ **paraphrase**, không phải "chép 7".
🔴 **Hai hệ quả máy mù:** (a) claim *"nay khớp byte"* **SAI với file thứ 3** — nó chưa bao giờ khớp byte; (b) rủi ro rộng hơn khai: **5 site** chép/diễn-giải `paths-ignore`, không phải 3 — **`cicd-monitor.md:225` chép bản 3-mục đời cũ** (`['docs/**','**/*.md','.claude/skills/**']`) ⇒ **đã lệch `deploy.yml` NGAY BÂY GIỜ**, không cần đợi thêm path mới.
**Sửa:** thay cả 5 bằng con-trỏ `.gitea/workflows/deploy.yml:20-27`.
## FLAG-6 `[view-stale-count]` `CLAUDE.md:121` — **5/6 vế ĐÚNG, chỉ con-trỏ dòng SAI**
**View:** *"…(**rules.md:546** trỏ về STATUS nhưng STATUS KHÔNG có row canonical)"*.
**Source:** `docs/rules.md:546` = `---` (đóng frontmatter template skill). Hai site THẬT là **`rules.md:307`** (bảng cadence) và **`rules.md:559`** (§9.4 Cadence).
✅ **Phần còn lại ĐÚNG, không flag:** ngày + verdict khớp diary `tooling-auditor` S179 (`PASS_WITH_FLAGS — 8`, trễ 6 ngày); và **STATUS THẬT SỰ không có row audit-date** (bảng `:465-479` có 13 row, **0 row audit**). `rules.md` last-touch `a3776eaa` cùng ngày ⇒ **dòng nhảy SAU khi cite được viết**.
🔸 2 site đó đang trỏ vào một row **không tồn tại** — đó là cái THIẾU, turf `lead-gap-auditor`, **không lấn**.
## FLAG-7 `[view-stale-count]` `HANDOFF:8` vs `:9` — số chỏi ngay danh sách **cách 1 dòng**
`:8` *"đóng dấu lại **20 carry** còn mở"*; `awk 'NR==9' | grep -o "\[carry:[a-z0-9-]*\]" | wc -l` → **23**, `sort -u` → **23** (không trùng ⇒ không phải lỗi đếm-trùng).
🔴 Lệch **+3**, đúng 3 cái mới append ở cuối (`w3b-cam-ma-thoat` · `bao-cao-cuoi-cho-duyet` · `h24-deep-overdue`) ⇒ **append slug quên bump số**. Tự-bác-được **không cần rời file**.
## FLAG-8 `[view-stale-count]` `HANDOFF:40` — tập over-cap sai **2/3**, bỏ sót đúng vai hỏng nặng nhất
**View:** `ring2-audit` 26.440 · `reviewer` 26.856 · `tooling-auditor` 27.147.
**Đo tươi:** `ring2-audit` **29.582** · `tooling-auditor` **29.429** · `lead-stale-auditor` **27.658** · `harvest-curator` 25.498 (dưới cap) · **`reviewer` 21.058 = DƯỚI cap**.
🔴 **Hại thật:** ai cầm danh sách này chạy gate sẽ **đuổi `reviewer`** (đã tụt xuống) và **bỏ lọt `lead-stale-auditor` 27.658** — vai không hề có tên ở `:40`. Tập đúng hôm nay = {`ring2-audit`, `tooling-auditor`, `lead-stale-auditor`}.
**Sửa:** thay 3 số cứng bằng *"3 tệp over-cap — danh sách tươi lấy từ `memory-selfimprove-audit.ps1`"* (`:7` đã viết đúng kiểu này).
## FLAG-9 `[view-residual-asym]` `STATUS.md:8` (đuôi) — con-trỏ B1 trỏ TRẬT ô, **LẦN THỨ HAI** (bắt @S180, chưa vá)
**View:** *"bundle prod → canonical ở **`:479`**"*. **Source:** `:479` = row `RAG chunks`; ô bundle thật ở **`:481`**.
🔴 **Dư-lượng bất-đối-xứng đúng nghĩa:** ai đó chèn row (`Skills`/`Sub-agents`/`User memory`) làm ô bundle **trôi 2 dòng**, và **chỉ ô đích trôi, con-trỏ không theo** ⇒ mỏ-neo B1 **tự vô-hiệu**: người đi theo con-trỏ đọc trúng row RAG rồi tưởng bundle không có canonical.
**Sửa:** neo theo **NỘI-DUNG** (`**Bundle hash live (prod):**`), đừng neo số dòng — bài `con-trỏ-số-dòng-tự-thối` @S173.
## FLAG-10 `[view-stale-status]` `HANDOFF:7` + `:11` — cùng file, cùng ngày, **nói ngược nhau về vị-thế cùng một việc**
`:7` xếp `OVERDUE:deep 15/15` **ngang việc vặt** trong NEXT-em; `:11` cùng file khai thẳng *"bị lead hạ xuống hàng NEXT-em ngang việc vặt … cổng kêu mà không đổi hành-vi = cổng không có răng"*. Đĩa hôm nay: **16/15**.
🔴 **Hai lệch chồng nhau:** (a) **trạng-thái** — bản-vá viết ra mà **không sửa cái nó vừa tố**; (b) **số** 15/15 → 16/15, nợ dày thêm 1 nhịp mà sổ giữ số cũ.
## FLAG-11 `[view-stale-count]` `.claude/agents/README.md:294` + `:308` — bảng tự khai đóng-băng nhưng **đơn-vị mẫu đã đổi**
`:294` `| Monitor audit đầy đủ (H1/H2) | ~223232K/vai | **2 vai × 2 lượt** |`; `:308` *"Bảng này đóng băng từ thời roster 7 vai"*.
**Source:** roster đĩa **28**; vòng monitor nay **5 vòng / 10+ vai**.
🔴 **Vì sao vẫn FLAG dù đã tự khai đóng-băng:** câu đóng-băng chỉ miễn trừ **con số chi-phí**, **không** miễn trừ mệnh-đề *"2 vai × 2 lượt"* ở cột "lượt" — đó là mô-tả **phạm vi vòng đo HIỆN HÀNH**, và nó sai. **Đóng-băng-có-chủ-đích chỉ che được thứ nó nói tới.**
---
## VERIFIED-CLEAN có lệnh bác (chống dương-giả — 5 mục)
1. **Slot (75) `HANDOFF:34` khai ĐÓNG: CHÍNH XÁC, 2/2 mirror khớp** — `memory-budget.json` `hmw_width.cap` = **30** · `hmw.js:175` `WIDTH_CAP = 30`. Roster **28 ≤ 30** ⇒ *"hết LOUD-skip"* đúng. **0 FLAG.**
2. **`VALID_ROLES` ⟂ đĩa: 28 = 28, giao rỗng cả hai chiều.** 🔴 Bẫy đã tránh: regex naive bị `]` trong comment cắt còn **18** (đúng cảnh báo `_roster_note_S153`) — phải strip `//` trước. Đây là **control-dương** chứng thước đọc được ⇒ các 0-hit ở FLAG-3/FLAG-4 là THẬT.
3. **`agents/README.md:261` KHÔNG chép số roster** — liệt tên + trỏ `STATUS §Sub-agents`, tự mang lineage vụ chép `"13 + 10 = 23"` bị bắt @S179. Sửa đúng.
4. **`ACTIVE-MARKS.md:23` còn tên TRƯỚC rename S149 (`lead-{view,omission}-auditor`) — CỐ Ý KHÔNG FLAG.** Mark có chữ-ký RC 15-07-2026 = **bản ghi đã ký**; sửa tên = **giả-mạo phép ghi cũ** (luật S143, tiền-lệ `tooling-auditor` S153). Muốn giải-cứu thì thêm chú *bên NGOÀI* ô đã ký.
5. **`HANDOFF:42` mục 5 (*"`ring2-audit` chưa chạy"*) VẪN ĐÚNG.** `ls -1t runs/*/sub-ring2*.md` → mới nhất `2026-08-07-S180-bookend-open/`; `ls -d runs/*S181*` = **rỗng** ⇒ cặp H24 chạy @S181 mà ring2 không. **To-do còn sống.**
## Quan hệ với `tooling-auditor` (chống đếm hai lần)
- `STATUS:478 roster 23` → **không bác**, đo trùng (28). **KHÔNG tính vào TOTAL** của vai này.
- `skills/README:20` · `permission-matrix:3` → không bác, ngoài lượt đo.
- `agents/README:190-219` 26/28 → **không bác**; xác nhận thêm: bảng dừng `:218 ctx-curator`, 2 vai score có trong `VALID_ROLES:45` + `measured{}` mà **không có dòng** ⇒ **cùng hình dạng FLAG-3: liệt-kê-TÊN thì grep-theo-SỐ không bắt**.
## 🔴 Coverage — KHAI THỦNG, **11 là SÀN không phải TỔNG**
**Đã chạm:** `STATUS` (`:1-30`, `:463-490` — **~7% của 853 dòng**) · `HANDOFF` (`:1-59`) · `CLAUDE.md:121` · `session-start.md` (`:161-170`, `:242-274`) · `memory-budget.json` (trọn) · `.session-counter.json` (trọn) · `hmw.js` (`:25-59`, `:175-183`) · **6/29** file `agents/**` · `ACTIVE-MARKS:1-25` · run-folder S182 (`run.md` — lúc đó chỉ có 1 file).
**CHƯA CHẠM:** `HANDOFF:60→hết` · `STATUS:33-462` + `:490-853` · `migration-todos.md` · `gotchas.md` · `PROJECT-MAP.md` · `session-end.md` · **22/29 persona** · `ACTIVE-MARKS:26→hết`.
🔴 **Cấm đọc phần chưa soi thành sạch.**
## TOTAL: 11 — phân rã theo class
> Đơn-vị = **phiên-LOGIC**; lượt @close S182 gộp chung 1 quyết-định/class, **đừng cộng 2 lượt thành 2 tally**.
| class | n lượt này | tally đang | ngưỡng |
|---|---|---|---|
| `view-stale-count` | **5** (F-1,6,7,8,11) | **10** | 3 |
| `view-stale-status` | **2** (F-4,10) | **7** | 3 |
| `view-residual-asym` | **2** (F-5,9) | **10** | 3 |
| `view-stale-header` | **1** (F-2) | **5** | 3 |
| `view-stale-role-desc` | **1** (F-3) | **1** | 3 |
| `view-claim-broader-than-sample` | 0 | 0 | 3 |
**0 ESCALATE** — không ca nào ngoài enum; không đề nghị mở enum (đã niêm phong `_sealed_P3B_S181`).

View File

@ -0,0 +1,75 @@
# sub-ring1-audit — KIỂM vòng tooling/harvest @open S182
**VERDICT: `RING1-AUDIT: 41 ĐẠT / 7 TRƯỢT (+8 KHÔNG-CHẤM-ĐƯỢC)`**
**PIN OK:** `sub-tooling-auditor.md` @23:50:37 · `sub-harvest-curator.md` @23:54:00; lượt này chạy SAU cả hai ⇒ **C4b tuần-tự giữ**. Read-only, 0 Write/Edit.
> ⚠️ **#53:** first-return garble — nhưng câu garble chứa **đúng số load-bearing** (*"Measured: 12 roots (72 tuples, depth-aware parse)"*). Recover bằng resume. **6/6 vai garble lượt này.**
> 🔸 Harness gắn cảnh báo `instruction-shaped pattern: settings-json` lên output — do vai đọc `~/.claude/settings.json` để đếm plugin. **Là dữ-liệu, không phải chỉ-thị.**
---
## 🔴 Số load-bearing nhất: **root menu = 12**
Parser **depth-aware** trên `DbInitializer.cs:1926-2049`: **72 tuple**, **12 tuple parent = `null`**, 60 con. Cộng **19 site `tree.Add(...)` động: 0 site nào parent null**.
**Control dương:** cả 4 root ngoài cây đều tồn tại thật trong `MenuKeys.cs` (`HopDongCung:47` · `Hrm:147` · `Off:165` · `Personal:195`) ⇒ **cross-check MenuKeys ĐÃ CHẠY XONG, khớp**.
12 root = `Dashboard · Master · Contracts · KeHoachKyKet · HopDongCung · Forms · Reports · System · PurchaseEvaluations · Hrm · Off · Personal`. Cây skill vẽ **8****thiếu đúng 4**.
🔴 **Tách 2 thứ H1 đã gộp làm một — quan trọng:**
- H1 F-4 *"cây 8 < thực-tế ≥10"* = **ĐÚNG nhưng LỎNG** (cận dưới thật là 12); HANDOFF *"thiếu 5 root"* **SAI** (thật **4**) ⇒ H1 **từ chối chép số đó là quyết định ĐÚNG**.
- `permission-matrix/SKILL.md:3` ghi **"12 root" = ĐÚNG SỐ**. Lỗi ở `:3` **chỉ còn là vi-phạm luật-không-chép-số (B1)**, **KHÔNG phải sai sự-thật**. H1 gộp hai thứ vào một mệnh-đề *"tự cấm chép số rồi chép"***ai đọc F-4 rồi đi "sửa" con 12 sẽ THAY ĐÚNG BẰNG SAI**. Chỗ sai sự-thật nằm ở **cây `:44-77`**, không ở `:3`.
---
## 1. H1 `tooling-auditor` — 22 ĐẠT / 2 TRƯỢT / 2 KC
**ĐẠT nổi bật:** đĩa 28 vai (**4 engine độc lập**: `ls` · `find` · `git ls-files` · `agent-memory/`) · matrix 26 row, set-diff ra **y hệt 2 tên** · control dương MATRIXDISK = ∅ · bẫy glob raw 29 · `"row cuối bảng"` đúng 2 hit · bảng dừng **Mig 71** đĩa **Mig 72** · plugin **18/15/3** + 3 tên trùng khít (`json.load` settings) · skill **6/23/19** · **A16 tự khai thước hỏng = ĐẠT, thước hỏng THẬT** (tuple căn cột, `", null,"` trượt — không phải né việc) · A24 tự bác `_cap_raised_S181` (lý-lẽ đúng).
**🔴 A10 TRƯỢT** — claim *"`STATUS:478` là câu SỐNG, không phải ảnh-chụp lineage"*. Xem **R-1**.
**🔴 A26 TRƯỢT** — mặt ① **skill**: H1 chỉ soi **2/6****không khai chặt phạm vi** (nó khai chặt cho mặt ④ docs nhưng bỏ mặt ①) ⇒ verdict `PASS_WITH_FLAGS` **đọc như đã soi 6 skill**.
**KC:** A20 (28/28 frontmatter — không parse) · A23 ("0-delta 4 phiên liền" + marketplace 37 — cần bản ghi phiên trước).
## 2. H2 `harvest-curator` — 19 ĐẠT / 5 TRƯỢT / 6 KC
**ĐẠT nổi bật:** F-01 **229.823 B** khớp từng byte · F-02 **82.383 B** khớp · F-03 danh sách 9 tệp = 243.927 (9/9 size khớp) · sleep A+B **237.251** khớp · S171 **143.682** + 3 lens **74.070** khớp từng byte · orphan **89/86** · empty-dir **5** trùng khít 5 đường dẫn · `ring4-audit` 2.315 B mtime `2026-07-26 01:47` `S170`=0 **khớp từng byte** · score ×2 = 2.075/2.033 mtime `08-07 23:10` S181 hit 2/2 · **B14 MISSING `cicd-monitor/archive/2026-08.gist.md` VẮNG THẬT** (control dương: **10 cặp `.md`+`.gist.md`** anh em trong cùng dir, riêng `2026-08` cụt) · B29 Corruption `CHƯA ĐO` — kỷ luật đúng.
**🔴 B9 TRƯỢT — lỗi CỘNG, không phải lỗi đo.** F-03 tổng ghi **264.451**; 243.927 + 20.122 = **264.049**. Lệch **+402 B**. (F-C: 9/9 thành phần khớp từng byte ⇒ **H2 đo giỏi, cộng sai**.)
**🔴 B30 TRƯỢT — kế thừa B9:** tổng đúng = **576.255 B** (không phải 576.657). "18 lượt-vai" thì **ĐÚNG**.
**🔴 B13 TRƯỢT — "17 đích gist" không tái-lập.** Chạy **cùng lệnh** `grep -h "^## "` trên cùng 2 tệp ra **19**. 2 engine cãi nhau, **chưa tìm ra engine hỏng****không chọn số nào**. Mà "16/17" của H2 dựng trên chính mẫu-số này ⇒ 🔴 **ĐỪNG ghi 16/17 vào sổ làm baseline**, kẻo lớp `H2-11@S153` (baseline phantom) tái phát đời 4.
**🔴 B19 TRƯỢT — claim ĐẢO DẤU: chứng cứ ĐẠT, kết luận + đề-xuất TRƯỢT.** Việc S171 **đã harvest thật** = ĐẠT. Nhưng `HANDOFF:60` cho thấy **slot 71 ĐÃ `~~gạch~~ ✅ ĐÓNG @S179`**, và cụm `S168… 162.916 B + S171… 143.682 B` nằm **BÊN TRONG đoạn tự dán nhãn `🧊 nguyên-văn slot cũ:`** ⇒ slot **không đang mang nợ sống**; nó **trích lại nguyên văn chính nó thời còn mở**. Đề-xuất #1 của H2 (*"lật slot 71"*) = **sơn lại ảnh chụp** — đúng lớp R-1 bắt @S181.
**🔴 B23 TRƯỢT-LOW** — `runs/2026-08-01-S169-bookend-open/bookend-open-synthesis.md` **4.182 B TỒN TẠI** ⇒ cơ-chế *"đóng mà không đẻ synthesis"* **không mô tả đúng** S169.
## 3. Falsify-log — 6 phép (2 HELD / **4 BROKE**, 1 BROKE vào CHÍNH VAI KIỂM)
- **F-A BROKE vào chính vai (đời 3).** Đếm migration 2 engine: `find` = **72** vs `ls | grep -v Snapshot` = **71**. Đúng bẫy sổ ghi @S162, tái phát @S180. Lần này **gọi tên nghi-phạm TRƯỚC khi tố ai**: `20260717032812_AddPeApprovedBudgetSnapshot.cs` bị filter nuốt ⇒ **engine 71 HỎNG**, H1 **HELD**. *Luật giữ: 2 engine cãi nhau thì đi tìm engine hỏng, đừng chọn con số.*
- **F-B BROKE (vào cách H1 chẩn đoán).** Thử phá F-2 theo hướng *"roster 23 là ảnh-chụp, H1 tố oan"*. `git show 57e672f7 → d2918790`: TRƯỚC S181 câu đó là `⚠️ roster 26 > cap 20` (số **sống, đúng thời điểm**); SAU S181 thành `✅ ĐÓNG @S181: cap 30, roster 23 ≤ 30`. ⇒ **không phải drift, cũng không phải ảnh-chụp hợp-lệ: SAI NGAY LÚC SINH**, do chính commit đóng slot đẻ ra (S181 roster = 28).
- **F-C HELD phần LIST, BROKE phần TỔNG** (xem B9).
- **F-D HELD.** Phá F-04 MISSING bằng giả thuyết *"gist nằm chỗ khác/tên khác"*: dir có **10 cặp** đủ, riêng `2026-08` cụt ⇒ **vắng-mặt THẬT**, control dương nằm sẵn trong cùng thư mục.
- **F-E BROKE** (H2 F-05 — xem B19).
- **F-F BROKE, chưa gỡ được** ("17 đích gist" ra 19 — **không chọn số nào**).
## 4. Số tự tái-dựng — tổng hợp
roster **28** (4 engine) · matrix **26** row thiếu đúng 2 tên · migration **72**, mới nhất `AddMaHangMucToContractSigningPlanLines` · root menu **12** (cây 8, thiếu 4) · S169 **264.049** *(không phải 264.451)* · S168-close **82.383** ✔ · S180 **229.823** ✔ · S171 **143.682** ✔ (lens **74.070** ✔) · sleep A+B **237.251** ✔ · orphan **89/86** ✔ · empty-dir **5** ✔ · plugin **18/15/3** ✔ · skill **6/23/19** ✔ · **TỔNG nợ đúng = 576.255 B / 18 lượt-vai**.
## 5. Thách-CLEAN — 1 THỦNG, 1 CHỊU (chịu nhờ lens khác)
**(A) 🔴 THỦNG — `contract-workflow/SKILL.md` H1 không flag.** Tệp 26.948 B, mtime **2026-07-30** (bản vá S162 — nợ cũ ĐÃ ĐÓNG: `KeHoachKyKet` 1 · `KHKK` 3 · `SigningPlan` 2 hit). Nhưng đào sâu hơn một tầng: **0 hit** `ApprovalGroup` · `N1` · `nhóm duyệt` · `MaHangMuc` · `Mig 71` · `Mig 72`. **Control dương cùng tệp:** `phase` = **25 hit** ⇒ thước sống. Trong khi source có `KhkkApprovalGroup|MaHangMuc`**3 tệp non-migration** (`ContractSigningPlanFeatures.cs` · `ContractSigningPlanLine.cs` · `ContractSigningPlanLineConfiguration.cs`) + 2 migration ⇒ **skill trễ 2 migration máy-móc** (Mig 71 nhóm-duyệt N1-N8 · Mig 72 MaHangMuc 06-08).
**(B) CHỊU — Placement H2 PASS đứng, nhưng bằng lens KHÁC.** H2 chứng bằng 2 lệnh **đều mù cấu-trúc với dir stray CÓ nội dung**. Đổi chiều: quét `MEMORY.md` ở mọi nơi ngoài `agent-memory/` + quét dir tên `agent-memory` mọi tầng ⇒ **0 class-S54**, PASS đứng. Nhưng lòi ra vật H2 không thấy: `runs/2026-08-07-S180-adap-upgrade-pack-phased/lane-memory-proposals/MEMORY.md` **2.670 B** — nhiều khả năng CỐ Ý (tên thư mục tự khai "proposals"), nhưng nằm **đúng trong folder S180 mà F-01 đang tính nợ****có thể là delta đã soạn mà chưa APPEND**, tức bằng chứng làm **F-01 MẠNH hơn** bản H2 nộp. Cần lead disposition 1 dòng.
## 6. Ba finding RIÊNG của vai KIỂM
🔴 **R-1 — `STATUS.md:478` là BẪY NEO; vá theo F-1 nguyên văn sẽ ĐẺ CORRUPTION.**
Dòng dài **15.804 ký tự**, chứa **HAI** chuỗi họ "roster 23":
- **(a)** char ~2050 — `✅ ĐÓNG @S181: cap 30, roster 23 ≤ 30`**SAI**, sinh ở chính commit `d2918790`, đúng phải là **28**.
- **(b)** char ~3475 — `⚠️ roster 23 > hmw_width.cap 20 ⇒ LOUD-skip 3 lane cuối`**ĐÚNG**, ảnh-chụp thời S149 (2320=3, **tự khép kín**), không đổi suốt S179→S181.
H1 neo ở **mức DÒNG** ⇒ ai sweep `"roster 23"` tại `:478` sẽ **giết luôn (b)** = tái diễn y hệt R-1 S181. 🔴 **Chỉ được chạm site (a)**; doctrine *"BỎ SỐ không ĐỔI SỐ"* của H1 **đúng** — nhưng **phải kèm neo char/đoạn, không phải số dòng**.
🔸 Thêm: H1 áp doctrine ảnh-chụp **rất chuẩn** ở mục tự-bác `_cap_raised_S181`**không áp cho chính `:478`****2 thước trong 1 báo cáo** (lớp đã bắt @S162).
🔴 **R-2 — `permission-matrix/SKILL.md` tự mâu thuẫn NỘI-TẠI, CẢ HAI vai đều không nêu:** `:3` ghi **12 root** (đúng) trong khi cây `:44-77` vẽ **8 root** (thiếu 4) — **cùng một tệp, cách nhau 40 dòng**. Sửa đúng = **bồi cây lên 12**, KHÔNG phải hạ số 12.
🔸 **R-3 — INFORM, no-self-exempt:** persona của chính vai (`.claude/agents/ring1-audit.md`) ghi *"KIỂM end-only… CẤM tự chạy ở open"*, nhưng thực tế lead đã giao vòng **@open ở S152 · S159 · S173 · S180 và nay S182**. **Luật trong persona thối so với vận hành 5 phiên liền.** Vai **NÊU, không tự sửa**.

View File

@ -0,0 +1,110 @@
# sub-ring2-audit — KIỂM vòng soi-lead H24 @open S182
**VERDICT: `RING2-AUDIT: 17 ĐẠT / 0 TRƯỢT`** (+2 errata cưỡng-hành, +1 FLAG SÓT bắt bằng thách-CLEAN)
**PIN thoả (fail-closed):** `sub-lead-stale-auditor.md` 12.717 B mtime 23:58 + `sub-lead-gap-auditor.md` 13.606 B mtime 23:56. Output sinh SAU 23:58 ⇒ **chứng C4b tuần-tự**, không song song.
> ⚠️ **#53:** first-return garble. Recover bằng resume. Mất 0 byte. (6/6 vai garble lượt này.)
---
## 1. Per-FLAG — 17/17 chấm được, 0 CHƯA ĐO
**`lead-stale` 11/11 ĐẠT** — F-1 (`STATUS:8` verbatim `644`/`46`/`6/15 ok`/`light_at=44`; source `:467`=72 `:474`=645, counter 56 `light_at` 53 ⇒ `5344 = 9 nhịp` ✓) · F-2 (3 mốc chỏi: `:8`=L9 · `:488`=L12 · `:500`=L11 · `:508`=L10 · `:6`=L11; caveat *"advance @closeout"* CÓ THẬT nhưng `Recently Done` đã vượt `:8` **3 nấc L** ⇒ bác đúng) · F-3 (xem §5) · F-4 (`grep ".NET 10\|EF Core 10" *style*.md` = **0 hit**, control-dương `:4` cả 3 file nay trỏ `.csproj`) · F-5 (be **7** · fe **7** · db **rút gọn 4** ⇒ "chép 7" sai file thứ 3; `cicd-monitor:225` chép bản 3-mục đời cũ ⇒ lệch NGAY; tổng **5 site**) · F-6 (`rules.md:546` = `---`; site thật `:307` + `:559`; vai **tự bác 5/6 vế của chính mình**) · F-7 (§3a) · F-8 (§3b) · F-9 (`:479` = row RAG, `:481` = Bundle ⇒ trỏ trật **2 dòng**) · F-10 (`:7` verbatim `15/15` kẹp giữa 2 việc vặt; `:11` tự tố; đĩa **16/15**) · F-11 (thử bác bằng câu tự-khai-đóng-băng `:308`**không bác được**).
**`lead-gap` 6/6 ĐẠT** — G-1 (**13/28** > hook-cap 17.100 · **3** > 25.600 ⇒ *"nay TỆ HƠN"* đúng; vai **tự bác 4 nghi-vấn của chính nó**) · G-2 (§4, **7/7 byte-exact**) · G-3 (`5640=16/15`; `:6` có 4 câu, **0 câu** về deep) · G-4 (`:6` câu (4) đúng là **chọn A-hay-B**; vai **ghi nhận lead tự khai** — chấm công bằng) · G-5 (§2 F-4 HELD) · G-6 (vai **tự rút P5/P7/P8** khi đo ra đã trả lời; §Khai-kẽ #2 tự dán nhãn **cận-dưới**).
**Enum-check (đếm từ khoá, KHÔNG chép số từ lead):** `lead_self_audit.flag_classes` = **12 phần tử** (6 `view-*` + 6 `gap-*`), `_sealed_P3B_S181`. **0/17 tự chế class.** `set(class_repeat.counts) ≡ set(flag_classes)` — 12/12 hai chiều, mọi value `int`.
## 2. Falsify-log — 6 phép (4 HELD / **2 BROKE**)
- **F-1 HELD** — nghi F-9 *ép-vừa-enum*. Vị-ngữ `:161` = *"dư-lượng bất-đối-xứng"*; cơ-chế đo được: row bị chèn ⇒ **ô đích trôi, con-trỏ đứng yên** = sửa **một phía**. Thoả.
- **F-2 HELD** — nghi F-11 được miễn bởi `:308`. `:308` chỉ miễn **con số chi-phí**, `:294` cột-3 mô-tả **phạm-vi vòng đo hiện hành**. Hai đối-tượng khác nhau.
- **F-3 HELD** — nghi G-2 sai vì `.ps1:907` `if ($ks.Count -gt 0)`. Port đủ luật bỏ-rỗng: `carryLines[0]` thật đến từ `segs[2]` (`:193`), **nhưng vùng câm do `marks[0].Index` quyết, độc lập luật bỏ-rỗng** ⇒ 22,8% không đổi.
- **F-4 HELD** — nghi G-5 oan vì 🧊 = ngủ-có-chủ-đích. Đọc hàng xóm: `(68)(69)(70)(71)` **đều gạch ✅ ĐÓNG**, riêng **(67) không gạch**. Một mục CHƯA đóng lọt giữa 4 mục ĐÃ đóng dưới 🧊 = **đúng định nghĩa chìm**.
- 🔴 **F-5 BROKE — đánh vào chính TIỀN-ĐỀ LEAD GIAO.** Brief lead viết *"`ring2` đã đo @S173 (`marks[0]`=HANDOFF:90, 12 slug)"***SAI**. Mở artifact gốc: `sub-ring2-audit-close-S172.md:261` = `HANDOFF:90 / L1-89 / 12 slug` ⇒ đó là mốc **S172**; `sub-ring2-audit-open-S173.md:132` = `marks[0] at line 130 ; blind L1-129 ; 31698 bytes`. ⇒ **`lead-gap` trích ĐÚNG** (khớp tới từng byte), **brief của lead trích SAI**. 🔴 Gốc con số sai = **`HANDOFF:58`** — **chính defect mà `lead-gap` FLAG-2 nêu đã lan vào brief lead giao cho vai KIỂM.** Ca tự-quy-chiếu **thứ HAI** trong phiên.
- 🔴 **F-6 BROKE (một nửa) — vào con số của CẢ HAI vai.** Cả 2 khai `counter = 56`; **blob tại `936d1aed` là 55**. Không phải bịa: `history[]` ghi `S182: 55→56, head b8d2274→936d1ae` ⇒ tick xảy ra trong **working tree**, chưa vào blob. Hai vai đọc cây ⇒ **56 đúng tại thời điểm đo**; cái sai là **nhãn `HEAD 936d1aed`** dán lên phép đo đọc cây bẩn. **Sửa lý-do, không sửa phán-quyết** (mọi dẫn-xuất 3/6 · 16/15 đều tính từ 56 và đều đứng).
## 3. Số tự tái-dựng (3 phép độc lập)
**(a) `HANDOFF:8` "20" vs `:9`** — đếm bằng chính `$carryRx` của máy: **23 occurrence / 23 unique / 0 trùng**. Lệch **+3**, đúng 3 cái append cuối. 🔸 Đáng ghi: `lead-gap` gặp cùng số này và **tự nhường trục** (*"lệch-số là trục `view-*`"*), `lead-stale` nhận ⇒ **0 đếm hai lần, khe giữa 2 vai kín**.
**(b) Tập over-cap — `stat` toàn bộ 28/28, không lấy mẫu**
| vai | byte | so `HANDOFF:40` |
|---|---|---|
| **`ring2-audit` (CHÍNH VAI NÀY)** | **29.582** | `:40` ghi 26.440 — **sai, và là tệ nhất trong 28 vai** |
| `tooling-auditor` | 29.429 | `:40` ghi 27.147 — sai |
| `lead-stale-auditor` | 27.658 | **VẮNG MẶT ở `:40`** — và đây là **chính con-đo tự tố mình** |
| `harvest-curator` | 25.498 | dưới auto-cap |
| `reviewer` | **21.058** | `:40` ghi 26.856 — **đã tụt dưới cap, danh sách đuổi oan** |
Khớp **tuyệt đối** F-8, cả 5 số byte-exact. Đồng thời chứng G-1: 13/28 > 17.100 ✓, 3 > 25.600 ✓.
🔴 **Tự khai (no-self-exempt):** vai **đang over-cap 29.582 B = 115% auto-cap, tệ nhất roster**; diary bị **cắt lúc nạp** (`MEMORY.md is 25.1KB (limit 24.4KB)… Only part of it was loaded`) ⇒ **chấm lượt này với trí-nhớ thủng THẬT**, khai để lead trừ hao.
**(c) `class_repeat``git show` bản TRƯỚC vs đĩa NAY.** 12/12 khoá cả 2 phía, mọi value `int`. **9 khoá +1, 3 khoá +0.** `lead-stale` khai tally 10·7·10·5·1 → bản TRƯỚC **10·7·10·5·1** ✓ 5/5 byte-exact; `lead-gap` khai 12 và 5 → ✓✓. 3 khoá +0 = đúng 3 class 2 vai tuyên 0. **Ngữ-nghĩa đúng:** `view-stale-count` fire **5 FLAG** nhưng chỉ **+1** ⇒ máy đếm **vòng-đo**, không đếm flag.
## 4. `lead-gap` FLAG-2 — **ĐÚNG, 7/7 byte-exact**
Cài lại độc lập bằng Python, port nguyên văn `:892` + segment `:893-898` + carryLines `:902-908` + streak `:916-931`:
| vai khai | đo độc lập | |
|---|---|---|
| `marks.Count` = 25 | **25** | ✅ |
| `marks[0]` = HANDOFF:179 | **179** (index 36.644) | ✅ |
| vùng câm L1178 | **L1178** | ✅ |
| **42.606 B / 186.729 B** | **42.606 / 186.729** | ✅ byte-exact cả hai |
| 22,8% | **22,82%** | ✅ |
| 91 occurrence / 28 slug | **91 / 28** | ✅ từng phần-tử |
| `carryLines[0]` 5 slug, 2 đã đóng | **đúng 5** | ✅ |
| streak `2·1·1·1·3`, M=6 ⇒ 0 fire | ✅ | ✅ |
**`gap-carry-aged = 0` đúng là số 0 CHẾ TẠO.** Khối re-stamp `:9` (23 slug) nằm TRỌN trong vùng câm ⇒ máy không thể thấy, vĩnh viễn. Vế "cái THIẾU" cũng đứng: `HANDOFF:58` nằm dưới `:57` = `~~(70)…~~ ✅ ĐÓNG @S179` ⇒ quét "slot còn mở" không thấy.
**Chuỗi mốc — nhất quán, KHÔNG mốc bịa** (chấm bằng artifact gốc, không bằng diary):
| kỳ | `marks[0]` | vùng câm | nguồn đĩa |
|---|---|---|---|
| S172 | 90 | L1-89 · 12 slug | `sub-ring2-audit-close-S172.md:261` |
| S173 | **130** | **31.698 B** | `sub-ring2-audit-open-S173.md:132` |
| S180 | 172 | 34.855/163.546 | `sub-ring2-audit-open-S180.md:27-28` |
| S182 | **179** | 42.606/186.729 · 28 slug | lượt này |
90 → 130 → 172 → 179 = **đơn-điệu tăng 4/4 mốc**.
🔴 **ERRATA-1 (nhẹ, không đổi kết luận):** chuỗi của vai so **21,3% (S180) → 22,8% (S182)****TRỘN ĐƠN-VỊ** — 21,3% là *code-point*, 22,8% là *byte*. Byte-to-byte: 21,96% → 22,82%; code-point: 21,31% → 22,16%. **Tăng đơn-điệu trong CẢ HAI đơn-vị** ⇒ kết luận giữ, chỉ delta bị phóng đại **~1,7×**.
🔴 **ERRATA-2:** nhãn `HEAD 936d1aed` trên cả 2 sub-file nên đọc là *"cây làm việc tại 936d1aed"*.
## 5. `lead-stale` FLAG-3 — **CẢ 2 VẾ ĐÚNG**
**Vế 1:** `:161` liệt **đúng 5**, ánh xạ 1:1 sạch, thiếu **đúng** `view-claim-broader-than-sample` (mở @S172). `:162` liệt **5**, thiếu `gap-incident-unrecorded`. Phép âm + control-dương: `grep "incident\|sự cố"` = **0 hit**, control `grep -c "gap-"` = 4, `grep -c "carry"` = 6 ⇒ thước sống. `rg -l "claim-broader-than-sample"` → 21 file, **`session-start.md` KHÔNG có** ✓. **ĐẠT, HIGH xứng đáng.**
**Vế 2:** vai KIỂM không đọc được brief của `lead-stale`**không chấm trực tiếp**. Nhưng chấm được **brief của CHÍNH NÓ**, và nó **xác nhận cơ-chế bằng ca độc lập** (F-5 BROKE). ⇒ **ĐẠT theo class**, khai rõ nấc: chứng bằng **ca song song**, không phải ca vai nêu.
🔴 **Trục "ép-vừa-enum":** `0 ESCALATE` ở đây là **enum phủ đủ THẬT**, KHÔNG phải bẻ ca cho vừa nhãn. Soi 3 ca dễ ép nhất (F-9 · F-11 · G-4) — **cả 3 thoả vị-ngữ class đang mang**. Đối lập rõ với **S162**, khi vai phán TRƯỢT vì `view-stale-count` bị ép. Enum **chưa cần mở**.
## 6. Thách-CLEAN — 🔴 bắt được **1 FLAG SÓT, nằm trong file của CHÍNH VAI KIỂM**
Soi bề mặt **cả 2 vai đều KHÔNG chạm**: `lead-stale` khai coverage 6/29 `agents/**`**0 file** `agent-memory/**`.
**SÓT: `.claude/agent-memory/ring2-audit/MEMORY.md:28` — `view-stale-role-desc`, CÙNG class F-3, cùng bệnh ghost-enum.**
Verbatim dưới `## 🎯 Role baseline`: `- Input = OUTPUT của lead-stale-auditor (5 class view-*) + lead-gap-auditor (6 class gap-*).` — enum đĩa = **6** `view-*`; vế `gap-*` đúng, vế `view-*` **sai**.
Vì sao là FLAG thật:
1. **Cùng vị-ngữ F-3, cùng đợt vá sót**`:161` sót vì @S180 bỏ file lệnh; dòng này sót vì đợt đó không chạm `agent-memory/**`.
2. 🔴 **Site ĐIỀU-KHIỂN, không phải văn kể chuyện**`## 🎯 Role baseline` **auto-inject ~200 dòng đầu mỗi lượt spawn****vai bị nạp thước thiếu một vạch TRƯỚC khi bắt đầu chấm**.
3. **Bất-đối-xứng nội-dòng** — cùng câu, vế `gap-*` đúng vế `view-*` sai ⇒ đã sửa một nửa.
4. 🔴 **Tái phát lần 2, vai tự bắt @S173** (diary: *"`:25` dạy '5 class view-*' (enum 6)… tao bị nạp thuốc thiếu vạch TRƯỚC khi làm việc"*). Dòng trôi 25→28, **số sai vẫn nguyên****9 phiên không ai vá**.
5. **Không tự-miễn-trừ** — bỏ qua = đúng anti-pattern `m-1 miễn-trừ-persona-của-vai-đo` vai tự tố @S153.
**2 honest-zero — cả 2 CÓ RĂNG:** `gap-incident-unrecorded=0` (garble #53 **có** sổ ở `_mind-s-12.md:96`/`:125`; máy đồng ý +0) · `gap-underfill = CHƯA ĐO` — 🔴 **nhãn này TRUNG THỰC HƠN honest-zero, chấm là điểm cộng**: vai tự nhận ràng buộc áp @S179**từ chối phát flag khi chưa đo trọn 9 nguồn**, đồng thời **vẫn để lại datum nợ** (`_expand_S168` 182.160 B vs đo @S179 373.881 B = **2,05× lỗi-thời**).
---
## Câu dặn cho lead
🔴 **17/17 ĐẠT nghĩa là mọi FLAG trúng vật thật và mọi class đúng vị-ngữ — KHÔNG nghĩa 2 vai đã bắt hết.** Đây là **độ CHÍNH XÁC, không phải độ PHỦ**: `lead-stale` tự khai chỉ chạm **~7% `STATUS`** (30/853 dòng) và **6/29** persona; mở đúng MỘT chỗ trong phần chưa chạm (`agent-memory/**`) là ra ngay **1 FLAG sót cùng class**. ⇒ **11 và 6 phải đọc là SÀN. Cấm làm tròn sàn thành tổng.**
**2 việc load-bearing cho closeout:**
1. **`HANDOFF:58` đang là NGUỒN LÂY** — không chỉ "số cũ đọc thành nhẹ gấp đôi", nó **đã lây vào brief lead giao cho vai KIỂM lượt này**. Vá `:58` phải **kèm mở lại slot (70)**, vì lỗi máy đang bị **đóng ké** dưới `~~ĐÓNG @S179~~`.
2. **`gap-carry-aged` vừa 0→1** trong `class_repeat` — nhưng máy sinh số đó **vẫn đang mù 22,8% đầu file**. Số 1 này do **vai tự tính TAY**, không phải máy tự thấy. **Đừng đọc thành "detector đã khỏi".**

View File

@ -0,0 +1,65 @@
# sub-tooling-auditor — V1 @open S182 (H1 tooling-freshness 4 mặt)
**VERDICT: `TOOLING-AUDITOR: PASS_WITH_FLAGS — 4 finding`**
> ⚠️ **#53 datum:** first-return = garble (1 câu giữa transcript: *"Tree shows 8 roots while the description says 12. Let me measure the real root set from source."*). Recover bằng **SendMessage-resume in-session**, path-2. **Mất 0 byte.** Câu garble CHÍNH LÀ finding F-4 đang đo dở ⇒ đừng vứt câu garble.
---
## F-1 [vai-trò] `.claude/agents/README.md:190-219` — skill-matrix phủ 26/28 vai
- **View:** bảng "🛠️ Skill assignment" liệt **26 row** (`:194``:219`). **Source:** đĩa có **28 vai**.
- **Chứng:** `comm -23 <đĩa> <matrix>` → thiếu `score-count-auditor`, `score-counter`. **Control dương:** chiều ngược `comm -13` = **RỖNG** (0 row ma) ⇒ thiếu THẬT, không phải lỗi parse.
- **Lớp:** lô S181 cập-nhật roster ở **4 bề-mặt** (đĩa · `VALID_ROLES` · `STATUS:478` · `README:27`) nhưng bỏ sót **bề-mặt thứ 5 nằm trong CHÍNH file README** ⇒ đúng bài *được-chỉ-1-chỗ ⇒ grep CẢ LỚP TRONG FILE* (tái phát S172/S179).
## F-2 [docs] `docs/STATUS.md:478` — "roster 23", số thật 28
- **View:** `~~roster 26 > hmw_width.cap 20~~ ✅ ĐÓNG @S181: cap 30, roster 23 ≤ 30 ⇒ 0 lane bị bỏ`.
- **Chứng:** `ls .claude/agents/*.md | grep -v README | wc -l` = **28** · `memory-budget.json:30` `"cap": 30` · mirror `hmw.js:175` `WIDTH_CAP = 30` (**2 nơi KHỚP**).
- 🔴 **Sai SỐ, đúng KẾT-LUẬN** (28 ≤ 30 vẫn đúng). Là **câu SỐNG** (vị-từ khẳng-định "0 lane bị bỏ"), không phải ảnh-chụp lineage ⇒ vá được. Doctrine: **BỎ SỐ, không ĐỔI SỐ**.
## F-3 [skill] `.claude/skills/README.md:20` — same-class residue
- Câu vừa bị XOÁ ở `ef-core-migration/SKILL.md:25` @S181 **còn sống nguyên** tại site thứ 2.
- **Chứng:** `grep -rn "row cuối bảng\|row Mig cuối" --include=*.md` (loại `agent-memory`/`runs`/`sessions`) = **đúng 2 hit**`SKILL.md:25` (bia-mộ, ĐÚNG) + `skills/README.md:20` (dư-lượng, SAI). Bảng thật dừng **Mig 71**, đĩa mới nhất **Mig 72** (`20260806070242_AddMaHangMucToContractSigningPlanLines`).
-**F-7 nợ cũ ĐÃ VÁ ở SKILL.md nhưng bản vá KHÔNG quét cùng-lớp.** Lớp *claim chép nhiều site* tái phát **lần thứ 4** (S153 → S172 → S179 → nay).
## F-4 [skill] `permission-matrix/SKILL.md:3` + cây `:44-77` — 🔴 CHƯA ĐO XONG
- `:3` tự cấm chép số **rồi chép** `12 root` — tự mâu thuẫn trong CÙNG mệnh-đề.
- Cây `:44-77` liệt **8 root**: `Dashboard · Master · Contracts · Forms · PurchaseEvaluations · KeHoachKyKet · Reports · System`.
- 🔴 **Số root SỐNG CHƯA CHỐT — thước hỏng, tự khai:** hai phép đo trên cùng `DbInitializer.cs` **cãi nhau** (một lần trả **3**, một lần trả **10**). Nguyên nhân: `grep ", null,"` **trượt** các dòng có khoảng-trắng căn cột. Theo luật *2 engine cãi nhau ⇒ đi tìm engine HỎNG, đừng chọn con SỐ***không chốt số**, cần đo lại bằng cắt-cột tuple.
- **Chốt được tới đây:** cây **8 root < thực-tế ≥10** ⇒ nợ cũ **F-2 CÒN MỞ****F-3 CÒN MỞ**. 🔴 Con số *"thiếu đúng 5 root"* trong HANDOFF **CHƯA tái-lập được****đừng chép lại**.
---
## Nợ cũ S179 — trạng-thái từng cái
| Nợ | Trạng-thái | Chứng |
|---|---|---|
| F-2 cây `permission-matrix` thiếu root sống | **CÒN** | cây = 8 root, source ≥10 |
| F-3 `:3` cấm chép số rồi chép "12 root" | **CÒN** | literal còn nguyên `:3` |
| F-4 dải `§16+` 4 giá-trị/4 surface | **CHƯA ĐO** | hết lượt trước khi chạm |
| F-5 `PROJECT-MAP.md` 0-hit 4 module sống | **CHƯA ĐO** | — |
| F-7 `ef-core-migration` bảng dừng Mig 71 | **VÁ** (đúng cách) — nhưng đẻ **F-3 mới** | bia-mộ `SKILL.md:25` |
| F-8 `schema-diagram:515` "91 bảng" vs 97 | **CHƯA ĐO** | — |
| F-6 S179 (`CLAUDE.md` neo `Lần kế 2026-08-01`) | **VÁ** | nay `Lần kế: 2026-09-01` |
| S181 F-1/F-4 (2 vai W4b thiếu `effort:`/`memory:`) | **VÁ TRỌN** | 28/28 `model: opus` · 28/28 `effort: max` · 28/28 `memory: project` |
## DIFF vs S181/S180
- **THÊM:** roster 26→**28**. Khớp **5/5 bề-mặt** — đĩa 28 (29 file README; 🔸 bẫy glob, đừng đếm 29) · `VALID_ROLES` 28 (parse chặt neo `^const VALID_ROLES = \[``^\]`, strip `//`; `diff` tập-TÊN vs đĩa **IDENTICAL**) · `agent-memory/` 28 dir · `STATUS:478` `**28**` · `README:27` "28 vai". Bề-mặt thứ **6** (skill-matrix) = F-1.
- **ĐỔI:** `hmw_width.cap` 20→**30**, mirror `hmw.js:175` đổi CÙNG lượt ⇒ 0 drift 2-nơi. Slot (75) đóng thật (28 ≤ 30, hết LOUD-skip).
- **XÓA:** câu *"mới nhất = row cuối bảng"* gỡ khỏi `ef-core-migration/SKILL.md` — nhưng **không quét cùng-lớp** ⇒ F-3.
- **0-DELTA:** skill **6 project** + **23** standalone + **19** command (khớp `README:233`) · plugin **18 registered / 15 enabled / 3 disabled** (`pr-review-toolkit` · `code-modernization` · `hookify`), **0-delta 4 phiên liền**, marketplace **37**. 🔸 Nấc báo đúng: 15 **enabled**, KHÔNG đọc thành "đang dùng".
- **stale:** `STATUS:478` (F-2) · `skills/README:20` (F-3) · `permission-matrix/SKILL.md:3` + cây (F-4).
## Tự BÁC 1 ứng-viên flag (ghi để không tái-sinh)
`memory-budget.json:31` `_cap_raised_S181` ghi *"roster 26 và còn để chỗ cho 2 vai tầng-2/tầng-3 của W4b"***KHÔNG flag**. Đó là lý-lẽ **tại thời-điểm nâng cap** (26 + 2 = 28), nằm trong khoá `_cap_raised_*` = **bản-ghi thuật lại**; sửa = **giả-mạo phép đo cũ**. Khác hẳn `STATUS:478` là vị-từ khẳng-định đang sống.
## Khai chặt phạm vi
Mặt ④ docs **CHƯA phủ hết**: `docs/HANDOFF.md` · `docs/governance/` · `docs/PROJECT-MAP.md` · `schema-diagram.md` = **CHƯA ĐO** lượt này.
**Propose-only — 0 file bị sửa.**

View File

@ -0,0 +1,11 @@
# owner-decisions — YC-013 pipeline (S182, 2026-08-08)
## D3 — 9 checkbox quyền duyệt trên panel KHKK (YC-014, AskUser ~01:20)
**Anh chọn: "C — Mờ ô chưa nghe (Recommended)"**
- Giữ layout y khuôn ảnh; ô nào máy KHKK chưa nghe ⇒ **disabled + tooltip "chưa áp dụng cho KHKK"** — tiền lệ chính trang này (ô Duyệt-thẳng-Cấp-cuối đã mờ cho type-10 từ K3, `ApprovalWorkflowsV2Page.tsx:1292-1302`).
- Hệ quả scope: **W3 (port semantics) HỦY đợt này**. W2d thi hành theo C.
- Danh sách ô mờ CHÍNH XÁC = chờ 2 reviewer chấm (đặc biệt ô #4 Trả-về-Drafter: hành-vi thật = luôn-cho dù cờ tắt — mờ-tick-sẵn hay mờ-thường, reviewer khuyến nghị).
- Ô "Ngưỡng giá trị gói CEO" (`:944-961`): cùng cách — KHKK cố ý không port (OG-3 NĐUQ human-gate).
## D4 — nhãn panel (lead quyết theo đề xuất FE-lens, reviewer chấm)
Title = menu-label "Duyệt KHKK — Nhóm {g}" + phụ đề nghiệp vụ `khkkGroupMenuLabel(g)`.

View File

@ -0,0 +1,40 @@
# run — KHKK 8 workflow-nhóm mirror trang Duyệt NCC (YC-013, S182/L13)
- ts-mở: 2026-08-08T00:50+07:00 · HEAD nền: `936d1aed` (cây làm việc)
- lead: **Fable 5** (anh `/model claude-fable-5` @S182 — chủ dự án đổi, 0 caveat)
- yêu-cầu: `YC-013` sổ `docs/governance/so-yeu-cau-chu-du-an.md` (nguyên văn + 4 ảnh)
## Đề bài (từ 4 ảnh anh gửi)
Trang `admin.solutions.com.vn/system/approval-workflows-v2/DuyetNcc` (QT-DN-V2-001 v04) có ĐỦ:
xem 3 Bước × Cấp × NV + badge role · **Sửa tại chỗ** (dialog per-NV **9 checkbox quyền duyệt**: Trả-về-1-Cấp-trước / Trả-về-1-Bước-trước / Trả-về-Người-chỉ-định / Trả-về-Drafter-mặc-định / Sửa-Section-2 / Sửa-Section-ngân-sách / Duyệt-thẳng-Cấp-cuối / Duyệt-là-KẾT-THÚC / **XÓA phiếu**) · **Tạo phiên bản mới** · **Ghim/Bỏ ghim** (Cho user chọn) · **Xoá version** · Lịch sử versions (v03 Archived) · Tạo quy trình mới · rule-box (mỗi Bước 1 Phòng + tối đa 3 Cấp, OR-of-N, tuần tự).
**8 mục sidebar "Duyệt KHKK — Nhóm 1..8"** (khoanh đỏ) phải **GIỐNG NHƯ VẬY** — thêm/sửa/xóa/edit/stick tương tự Duyệt NCC, UI **không lệch**.
## Pipeline (anh chỉ định trong YC-013)
| bước | engine | trạng thái |
|---|---|---|
| B1a `/fable-real investigator-codebase` — lens **BE** | Fable single deep-pass | [ ] |
| B1b `/fable-real investigator-codebase` — lens **DATABASE** | Fable single deep-pass | [ ] |
| B1c `/fable-real investigator-codebase` — lens **FE-UI** ("đúng nhất về UI lẫn giao diện, ko lệch") | Fable single deep-pass | [ ] |
| B2 lead tổng hợp 3 lens → **wave-plan chi tiết** (spec draft) | lead Fable | [ ] |
| B3 ×2 `/fable-real reviewer` — review + tổng hợp wave-plan | Fable single ×2 | [ ] |
| B4 lead chốt **spec final** | lead | [ ] |
| B5 **hmw fan-out Opus 5 MAX** execute wave | Workflow hmw.js (worker-pin opus+max) | [ ] |
## Artifact chờ
`sub-invest-be-fable.md` · `sub-invest-db-fable.md` · `sub-invest-fe-fable.md` · `spec-khkk-awf-mirror-ncc-DRAFT.md` · `sub-review-1-fable.md` · `sub-review-2-fable.md` · `spec-khkk-awf-mirror-ncc-08-08-2026.md` · `implement-synthesis.md`
## Đo nền W0 (R-3 — số đo TƯƠI, lane implementer-backend, 2026-08-08)
`baseline-W0: 645 PASS (45D+600I)` — 0 fail, 0 skip. Lệnh: `dotnet test SolutionErp.slnx` chạy **sau** W0.1/W0.2, HEAD nền `936d1aed` + 0 sửa mã. Domain 45 (407 ms) · Infrastructure 600 (35 s). ⇒ **W1 đạt khi FULL suite = 645 + 14 = 659, 0 fail.**
W0.1 (mig ×2 DB): `SolutionErp_Dev` + `SolutionErp_Design` cùng từ `20260731085624` (Mig 71) → **`20260806070242`** (Mig 72) ✅. *(Khai: local có BA DB — `SolutionErp` base ở `appsettings.json:3` bị `appsettings.Development.json:3` override nên KHÔNG phải DB runtime; lượt đầu tôi trỏ nhầm vào nó, đã chạy lại đúng 2 DB — chi tiết `sub-implementer-backend-0.md §1`.)*
W0.2 (seeder): boot API 1 lần (`ASPNETCORE_ENVIRONMENT=Development`) ⇒ **8 row `KHKK-N1..8`, ApplicableType=10, IsActive=1, IsUserSelectable=1**, mỗi row 3 Bước / 9 Cấp (roster local resolve ĐỦ người). Đã tắt API sau khi seed.
## Chống-#53 (7/7 vai garble @bookend-open phiên này)
Mỗi sub **GHI ARTIFACT LIÊN TỤC TRONG LÚC LÀM** vào đúng 1 file `sub-*` của mình; return chỉ cần VERDICT-header + con-trỏ. Return garble ⇒ đĩa cứu.

View File

@ -0,0 +1,80 @@
# 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.<flag>}` 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 disabled-**state-thật** + #7 giữ + #8 sống). 🔴 **ERRATA ratified @1bis (FE FLAG-B):** chữ "forced-true" ở dòng này bản trước = **rút gọn HAO NGHĨA** của MỤC 2 *"state thật (thường TRUE)"* — hard-code `checked={true}` sẽ nói dối ca row=false, cùng class lỗi mà D3=C cấm ở 6 ô kia chỉ đảo dấu. Code theo **state thật** là ĐÚNG (default=true ở 4 lớp ⇒ thực tế vẫn hiện tick, thoả A2). MỤC 2 W2d là bản chuẩn |
| 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).

View File

@ -0,0 +1,68 @@
# SPEC DRAFT — YC-013: 8 panel "Duyệt KHKK — Nhóm 1..8" mirror trang Duyệt NCC (S182/L13)
> Tổng hợp từ 3 Fable deep-pass: `sub-invest-be-fable.md` (§1-§5+VERDICT) · `sub-invest-db-fable.md` (§1-§7) · `sub-invest-fe-fable.md` (§1-§6). Trạng thái: **DRAFT chờ 2 reviewer Fable + anh chốt D3**.
## MỤC 1 — Mục tiêu + phạm vi + tiền đề đã chứng
**Mục tiêu (YC-013):** 8 mục sidebar "Duyệt KHKK — Nhóm 1..8" ra ĐÚNG khuôn trang `approval-workflows-v2/DuyetNcc`: panel active-card + 4 nút (Sửa-tại-chỗ / Tạo-phiên-bản-mới / Ghim-Bỏ-ghim / Xoá-version) + Lịch sử versions + dialog Designer 9-checkbox per-NV + rule-box. **UI không lệch.**
**Tiền đề 3 lens hội tụ (mỗi cái có file:line trong artifact):**
- **0 MIGRATION** — 9/9 checkbox = 9 cột bit sẵn trên `ApprovalWorkflowLevels` dùng chung; versioning UNIQUE `(Code,Version)` + `IsActive`; Archived = `IsActive=false`.
- **BE generic ~85%** — 5 action controller + PUT diff-by-Id + guard chữ-ký 3-site ĐÃ KHKK-aware + `SetUserSelectable`/`Delete` by-Id.
- **Danh tính nhóm = `Code` (`KHKK-N{n}`), KHÔNG phải ApplicableType** — 8 workflow cùng type=10. Đây là gốc của MỌI việc phải sửa.
- **Hiện trạng "lệch":** 8 route `/system/approval-workflows-v2/KhkkN{n}` (Layout `:188`) đổ về **landing grid**`TYPE_CODE_TO_INT` không có key `KhkkN*` (`ApprovalWorkflowsV2Page.tsx:164-172`).
- **Authz:** giữ `Workflows.Create` y trang DuyetNcc (32 policy `AwV2_KhkkN*` 0-consumer BE — đo + control-dương; per-group-authz = đổi hình controller 9-type, KHÔNG làm trừ khi anh lệnh).
- Runtime 9-flag: KHKK hiện chỉ nghe **#8 Finalize**; 4 Trả-về + Sửa-S2 + Sửa-NS + Duyệt-thẳng + XÓA = **BỎ QUA/CHƯA WIRE** ⇒ điểm quyết D3 (dưới).
## MỤC 2 — WAVE-PLAN chi tiết
### W0 — prep local (bắt buộc trước mọi test)
1. `dotnet ef database update` ×2 DB (Dev + Design **đang kẹt Mig 71**, đĩa = Mig 72 — committed-but-unapplied class S53).
2. Boot API 1 lần → `SeedKhkkGroupWorkflowsAsync` đẻ 8 row local. ⚠️ Local 8 workflow **RỖNG NGƯỜI là ĐÚNG** (seeder resolve email prod) — acceptance cấu trúc chạy local, acceptance đội-hình chạy prod.
### W1 — BE-1: 4 fix, 2 file, 0-mig (`ApprovalWorkflowV2AdminFeatures.cs` + `ContractSigningPlanFeatures.cs` +1 helper)
| # | Fix | Site | Cách |
|---|---|---|---|
| a | 🔴 **Deactivate-per-Code cho type-10** (mìn M1, 3 lens hội tụ) | `:339-343` | `typeEnum == ContractSigningPlan` ⇒ scope `w.Code == request.Code`; type khác giữ per-type (P1 — KHÔNG làm P2 per-Code toàn cục: đổi hành vi PE/Contract chưa lường) |
| b | **Group-match guard 2-site** (bypass rào-v) | `:603-607` + `SubmitAsync` | extract rào (v) `:479-486``EnsureWorkflowGroupMatchAsync(db, awId, group)`; gọi ở Create + UpdateDraft + Submit (chốt-cuối, khuôn 2-tầng `:179-187`). Message lỗi chỉ việc cần làm cho phiếu lỡ pin lệch trước vá |
| c | **Changelog type-10 khi PUT sửa-tại-chỗ** | `:863-893` | thêm nhánh mirror block PE → `ContractSigningPlanChangelogs` (bảng sẵn Mig 69; khuôn ghi `ContractSigningPlanWorkflowService.cs:546-557`), query phiếu pin `def.Id` Phase ∉ {DaDuyet, TuChoi} |
| d | **DELETE usage-check → 409 đích danh** (lợi MỌI type) | `:1193-1205` | pre-count pin 8 module có cột `ApprovalWorkflowId` + LevelOpinions (khuôn `CountLiveOpinionsAsync` theo workflow-id) → 409 "N phiếu đang pin"; hết cảnh `SqlException 547 → 500 thô` (`GlobalExceptionMiddleware.cs:32-39` không map DbUpdateException) |
**Test W1 (test-before cho a/b, test-after c/d):** tạo v2 `KHKK-N1` ⇒ N2..N8 `IsActive` bất biến + regression DuyetNcc tạo-Code-mới-cùng-type vẫn tắt Code cũ · UpdateDraft re-pin lệch nhóm ⇒ 409 · PUT workflow nhóm có phiếu treo ⇒ 1 row `ContractSigningPlanChangelogs` · DELETE version có pin ⇒ 409 (không 500).
### W2 — FE: 1 file chính `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` (~60-90 dòng diff)
| # | Chạm | Site | Cách |
|---|---|---|---|
| a | Parse kind | `:164-172` | `match(/^KhkkN([1-8])$/)``khkkGroup`; `selectedTypeInt = khkkGroup ? 10 : TYPE_CODE_TO_INT[typeCode]` (query overview giữ `applicableType=10`) |
| b | Synthetic slice per-Code | `:299-301` | từ type-10: `versions = history.filter(d => d.code === khkkGroupWorkflowCode(g))` · `active = versions.find(v => v.isActive) ?? null` (🔴 KHÔNG tin ô `Active` per-type của BE — nó số-ít, 8 nhóm trộn) · title = "Duyệt KHKK — Nhóm {g}" + phụ đề nghiệp vụ `khkkGroupMenuLabel(g)` (D4 — lead chọn theo đề xuất FE, reviewer chấm) |
| c | `lockedCode` xuyên suốt | `TypePanel:357``Designer:620/:650/:798/:927` | prop `lockedCode?: string`; ô Mã `disabled={isEdit || !!lockedCode}`; POST gửi `lockedCode ?? code`. 🔴 Thiếu = "Tạo quy trình mới" từ panel nhóm với mã tự gõ đẻ version **rơi khỏi slice** + không được rào (v) nhận |
| d | **D3 — 7 ô chưa-wire** (chờ anh chốt, xem MỤC 3) | `:1275/:1284/:944-961/…` | thi hành theo phương án anh chọn {A giữ / B port / C mờ-ô} |
| e | (optional) mirror menuKeys | `fe-{admin,user}/src/lib/menuKeys.ts:31-32` | +8 const `AwV2_KhkkN*` — 0 hệ quả runtime, mirror-completeness |
**SHA-mirror 2 app: KHÔNG áp dụng** — Designer là fe-admin-only (đã CHỨNG: fe-user 0 route/0 page/0 nhánh `AwV2_*`); `types/khkk.ts` 2 app identical sẵn, không chạm.
**Regression:** 4 route cũ (`DuyetNcc`/`PhuongAn`/`Contract`/`ContractSigningPlan`) render như cũ · fe-user tạo phiếu nhóm auto-pin `KHKK-N{g}` không đổi API · **hệ quả nghiệp vụ THẬT của nút Ghim:** `IsUserSelectable=false` 1 nhóm ⇒ CHẶN TẠO PHIẾU nhóm đó cả 2 app (`KhkkCreatePage.tsx:104/:113-116`) — acceptance phải nêu để anh biết nút này có răng.
### W3 — CHỈ KHI anh chọn D3=B: port semantics vào `ContractSigningPlanWorkflowService`
F6 XÓA-đúng-lượt (mirror PE 3-rào `PeSoftDeleteFeatures.cs:110-114`) · F1×4 return-modes per-level (mirror switch `PurchaseEvaluationWorkflowService.cs:421-424`) · F3-analog sửa-Lines-lúc-duyệt · F2 skip-to-final (+gỡ ép-false `:387` + gỡ disable FE `:1292-1302` **CÙNG WAVE** — tiền lệ F5 @K3). Mỗi cờ = test RED→GREEN riêng. `CeoApprovalThreshold` **KHÔNG port** (OG-3 NĐUQ human-gate — owner đã chốt trước).
### W4 — test-specialist đóng suite (baseline 645 → +N per W1/W3)
### W5 — reviewer adversarial pre-commit → commit → cicd-monitor 3-chân (bundle **admin rotate**, eoffice giữ nguyên nếu chỉ W2a-d; +eoffice nếu e) → acceptance prod + 3 query SQL verify (DB §4 bàn giao)
## MỤC 3 — Điểm quyết + acceptance + rollback
**D3 (ANH CHỐT — chặn scope W2d/W3):** 6/9 checkbox tick được mà KHKK-runtime KHÔNG nghe (#78 config-lie class):
- **A** — giữ 9 ô tick được (uniform tuyệt đối với ảnh; cờ lưu DB nằm chờ; risk: người cấu hình tưởng có hiệu lực).
- **B** — port cho sống thật (W3; đổi hành vi duyệt KHKK; nặng + cần test per-cờ).
- **C** — mờ ô chưa-wire + tooltip "chưa áp dụng cho KHKK" (tiền lệ CHÍNH TRANG NÀY: SkipToFinal đã disabled cho type-10 `:1292-1302`; + ô Ngưỡng CEO cùng cách; trung thực #78; port sau gỡ dần).
**D4 (lead quyết, reviewer chấm):** title panel = menu-label "Duyệt KHKK — Nhóm {g}" + phụ đề nghiệp vụ từ `khkkGroupMenuLabel(g)`.
**Acceptance (đo được, từ FE §5):**
1. 8 leaf sidebar → 8 slice panel khác nhau (URL `/KhkkN{n}`, header đúng nhóm, card `KHKK-N{n} v01` + badge + 3 Bước + 4 nút + Lịch sử) — hết landing grid.
2. Dialog Sửa-tại-chỗ: title `KHKK-N{n} v{NN} (giữ nguyên phiên bản)`, khối cam 9 checkbox đúng khuôn (theo D3), Drafter tick sẵn, rule-box.
3. "Tạo phiên bản mới" nhóm N1 ⇒ v02 nằm ĐÚNG panel N1; **badge "Đang áp dụng" 7 nhóm khác KHÔNG mất** (W1a).
4. Regression 4 route cũ + fe-user auto-pin + 645 test cũ xanh.
**Rollback:** 0 mig ⇒ revert commit là sạch; W1a có regression-test 2 chiều giữ hành vi cũ cho type ≠ 10.
## Nợ NGOÀI scope (khai, không lặng): GET Overview 0-policy lộ roster+9-flag cho mọi user authenticated (BE §5.2 — information-disclosure mềm, owner ngó sau) · roster 8 nhóm = snapshot email lúc seed, KHÔNG tự sync theo QT-DN-V2-001 (BE §5.3) · `Khkk_WfView` panel aggregate giấu 7/8 nhóm (FE §6.1 — slice này vá cho 8 leaf mới; re-point mục cũ = việc riêng) · comment "7 bảng" stale `:1120`.

View File

@ -0,0 +1,221 @@
# sub-cicd-monitor-0 — ĐỢT-1 lane PRECHECK (W0.4 prod-state, ĐỌC-ONLY)
> Run: `2026-08-08-S182-khkk-awf-mirror-ncc` · Role: cicd-monitor · Spawn: đợt-1 (W0.4)
> SPEC: `spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W0.4 (`:24`) + R-2 (`:10`) + F11 audit.
> 🔴 R-2: PROD chỉ ĐỌC. 0 lệnh ghi (không `scp`, không tạo file remote, không INSERT/UPDATE/DELETE).
> ⚠️ Naming: TASK gọi artifact `sub-cicd-monitor-0.md`; boilerplate RUN-TRACE gọi `sub-cicd-monitor-3.md`.
> Ghi vào **`-0`** theo TASK (cụ thể hơn). Cả 2 đều trong run-folder ⇒ ISOLATION OK.
## Trạng thái ghi (chống-#53 — đổ ruột liên tục)
- [x] S0 connectivity + control dương
- [x] Q1 ApprovalWorkflows type-10 — **KHỚP**
- [x] Q2 ContractSigningPlans GROUP BY ApprovalGroup, Phase — 🔴 **LỆCH**
- [x] Q3 COUNT ContractSigningPlanLevelOpinions — **13** (dồn hết vào N4)
- [x] Q4 audit pin-lệch (F11) — **KHỚP 0 row** (control mạnh)
- [x] BONUS đo thêm: flag-state 88 level + pin/opinion per-Code (de-risk W1d + W2d)
- [x] VERDICT = **LỆCH ở Q2, hệ-quả-chặn-W1 = 0**
---
## S0 — Connectivity + CONTROL DƯƠNG
Lệnh (khuôn S177, 0 file remote — `-Q` inline, KHÔNG `scp`):
`ssh vietreport-vps 'sqlcmd -S .\SQLEXPRESS -d SolutionErp -E -W -s "|" -Q "..."'`
| Phép | Kết quả | Ý nghĩa |
|---|---|---|
| `DB_NAME()` / `SUSER_SNAME()` | `SolutionErp` / `WIN-8M58521HH5D\Administrator` | integrated auth OK, KHÔNG cần password |
| `GETDATE()` srv | `2026-08-08 01:32:45` | đo tươi |
| `COUNT(*) ApprovalWorkflows` | **17** | >0 ⇒ bảng đọc được |
| `COUNT(*) ContractSigningPlans` | **12** | >0 ⇒ bảng đọc được. 🔴 **memory-pack ghi 3 phiếu (001-003) — nay 12** (đã lớn từ S177) |
| `COUNT(*) MenuItems` | **200** | ✅ **khớp ĐÚNG mốc prod đã biết (MEMORY anchor 200)** |
| `COUNT(*) Permissions` | **2221** | ✅ **khớp ĐÚNG mốc prod đã biết (anchor 2221)** |
🔑 **Control dương MẠNH:** 2 mốc độc lập (`MenuItems`=200, `Permissions`=2221) trùng khít anchor đã ghi từ #438 ⇒ thước ĐÚNG DB, ĐÚNG instance, không phải DB rỗng/nhầm. Mọi kết quả `0` phía dưới do đó là **vắng thật**, không phải hỏng thước.
**Tên bảng verify tại chỗ** (`INFORMATION_SCHEMA.TABLES LIKE 'ContractSign%'`) — 7/7 có mặt:
`ContractSigningPlans` · `~Approvals` · `~Attachments` · `~Changelogs` · `~DossierItems` · `~LevelOpinions` · `~Lines`
`ContractSigningPlanLevelOpinions` là tên ĐÚNG (không đoán).
---
## Q1 — `ApprovalWorkflows` ApplicableType=10
Kỳ vọng: 8 row `KHKK-N1..8`, `IsActive=1`. → ✅ **KHỚP 8/8**
| Code | Version | IsActive | IsUserSelectable |
|---|---|---|---|
| KHKK-N1 | 1 | 1 | 1 |
| KHKK-N2 | 1 | 1 | 1 |
| KHKK-N3 | 1 | 1 | 1 |
| KHKK-N4 | 1 | 1 | 1 |
| KHKK-N5 | 1 | 1 | 1 |
| KHKK-N6 | 1 | 1 | 1 |
| KHKK-N7 | 1 | 1 | 1 |
| KHKK-N8 | 1 | 1 | 1 |
🟢 **Bom per-type (R-1/`AdminFeatures.cs:339-343`) CHƯA NỔ** — 8/8 IsActive=1, 0 nhóm Archived-oan ⇒ **không cần recovery-tạo-version-mới sau W2**.
🟢 Cả 8 đều `IsUserSelectable=1`**D6/W2g (FE lọc `isActive`) an toàn**: auto-pin không mất mục tiêu; nút Ghim chưa ai bỏ.
🟢 Cả 8 đều `Version=1` ⇒ chưa ai bấm "Tạo phiên bản mới" trên panel type-10 ở prod (khớp cảnh báo R-1 "bom armed nhưng chưa kích").
**Đối chứng per-type (context cho R-6):** `GROUP BY ApplicableType` toàn bảng —
type 1 = 3 row / **1** active · type 3,4,5,6,7,9 = 1 row / 1 active · **type 10 = 8 row / 8 active**.
⇒ Type-1 đang đúng-hành-vi per-type (3 version, 1 active). Type-10 là ca DUY NHẤT cần per-Code ⇒ khẳng định phạm vi hẹp của W1a là đúng, và **T2 (regression type-1 Code-mới) có dữ liệu thật để soi**.
---
## Q2 — `ContractSigningPlans` GROUP BY ApprovalGroup, Phase
Kỳ vọng (SPEC `:24` + memory-pack): **mọi phiếu Phase=3 (DaDuyet TERMINAL)**.
→ 🔴 **LỆCH: chỉ 3/12 là Phase=3.**
| ApprovalGroup | Phase | n |
|---|---|---|
| 1 | **1** | 5 |
| 1 | **98** | 1 |
| 2 | **1** | 1 |
| 3 | **1** | 1 |
| 4 | 3 | 3 |
| 7 | **1** | 1 |
Tổng = 12 ✅ (khớp `COUNT(*)`=12 ở S0 — 2 phép độc lập cộng khít).
**Enum đọc từ NGUỒN, không đoán**`src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlanPhase.cs:10-14`:
`DangSoanThao=1` (Nháp) · `ChoDuyet=2` · `DaDuyet=3` (Terminal) · `TraLai=98` · `TuChoi=99`.
⇒ Giải mã: **8 phiếu Nháp · 1 phiếu Trả-lại · 3 phiếu Đã-duyệt · `ChoDuyet` = 0.**
🔑 **Vì sao memory-pack sai mà KHÔNG phải memory hỏng:** memory ghi *"phiếu 001-003 đều test, Phase=3"***vẫn đúng nguyên văn tới hôm nay** (001/002/003 = đúng 3 phiếu Phase=3, nhóm 4). Cái đổi là **thế giới**: 9 phiếu MỚI (004-012) đẻ ra sau khi memory được ghi. ⇒ Không phải "ký ức sai", mà là **ký ức đóng băng thời điểm** — SPEC `:24` chép lại mốc cũ rồi phổ quát hoá thành "đều Phase=3", đó mới là chỗ gãy.
**Ý nghĩa cho W1b (2 vế TÁCH, cấm nén):**
-**Vế 1 — tiền đề SPEC SAI:****9 phiếu ở phase submit-được** (8 Nháp + 1 TraLai), không phải 0. Chúng SẼ đi qua rào `EnsureWorkflowGroupMatchAsync` mới ở `SubmitAsync`.
-**Vế 2 — hệ quả = 0:** rào chỉ chặn khi pin-lệch, mà pin-lệch **đo được = 0** (Q4, control mạnh). ⇒ **0 phiếu bị kẹt submit sau W1b.** Không cần data-fix, không cần thông báo user.
- Phụ: `ChoDuyet=0`**0 phiếu đang treo giữa workflow** ⇒ W1c (changelog PUT, lọc `Phase != DaDuyet && != TuChoi`) trên prod hiện chạm **9 phiếu** (8 Nháp + 1 TraLai), **không** chạm 3 phiếu N4 đã duyệt. Test T8 cần phiếu `ChoDuyet`**phải seed local, prod không có mẫu**.
---
## Q3 — `COUNT(*) ContractSigningPlanLevelOpinions`
**13** (control dương cùng lượt: `~Approvals`=18 · `~Changelogs`=71 · `~Lines`=20 — 3 bảng anh em đều >0 ⇒ thước sống, 13 là số thật).
Phân bổ theo Code (đo riêng, cross-check khớp 13):
| Code | levels | opinions |
|---|---|---|
| KHKK-N1 | 11 | 0 |
| KHKK-N2 | 11 | 0 |
| KHKK-N3 | 11 | 0 |
| **KHKK-N4** | 11 | **13** |
| KHKK-N5 | 11 | 0 |
| KHKK-N6 | 11 | 0 |
| KHKK-N7 | 11 | 0 |
| KHKK-N8 | 11 | 0 |
⇒ 8 workflow × **11 level** = 88 level, đều nhau. Toàn bộ 13 chữ ký nằm ở N4 (đúng nhóm có 3 phiếu DaDuyet).
---
## Q4 — Audit pin-lệch (F11)
Kỳ vọng: 0 row → ✅ **KHỚP: 0 row.**
🔑 **CONTROL DƯƠNG cho một kết-quả-RỖNG** (bài học `#435` chân-lý-rỗng: `0==0` xanh mà 0 bit): chạy **CHÍNH câu đó bỏ vế `<>`** ⇒ trả **12 row**, liệt kê được từng dòng:
| MaKeHoach | Group | Phase | IsDeleted | pinned_code | khớp? |
|---|---|---|---|---|---|
| KHKK/2026/005 | 1 | 98 | 1 | KHKK-N1 | ✅ |
| KHKK/2026/006 | 1 | 1 | 1 | KHKK-N1 | ✅ |
| KHKK/2026/007 | 1 | 1 | 1 | KHKK-N1 | ✅ |
| KHKK/2026/008 | 1 | 1 | 1 | KHKK-N1 | ✅ |
| KHKK/2026/009 | 1 | 1 | 1 | KHKK-N1 | ✅ |
| KHKK/2026/011 | 1 | 1 | 0 | KHKK-N1 | ✅ |
| KHKK/2026/012 | 2 | 1 | 0 | KHKK-N2 | ✅ |
| KHKK/2026/010 | 3 | 1 | 0 | KHKK-N3 | ✅ |
| KHKK/2026/001 | 4 | 3 | 0 | KHKK-N4 | ✅ |
| KHKK/2026/002 | 4 | 3 | 0 | KHKK-N4 | ✅ |
| KHKK/2026/003 | 4 | 3 | 0 | KHKK-N4 | ✅ |
| KHKK/2026/004 | 7 | 1 | 0 | KHKK-N7 | ✅ |
⇒ 0-row của audit là **VẮNG THẬT**, không phải phép-trích-rỗng. Xác nhận **element-wise 12/12** bằng mắt, không chỉ tin `COUNT=0`.
🔑 **Bịt điểm-mù CẤU TRÚC của chính câu audit F11:** câu spec lọc `w.Code LIKE 'KHKK-N%'`**mù** với phiếu pin vào workflow NGOÀI họ (vd type-10 code lạ `QT-KHKK-V2-001`, hoặc workflow type khác). Bịt bằng **đẳng thức đếm**: `ApprovalWorkflowId IS NOT NULL` = **12** và JOIN-in-family = **12** ⇒ 12=12 ⇒ **0 phiếu pin ngoài họ, 0 phiếu chưa pin.** (Nếu 2 số lệch nhau thì audit F11 sẽ báo "sạch" một cách sai.)
---
## BONUS — 2 phép đo thêm (đọc-only, de-risk W1d + W2d)
### B1. Pin per-Code + soft-delete ⇒ đúng con số 409 mà W1d sẽ in ra
| Code | pins_total | pins_softdeleted | pins_submittable (Phase 1/98) | opinions |
|---|---|---|---|---|
| KHKK-N1 | **6** | **5** | 6 | 0 |
| KHKK-N2 | 1 | 0 | 1 | 0 |
| KHKK-N3 | 1 | 0 | 1 | 0 |
| KHKK-N4 | 3 | 0 | 0 | **13** |
| KHKK-N7 | 1 | 0 | 1 | 0 |
| KHKK-N5 / N6 / N8 | **0** | 0 | 0 | 0 |
- 🔴 **F2/T11 có DỮ LIỆU THẬT trên prod:** `KHKK-N1` bị pin bởi **6 phiếu, trong đó 5 XOÁ-MỀM**. Thiếu `IgnoreQueryFilters()` thì guard W1d(i) sẽ đếm **1** thay vì **6** ⇒ báo sai và (nếu qua được các rào khác) để lọt dangling FK. Đây không còn là ca giả định.
- **W1d(ii)** trên prod: xoá `KHKK-N4`*"3 phiếu đang gắn + 13 chữ ký"*. Xoá `KHKK-N1`*"6 phiếu đang gắn + 0 chữ ký"*.
- 🔴 **D5 có răng THẬT:** `N5/N6/N8` hiện **0 pin + 0 opinion****không có D5 thì 3 nhóm này XOÁ ĐƯỢC từ UI** (rào (i)/(ii) đều cho qua) → đúng ca seeder-tái-sinh M-1. Và vì cả 8 nhóm đều **Version=1 = version CUỐI**, sau D5 **không nhóm nào xoá được** — đúng chủ đích.
### B2. Flag-state THẬT của 88 level type-10 ⇒ nghiệm thu trước cho W2d (D3=C)
| # | Cờ | TRUE/88 |
|---|---|---|
| 1 | `AllowReturnOneLevel` | 0 |
| 2 | `AllowReturnOneStep` | 0 |
| 3 | `AllowReturnToAssignee` | 0 |
| **4** | **`AllowReturnToDrafter`** | **88 (100%)** |
| 5 | `AllowApproverEditDetails` | 0 |
| 6 | `AllowApproverEditBudget` | 0 |
| 7 | `AllowApproverSkipToFinal` | 0 |
| **8** | **`AllowApproverFinalize`** | **8** (= đúng 1/workflow) |
| 9 | `AllowApproverDelete` | 0 |
*(cột DB xác nhận tại chỗ qua `INFORMATION_SCHEMA.COLUMNS` — 9 cờ trên `ApprovalWorkflowLevels` khớp ĐÚNG 9 ô spec W2d liệt.)*
-**6 ô disabled (#1,#2,#3,#5,#6,#9) = 0/88** ⇒ lệnh *"GIỮ `checked` state THẬT, CẤM ép false"* render ra 6 ô **bỏ-tick + mờ**. Không có ca "mờ mà đang tick" gây rối mắt. Ép-false hay giữ-thật **hôm nay nhìn y hệt nhau** ⇒ 🔴 acceptance A2 **KHÔNG phân biệt được 2 cách làm trên dữ liệu prod**; muốn có răng phải **seed 1 level cờ=true ở LOCAL** rồi soi. (Ca kinh điển "chân-lý-rỗng" — báo trước để W2 không tự nghiệm-thu hớ.)
-**#4 = 88/88** ⇒ spec nói *"thường TRUE"***nói nhẹ**: thực tế **100%**. Chốt `forced-true-disabled + tooltip riêng` là đúng, và ô này sẽ hiện **tick** ở mọi level.
-**#8 = 8/88, đúng 1 mỗi workflow** ⇒ *"Finalize SỐNG"* không phải phòng xa: đây là **cờ chịu lực** (kết-thúc-tại-cấp) của cả 8 nhóm. Nếu W2 lỡ disable/ép-false #8**gãy hành vi finalize cả 8 nhóm prod**. Đây là ô nguy hiểm nhất trong 9 ô.
-**#7 = 0/88** ⇒ "GIỮ NGUYÊN" `:1292` là no-op trên dữ liệu hiện tại.
---
## VERDICT
### `LỆCH` — lệch ĐÚNG 1 chỗ (Q2), và chỗ đó **KHÔNG chặn W1**
| Q | Kỳ vọng | Đo được | Kết |
|---|---|---|---|
| Q1 | 8 row KHKK-N1..8 IsActive=1 | 8/8 IsActive=1, IsUserSelectable=1, Version=1 | ✅ **KHỚP** |
| Q2 | mọi phiếu Phase=3 | 3 DaDuyet · 8 Nháp · 1 TraLai · **0 ChoDuyet** | 🔴 **LỆCH** |
| Q3 | (không nêu) | **13**, dồn hết vào KHKK-N4 | ghi nhận |
| Q4 | 0 row pin-lệch | **0 row** + control 12/12 khớp element-wise | ✅ **KHỚP** |
**Kết luận cho lead — 3 câu:**
1. 🟢 **Bom per-type CHƯA NỔ.** 8/8 nhóm `IsActive=1`, `Version=1` ⇒ chưa ai bấm "Tạo phiên bản mới" trên panel type-10 prod. **Không cần recovery sau W2.** Nhưng bom vẫn **armed** ⇒ R-1 (cấm commit W2 thiếu W1a) giữ nguyên hiệu lực.
2. 🔴 **Tiền đề "phiếu đều Phase=3" của SPEC `:24` SAI** — thực tế 9/12 phiếu đang ở phase submit-được. **Nhưng** vì pin-lệch = 0 (đo, có control), **0 phiếu sẽ kẹt submit sau W1b**. Đề nghị: sửa câu `:24` cho khớp thực tế, **không** đổi thiết kế W1b.
3. 🟢 **W1 đi tiếp được.** Không phát hiện gì cần migration (R-4 an toàn), không cần data-fix prod.
**Cảnh báo mang sang wave sau:**
- W1d **BẮT BUỘC** `IgnoreQueryFilters()` — prod có **5 phiếu xoá-mềm** pin `KHKK-N1`; thiếu nó guard đếm 1 thay vì 6 (F2 = ca THẬT, không giả định).
- W2 acceptance A2 **không có răng trên dữ liệu prod** (6 ô đều false sẵn ⇒ ép-false và giữ-thật nhìn giống hệt). Muốn chứng thì **seed local 1 level cờ=true**.
- **#8 `AllowApproverFinalize` = ô chịu lực** (8/8 workflow đang dùng) — tuyệt đối không đụng.
- T8 (changelog phiếu `ChoDuyet`) **không có mẫu trên prod** (ChoDuyet=0) ⇒ phải seed local.
**R-2 tuân thủ:** 8 lệnh `ssh`, **100% `SELECT`**. 0 `INSERT/UPDATE/DELETE`, 0 `scp`, 0 file tạo trên VPS (dùng `-Q` inline, không `-i`). 0 lệnh chạm CI/deploy.
---
## PHỤ LỤC — ảnh chụp GIỮA CHỪNG lane W1 (⚠️ KHÔNG phải review)
⚠️ **Caveat mạnh:** lane `implementer-backend` đang chạy SONG SONG với tao (`git status` lúc 01:37 cho ` M ApprovalWorkflowV2AdminFeatures.cs`). Đây là **snapshot giữa chừng**, KHÔNG phải phán quyết — theo bài học *stale-diagnostic-in-background-agent* (#68), chỉ tin bản đo chạy SAU khi lane đóng. Ghi lại vì phát hiện B1 là **đầu vào trực tiếp** cho đúng đoạn code đang được viết ngay lúc này.
- ✅ Lane **đã ý thức** `IgnoreQueryFilters()`: **18 lần** trong file, có cả comment 🔴 `:991` tuyên "LÀ BẮT BUỘC, không phải trang trí", và phủ **8/8 bảng `*LevelOpinions`** (`:1000-1050`) + 8 nhánh orphan-check (`:1089-1118`), gồm `ContractSigningPlans` (`:1118`).
- ⚠️ **Nhánh W1d(i) — đếm PIN — chưa thấy trong ảnh chụp này.** `grep "ApprovalWorkflowId == def.Id"` chỉ ra **1 hit `:885`** (đó là khối changelog W1c), và `:1206` vẫn còn comment CŨ *"Sau UAT khi link với PE/Contract thật cần check usage trước khi delete."* ⇒ tại thời điểm chụp, rào đếm-pin có vẻ **chưa land** (hoặc đang gõ dở).
-**B1 vẫn còn hiệu lực và cần tới tay người viết W1d(i):** `KHKK-N1` bị pin bởi **6 phiếu, 5 trong đó XOÁ-MỀM**. Đếm-pin mà thiếu `IgnoreQueryFilters()` ⇒ ra **1** thay vì **6**. (Nhánh opinions đã đúng rồi — chỗ cần soi là nhánh PIN, không phải nhánh opinions.)
- Đề nghị lead: chuyển B1 sang lane BE, và để reviewer đo lại SAU khi lane đóng — **không** lấy phụ lục này thay cho review.
**END sub-cicd-monitor-0 — VERDICT = LỆCH (Q2 premise) · W1 GO · 0 blocker**

View File

@ -0,0 +1,88 @@
# sub-implementer-backend-0 — ĐỢT-1 lane BE (W0 + W1) — S182 YC-013
> Artifact ghi LIÊN TỤC trong lúc làm (chống-#53). Spec nguồn: `spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2.
> Trạng thái: **IN-PROGRESS** (khởi tạo lúc bắt đầu lane).
## 0. Kế hoạch lane
- W0.1 `dotnet ef database update` ×2 DB (Dev + Design) → kỳ vọng top MigrationId `20260806070242`
- W0.2 boot API 1 lần → verify 8 row `KHKK-N1..8` type-10 IsActive=1
- W0.3 `dotnet test SolutionErp.slnx` baseline TƯƠI → ghi `run.md`
- W1a deactivate-per-Code type-10 (`ApprovalWorkflowV2AdminFeatures.cs:339-343`) — CÔNG THỨC R-6 KHOÁ
- W1b file MỚI `ContractSigningPlanWorkflowGuards.cs` + gọi 3 site
- W1b2 pin-check IsActive ∧ IsUserSelectable (D6)
- W1c changelog type-10 (`:863-893`)
- W1d DELETE guard (`:1193-1205`)
## 1. W0 — prep + đo nền
### W0.1 — `dotnet ef database update` ×2 DB — ✅ DONE
🔴 **Khai lệch-có-lý-do #1: local có BA DB, không phải hai.** Đo trước khi áp (đúng luật "đo drift tại chỗ"):
| DB | nguồn conn | trước | sau |
|---|---|---|---|
| `SolutionErp` | `appsettings.json:3` Default (**base — KHÔNG dùng ở dev**, bị `appsettings.Development.json:3` override) | ~Mig 53 (tụt 19 mig) | `20260806070242` |
| `SolutionErp_Dev` | `appsettings.Development.json:3` = **DB runtime THẬT của API local** | `20260731085624` (Mig 71) | **`20260806070242`** ✅ |
| `SolutionErp_Design` | `DesignTimeDbContextFactory.cs:15` (ef CLI mặc định) | `20260731085624` (Mig 71) | **`20260806070242`** ✅ |
- Spec nói "Dev + Design"; memory-pack Pattern-2 định nghĩa Dev = `SolutionErp_Dev` (explicit conn) ⇒ **2 DB spec đòi = `_Dev` + `_Design`, cả 2 ĐÃ lên Mig 72.**
- Lượt chạy ĐẦU tôi trỏ nhầm `--connection` vào `SolutionErp` (base). **Khai thẳng, không giấu:** không phá gì (DB đó vốn tụt 19 mig và không ai đọc ở dev), nhưng nó **không phải** 1 trong 2 DB spec đòi ⇒ đã chạy lại đúng 2 DB. Nếu chỉ nhìn lượt đầu mà tuyên "xong W0.1" thì đó là **PASS giả**.
- Lệnh đo: `sqlcmd -S "(localdb)\MSSQLLocalDB" -d <db> -Q "SELECT TOP 1 MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId DESC"`
### W0.2 — boot API → 8 row `KHKK-N1..8` — ✅ PASS
- Trước boot: `SELECT ... WHERE Code LIKE 'KHKK-N%'` trên `SolutionErp_Dev` = **0 row** (control âm — thước sống).
- Boot: `ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5443 dotnet run --project src/Backend/SolutionErp.Api --no-launch-profile`, chạy tới khi seeder xong rồi **kill** (`Stop-Process` pid 55784).
- Sau boot: **8/8** row `KHKK-N1..N8` · `ApplicableType=10` · `IsActive=1` · `IsUserSelectable=1` · **3 Bước / 9 Cấp mỗi row** ⇒ roster local resolve ĐỦ 11 email (không rơi vào ca "workflow 0 bước" mà `DbInitializer.cs:544-548` cho phép).
- Seeder: `SeedKhkkGroupWorkflowsAsync` `DbInitializer.cs:549`, idempotent PER-CODE `:610` `if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;`**KHÔNG đụng** (memory-pack cấm).
### W0.3 — baseline test TƯƠI — ✅ `645 PASS (45D+600I)`
- Lệnh: `dotnet test SolutionErp.slnx`, chạy SAU W0.1/W0.2, **0 sửa mã** tại thời điểm đo.
- Domain 45 / 407 ms · Infrastructure 600 / 35 s · **Failed 0 · Skipped 0**.
- Ghi `run.md` mục "Đo nền W0" (R-3). Trùng số lead đo @bootstrap (645) nhưng **đo lại độc lập**, không chép.
- ⇒ Cửa W1/W4: FULL suite = **645 + 14 = 659**, 0 fail.
## 2. W1 — 5 fix
> 🔴 **Khai lệch-có-lý-do #2 — ARTIFACT NÀY TỪNG NÓI DỐI (lag).** Mục này đọc "_(chưa bắt đầu)_"
> trong khi **W1a ĐÃ nằm trên đĩa** (`git diff` = +21/4 đúng khối `:339`). Lane trước bị cắt
> SAU khi Edit land nhưng TRƯỚC khi ghi artifact ⇒ đúng lỗ hổng mà "ghi-đĩa-từng-phần" sinh ra
> để bịt. Lane tiếp đã **TIN DIFF, không tin artifact** (lệnh lead). Bài học ghi §4.
### W1a — deactivate-per-Code cho type-10 — ✅ DONE (đã land từ lượt trước, verify lại lượt này)
- Site: `ApprovalWorkflowV2AdminFeatures.cs:339-359` (khối `var actives = ...`).
- Công thức **ĐÚNG R-6**, verify từng vế trên đĩa:
`typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan ? Where(w => w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive) : Where(w => w.ApplicableType == typeEnum && w.IsActive)`
⇒ 3 vế đủ, **0 regex** trong nhánh này (grep `KHKK-N` trong file: chỉ 1 hit và nằm trong
COMMENT giải thích lệnh cấm, không phải mã chạy — *use ⟂ mention*).
- Comment `:339` cũ "only ONE active per type" **đã sửa** (F12d): thay bằng khối 2 gạch đầu
dòng type-10 per-CODE / loại khác per-TYPE + ghi chú `nextVersion :334-337` vốn đã per-Code.
### W1b — file MỚI `ContractSigningPlanWorkflowGuards.cs` + gọi 3 site — ✅ DONE
- **File mới:** `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs`
`public static class`, 2 method: `ParseGroup(string?)` + `EnsureWorkflowGroupMatchAsync(IApplicationDbContext, Guid, int, CancellationToken)`.
Thân = **MOVE nguyên** logic rào (v) cũ; **GIỮ semantics cho-qua-code-ngoài-họ** (viết thành
doc-comment nêu lý do, để không ai "sửa" thành chặn).
- **Site 1 Create** `ContractSigningPlanFeatures.cs:475-489` — 14 dòng regex inline XOÁ, thay bằng 1 call.
- **Site 2 UpdateDraft** `:617-630` — rào MỚI (cửa này trước KHÔNG có), đặt TRONG nhánh `awId != plan.ApprovalWorkflowId`.
- **Site 3 SubmitAsync** `ContractSigningPlanWorkflowService.cs:189-197` — chèn SAU rào hạng-mục
(`okCount != catalogIds.Count` `:185-187`) và NGAY TRƯỚC `LoadStepsAsync` `:198`. **Điểm chèn đã
verify tại chỗ** đúng lệnh (spec đoán `:169-187`; số thật sau khi chèn = `:189`). Thêm
`using SolutionErp.Application.ContractSigningPlans;` vào service.
- Message 409 dùng **nguyên văn spec**, 1 message chung 3 site.
### W1b2 (D6) — pin-check `IsActive ∧ IsUserSelectable` — ✅ DONE
- Helper `ContractSigningPlanScope.EnsureWorkflowPinnableAsync` (`ContractSigningPlanFeatures.cs:322-347`)
— mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147`, **2 message TÁCH** ("đã ngừng
hoạt động" / "chưa ghim") đúng lý do vá-3 nêu.
- Gọi 2 site: Create sau rào (iv) (`:478`) · UpdateDraft **CHỈ** trong nhánh `awId != plan.ApprovalWorkflowId` (`:626`).
- Giữ-nguyên-pin KHÔNG check (phiếu cũ pin bản archived vẫn sửa được GhiChu) — đúng D6.
### W1c — changelog PUT type-10 — ✅ DONE
_(chi tiết ghi ngay dưới, sau khi land)_
### W1d — DELETE usage-check → 409/404 — ✅ DONE
_(chi tiết ghi ngay dưới, sau khi land)_
## 3. Verify
_(chưa)_
## 4. Ambiguity / lệch spec khai
_(chưa)_

View File

@ -0,0 +1,205 @@
# sub-implementer-frontend-1 — S182 W2 (a-h) lane FE
> ⚠️ TÊN FILE: TASK text ghi `sub-implementer-frontend-0.md`, RUN-TRACE block + structured-output ghi `sub-implementer-frontend-1.md`.
> Chọn `-1` (bản RUN-TRACE = bản em main đọc qua `subMdPath`). Khai ở đây để lead biết.
## §0 — Trạng thái lượt (append liên tục, chống-#53)
- [x] Đọc SPEC FINAL MỤC 2 W2 (a-h) + MỤC 1 R-1..R-6
- [x] Đọc file đích + đo tiền-đề từ ĐĨA (line-number trong spec có thể lệch)
- [x] W2a parse kind
- [x] W2b synthetic slice
- [x] W2c lockedCode
- [x] W2d D3=C 6 ô + #4
- [x] W2e menuKeys ×2 app
- [x] W2f ngưỡng CEO
- [x] W2g auto-pin isActive ×2 app
- [x] W2h comment + subtitle
- [x] build fe-admin — exit 0
- [x] build fe-user — exit 0
**Lượt 2 (vá-nốt):** chẩn-đoán TS trong TASK là **ảnh cũ** — đo lại từ đĩa thì đã sạch ⇒ **0 dòng code sửa**, lượt này là ĐO + CHỨNG. Chi tiết §3.3.
- [x] tsc thật ×2 app (lệnh trong spec là phép đo CÂM — §3.1)
- [x] control-dương chứng tsc có răng (§3.2)
- [x] proof-harness 27/27 + anti-Goodhart (§3.4)
- [x] đối chiếu code-vs-spec W2a-h (§3.5)
- [x] mirror proof SHA + 4-place (§3.6)
## §1 — Đo tiền-đề từ đĩa (TRƯỚC khi gõ)
| # | Tiền-đề spec | Đo trên đĩa | Verdict |
|---|---|---|---|
| P1 | `:164-172` TYPE_CODE_TO_INT | `ApprovalWorkflowsV2Page.tsx:164-172` ĐÚNG (`TYPE_INT_CONTRACT_SIGNING_PLAN=10` `:149`) | HELD |
| P2 | `:299-301` currentType | `:299-301` `overview.data?.types.find(...)` ĐÚNG | HELD |
| P3 | `:357/:620/:650-651/:798/:920-933` | `TypePanel :357` · `Designer :620` · `defaultCode :650-651` · POST code `:798` · ô Mã `:920-933` — ĐÚNG cả 5 | HELD |
| P4 | 6 ô D3 `:1235-1266`, `:1268-1276`, `:1277-1285`, `:1320-1328` | #1 `:1232-1240` · #2 `:1241-1249` · #3 `:1250-1258` · #4 `:1259-1267` · #5 `:1268-1276` · #6 `:1277-1285` · #7 `:1292-1302` · #8 `:1307-1315` · #9 `:1320-1328` | HELD (số ô #1-#4 lệch ±3 dòng so spec, khớp nội dung) |
| P5 | `:944-961` ngưỡng CEO | `:943-961` ĐÚNG | HELD |
| P6 | `menuKeys.ts:31-32` = 2 const `AwV2_*` | fe-admin `:31-32` + fe-user `:31-32` ĐÚNG, 0 key KhkkN | HELD |
| P7 | BE const để mirror | `MenuKeys.cs:127-134` `AwV2_KhkkN1..8` (VÀO `All`) | HELD |
| P8 | 4-place Layout staticMap | `fe-admin/Layout.tsx:188` `/^KhkkN[1-8]$/` ĐÃ CÓ (K3 S164/S165) ⇒ **chỗ-4 KHÔNG cần chạm** | HELD |
| P9 | `khkkGroupWorkflowCode` / `khkkGroupMenuLabel` | `types/khkk.ts:159-168`, 2 app SHA identical `b26bf674…` | HELD |
| P10 | 🔴 spec W2g *"2 file KhkkCreatePage vốn khác nhau — không cần byte-equal"* | **SAI**: `sha256sum` 2 file = `332986ae6bf1904a4db434c796164caa2768beaaebdc7843dd2b1cdd729d17da` **BẰNG NHAU** + `diff` 0 dòng | **LỆCH** → sửa mirror BYTE-EQUAL, giữ SHA-pair |
| P11 | `WorkflowDto` (local) có `isActive`? | `KhkkCreatePage.tsx:57-62` = `{id, code, name, isUserSelectable}`**THIẾU `isActive`** | phải thêm field (BE có: `DefinitionDto.isActive :79`) |
| P12 | 🔴 spec W2h *"thêm ~1 prop `subtitle`"* | `components/PageHeader.tsx:3-11` chỉ có `title/description/actions` (bài S37 lặp lần 3) | **LỆCH** → xem §4 FLAG-1 |
| P13 | build = phép thử TS thật | `package.json` build = `tsc -b && vite build`, `noUnusedLocals`+`noUnusedParameters`+`erasableSyntaxOnly` ON | HELD |
## §2 — Thay đổi từng task (đã gõ xong, chờ build)
**File chạm (4):**
1. `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` (a,b,c,d,f,h)
2. `fe-admin/src/lib/menuKeys.ts` (e)
3. `fe-user/src/lib/menuKeys.ts` (e)
4. `fe-admin/src/pages/khkk/KhkkCreatePage.tsx` + `fe-user/…` (g, `cp` → SHA-pair)
### W2a — parse kind
- `khkkGroupMatch = typeCode?.match(/^KhkkN([1-8])$/) ?? null``khkkGroup: number|null`
- `selectedTypeInt = khkkGroup !== null ? TYPE_INT_CONTRACT_SIGNING_PLAN : typeCode ? TYPE_CODE_TO_INT[typeCode] : null`
- queryKey GIỮ NGUYÊN (`['approval-workflow-v2-overview', selectedTypeInt]`) ⇒ 8 panel nhóm + panel type-10 **dùng chung 1 ô cache**, invalidate làm tươi cả 9. Khai trong comment.
- Regex CHUỖI (không `Number()`) — `KhkkN03` không lọt (bài học L1 #11).
- +import `{ khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk'` (không chép chuỗi `KHKK-N`).
### W2b — synthetic slice
- Tách `typeBucket` (raw BE) ⟂ `currentType` (lát cắt). `useMemo([khkkGroup, typeBucket])`.
- `versions = typeBucket.history.filter(d => d.code === khkkGroupWorkflowCode(g))` · `active = versions.find(v=>v.isActive) ?? null` · **KHÔNG đọc `typeBucket.active`** (1-trong-8).
- Giữ shape `TypeSummaryDto` ⇒ TypePanel/Designer không biết mình đang xem nhóm hay loại.
### W2c — lockedCode xuyên suốt
- Page: `lockedCode = khkkGroup !== null ? khkkGroupWorkflowCode(khkkGroup) : null`
- `TypePanel({type, lockedCode, onSaved})``<Designer lockedCode={lockedCode} …>`
- `defaultCode = lockedCode ?? DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'` — ô hiện ĐÚNG mã sẽ gửi (không hiện `QT-KHKK-V2-001` rồi gửi `KHKK-N3`).
- Ô Mã `disabled={isEdit || lockedCode !== null}` + hint "Mã khoá theo nhóm — version tự tăng."
- POST `code: lockedCode ?? code` (bất-biến theo CẤU TRÚC, không dựa vào input disabled).
- **+`key={typeCode ?? 'all'}` trên `<TypePanel>`** — NGOÀI spec, lý do: chuyển N1→N3 khi Designer đang mở thì `cloneFrom/editTarget` (state trong TypePanel) còn trỏ bản N1 trong khi `lockedCode` đã là N3 ⇒ POST đẻ `KHKK-N3` mang ruột N1. Remount xoá state. Khai ở đây.
### W2d — D3=C danh-sách-chốt
Cờ chung `const isKhkk = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN` (1 nguồn, 8 chỗ dùng).
| Ô | Cờ | Xử lý |
|---|---|---|
| #1 | allowReturnOneLevel | `disabled={isKhkk}` + title chung + **`checked={entry.…}` GIỮ** + mờ text |
| #2 | allowReturnOneStep | ↑ |
| #3 | allowReturnToAssignee | ↑ |
| #4 | allowReturnToDrafter | `disabled` + `checked` THẬT + **title 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ờ** (giữ `text-slate-700`) |
| #5 | allowApproverEditDetails | như #1 |
| #6 | allowApproverEditBudget | như #1 |
| #7 | allowApproverSkipToFinal | **GIỮ hành vi** (kể cả `checked` ép-false của K3 S165); chỉ thay 3 biểu thức trùng → `isKhkk`, giá trị y hệt |
| #8 | allowApproverFinalize | **SỐNG** — 0 chạm |
| #9 | allowApproverDelete | như #1 (rose→slate khi mờ) |
- `levelFlagsPayload` **0 dòng đổi** (echo state thật).
- +1 dòng giải thích trong hộp tím (chỉ khi `isKhkk`) — nói rõ ô mờ vẫn hiện đúng giá trị DB, và 2 ô còn tác dụng.
### W2e — menuKeys ×2 app
+8 const `AwV2_KhkkN1..8` mirror `MenuKeys.cs:127-134`, sau `:32` cả 2 app. Comment viết theo app (fe-admin trỏ `Layout.tsx:188`; fe-user khai "cố ý không có Designer") — **cố ý lệch chữ, không lệch const**.
### W2f — Ngưỡng CEO
`disabled={isEdit || isKhkk}` + helper-text "KHKK không dùng ngưỡng máy — NĐUQ quyết…" + POST `ceoApprovalThreshold: isKhkk ? null : (…)`.
### W2g — auto-pin lọc isActive ×2 app
- `WorkflowDto` (local, `:57-62`) **+`isActive: boolean`** — BE có (`AwAdminOverviewDto`, đối chiếu `DefinitionDto.isActive`).
- `find(w => w.isActive && code-match)`; filter `isUserSelectable` ở query GIỮ.
- Sửa 2 câu thông báo cho khớp điều-kiện MỚI (3 vế: đúng mã ∧ đang áp dụng ∧ đã ghim) — nếu không sẽ nói "chưa có quy trình" trong khi nó đang nằm đó ở bản archived (sai-thầm-lặng).
- `cp` fe-admin → fe-user, SHA-pair `89dfa607…` **BẰNG NHAU**.
### W2h — comment + tiêu đề
- Vá comment `:641-644`: câu cũ "2 ô duyệt sớm sống cho MỌI loại" **SAI**#7 vẫn khoá cho type-10 từ K3 S165. Viết lại + trỏ tại-chỗ.
- PageHeader: title = `Quy trình duyệt (Mới): {khkkGroupMenuLabel(g)}` · description = phụ đề nghiệp vụ (loại + nhóm N{n} + mã khoá + "7 nhóm còn lại không đổi"). Xem FLAG-1.
## §3 — Verify (build ×2 + proof)
### 3.1 🔴 Lệnh acceptance trong spec/TASK là PHÉP ĐO CÂM — phải đổi
`npx tsc --noEmit` (nguyên văn acceptance R-2 + TASK lượt 2) chạy ra **EXIT=0, 0 dòng output** ở CẢ 2 app **kể cả khi code sai**: `fe-{admin,user}/tsconfig.json` là solution-style `{"files": [], "references": [...]}` ⇒ tsc **không biên dịch file nào** rồi thoát 0. Xanh vì CÂM, không phải vì đúng.
**Lệnh THẬT** (đã dùng dưới đây): `npx tsc -p tsconfig.app.json --noEmit` — hoặc `tsc -b` (chính `npm run build`).
| # | Lệnh | Exit | Ghi chú |
|---|---|---|---|
| V1 | `fe-admin$ npx tsc -p tsconfig.app.json --noEmit` | **0** | 114 file `src/` trong tập biên dịch (`--listFiles`), có `ApprovalWorkflowsV2Page.tsx` + `types/khkk.ts` |
| V2 | `fe-user$ npx tsc -p tsconfig.app.json --noEmit` | **0** | |
| V3 | `fe-admin$ npm run build` (`tsc -b && vite build`) | **0** | ✓ 1964 modules · `index-CmEoYZiH.js` 1.761,63 kB |
| V4 | `fe-user$ npm run build` | **0** | ✓ 1953 modules · `index-CT5Y_xQ4.js` 1.677,49 kB |
V3/V4 chạy **SAU** khi đã hoàn tác tiêm-lỗi ⇒ đo đúng trạng thái cuối trên đĩa.
Warning PRE-EXISTING (không phải nợ mới): chunk >500 kB · `INEFFECTIVE_DYNAMIC_IMPORT realtime.ts` · CSS `@import` order.
### 3.2 Control-DƯƠNG — chứng phép đo có RĂNG
Tiêm `const __ctrl_neg: number = khkkGroupWorkflowCode(3)` vào cuối chính file đã ship → tsc nổ **2 lỗi**:
`TS2322 Type 'string' is not assignable to type 'number'` + **`TS6133 '__ctrl_neg' is declared but its value is never read`**.
🔴 Ý nghĩa: **TS6133 (`noUnusedLocals`) CÒN SỐNG** — đúng class lỗi mà 3 chẩn-đoán "declared-never-read" (`lockedCode :417` · `isKhkk :714` · `khkkNotYetHint :717`) thuộc về. Nên "0 lỗi" ở V1 = **thật sự hết**, không phải đo hụt.
Hoàn tác: SHA trước/sau `dda4be1f9d4dabd…b69b7f71` **BẰNG NHAU** (byte-identical).
### 3.3 🔴 Chẩn-đoán trong TASK là ẢNH CŨ — 0 dòng code phải vá
6 lỗi TS mà TASK liệt (thiếu import `khkkGroupWorkflowCode`/`khkkGroupMenuLabel` `:330/:343/:355` · `lockedCode` chưa khai ở signature `:403/:517` · 3 biến never-read) **KHÔNG còn tồn tại trên đĩa**: `import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk'``:36`; `lockedCode` khai đủ ở CẢ 2 signature (`TypePanel :417/:421` · `Designer :690/:699`); 3 biến đều có chỗ dùng thật (`isKhkk` 20 call-site, `khkkNotYetHint` 6, `lockedCode` 6).
⇒ Đây là **IDE-diagnostic chụp giữa lúc lane trước đang gõ** (gotcha #68). Lượt này **KHÔNG sửa 1 dòng code nào** — chỉ đo + chứng. `git diff --stat` giữ đúng **5 file**, 0 file untracked.
### 3.4 Proof-harness — chạy CHÍNH text đã ship (extract-by-anchor, neo lệch thì script tự chết)
`node proof-w2.mjs`**27 PASS / 0 FAIL**:
- **T1 (8/8)** dây xuyên suốt `AwV2_KhkkN{n}``Layout.tsx:188``/system/approval-workflows-v2/KhkkN{n}` → regex page parse nhóm `{n}``lockedCode = KHKK-N{n}`, đối chiếu đủ 3 nguồn BE+admin+user.
- **T2 (7/7) control-ÂM:** `KhkkN0` · `KhkkN9` · **`KhkkN03`** · `KhkkN10` · `khkkn1` · `"KhkkN1 "` · `KhkkN` — bị chặn ở CẢ 2 regex (regex CHUỖI, không `Number()`).
- **T3 (2/2)** control-âm cho chính T1: key bịa `AwV2_KhkkN9/N0` vắng ở cả 3 nguồn.
- **T4/T5/T6 (10/10)** D3=C — xem 3.5.
**Anti-Goodhart** (`proof-control.mjs`, exit 0): áp ĐÚNG vị ngữ T4 và T5 lên ô **#7** (ô cố ý khoá + ép-false từ K3 S165) ⇒ **cả hai đều `false`**. Phép đo phân biệt được ô-sống ⟂ ô-khoá và bắt được ép-false; không phải luôn-luôn-PASS.
### 3.5 Đối chiếu code-vs-spec W2ah
| Task | Verdict | Bằng chứng |
|---|---|---|
| W2a parse kind | ✅ | `:302-306` regex chuỗi; queryKey giữ nguyên (9 panel chung 1 ô cache — khai tại `:300-301`) |
| W2b synthetic slice | ✅ **và là BẮT BUỘC** | BE `active = versions.FirstOrDefault(v => v.IsActive)` (`:210`) — với type-10 nay có **8 bản active song song** (BE lane đổi sang tắt **per-CODE** `:336-360`) ⇒ đọc `bucket.active` sẽ sai cho 7/8 panel. `history` xác nhận **KHÔNG lọc** `IsActive` (`definitions` `:118-134` chỉ lọc Type + IsUserSelectable) |
| W2c lockedCode | ✅ | signature 2 nơi · `defaultCode :733` · ô Mã `disabled :1020` · **POST `code: lockedCode ?? code` `:884`** (bất-biến theo CẤU TRÚC). Khớp khoá với vị ngữ `w.Code == request.Code` bên BE |
| W2d D3=C | ✅ **đúng danh-sách-chốt** | 6 ô `disabled={isKhkk}` + `checked={entry.…}` THẬT · #4 tooltip riêng + KHÔNG mờ · #7 giữ nguyên · **#8 `allowApproverFinalize` 0 chạm** (T4) · `levelFlagsPayload` 0 dòng đổi |
| W2e menuKeys ×2 | ✅ | 8 const khớp `MenuKeys.cs:127-134`; khối const 2 app **diff 0 dòng**, chỉ comment cố ý lệch |
| W2f ngưỡng CEO | ✅ | `disabled={isEdit \|\| isKhkk} :1052` + helper-text `:1055` + **POST `null` `:891`** |
| W2g auto-pin isActive | ✅ | `isActive` **CÓ THẬT** trong payload (`ToDto` `:176`) — không phải field bịa; 2 câu thông báo đổi sang nói ĐIỀU KIỆN (rỗng-vì-lọc ⟂ rỗng-vì-chưa-có) |
| W2h comment + tiêu đề | ✅ | subtitle gộp vào `description` (xem FLAG-1) |
### 3.6 Mirror proof (Pattern 16-bis + SHA)
- `KhkkCreatePage.tsx` admin ⟂ user: `89dfa607061cd3d560dcbbdb8fbad157d38cae1786be3cd41a81bd8e9c1d1390` **BẰNG NHAU** (cặp `cp`, W2g).
- `types/khkk.ts` admin ⟂ user: `b26bf67492821b1003c3d523585e2ce563a26b04ef728867689911263e5a33d3` **BẰNG NHAU** — file **không sửa****đối chứng DƯƠNG** cho phép đo hash.
- **4-place:** ① page+types ✅ · ② `App.tsx:80` route `:typeCode` đã có (0 route mới) ✅ · ③ `menuKeys.ts` ×2 ✅ · ④ `Layout.tsx:188` staticMap **đã có sẵn từ K3 S164/S165** ⇒ chỗ-4 KHÔNG cần chạm (P8 đúng), regex Layout khớp regex page.
- **Lệch CÓ CHỦ ĐÍCH:** fe-user **không có** `ApprovalWorkflowsV2Page.tsx` + **0** nhánh `AwV2` trong `Layout.tsx` (màn cấu hình là của admin) ⇒ 8 const bên fe-user là mirror hằng-số thuần, không sinh menu.
## §4 — Nợ / FLAG cho lead
- 🔴 **FLAG-A (acceptance hỏng — cần sửa spec):** acceptance R-2 ghi *"`npm run build` ×2 app + **tsc 0-err**"*. Nếu ai chạy `npx tsc --noEmit` đúng nguyên văn thì **luôn xanh kể cả code sai** (§3.1). Đề nghị sửa acceptance thành `npx tsc -p tsconfig.app.json --noEmit`. Ảnh hưởng **mọi wave FE trước/sau**, không riêng S182.
- 🔴 **FLAG-B (spec tự mâu-thuẫn — đã chọn nhánh, xin lead ratify):** ô **#4 `allowReturnToDrafter`**. MỤC 2 dòng `d` (bảng thi-hành, có neo dòng) ghi *"hiển thị **state thật (thường TRUE)**"*; MỤC 3 bảng D3 ghi bản rút gọn *"#4 **forced-true**-disabled"*. Code đang theo **state thật** (`checked={entry.allowReturnToDrafter}`), vì:
(1) đo đĩa: mặc định `true`**4 lớp độc lập**`ApprovalWorkflow.cs:129 = true` · EF `HasDefaultValue(true)` · DTO `:232/:465` · FE hydrate `?? true` (`:213`) ⇒ thực tế **vẫn hiện TICK**, thoả acceptance A2 *"ô #4 tick-disabled"*;
(2) hard-code `checked={true}` sẽ **nói dối** đúng ca hàng row có `false` — cùng class lỗi mà chính D3=C cấm ở 6 ô kia (*"CẤM ép false"*), chỉ đảo dấu.
⇒ Đọc *"forced-true"***rút gọn hao nghĩa** của *"state thật (thường TRUE)"*. **Nếu lead thật sự muốn ép cứng `true`** thì nói 1 câu, sửa 1 dòng — nhưng nên biết là đánh đổi tính trung thực với DB.
- ⚠️ **FLAG-1 (P12, đã xử lý — không còn là nợ):** `@/components/PageHeader` **không có** prop `subtitle` (`title/description/actions`, cả 3 đều `ReactNode`). **Không thêm prop** — phụ đề nghiệp vụ gộp vào `description`. Lý do: PageHeader dùng chung toàn app, đẻ prop cho 1 trang là mở rộng API cho cả trăm call-site. (Bài S37, lặp lần 3 — mỗi lần lại tưởng có `subtitle`.)
- ⚠️ **Ngoài spec, đã khai:** `key={typeCode ?? 'all'}` trên `<TypePanel>` (`:401`) — remount khi đổi nhóm; thiếu nó thì `cloneFrom/editTarget` còn trỏ bản N1 trong khi `lockedCode` đã sang N3 ⇒ POST đẻ `KHKK-N3` mang ruột N1.
- ⚠️ **Nợ nhận-thức (không chặn):** 8 panel nhóm + panel `/ContractSigningPlan` **dùng CHUNG 1 ô cache** (queryKey không có `khkkGroup` — cố ý, vì lọc client-side trên payload đã tải trọn). Invalidate sau khi lưu làm tươi cả 9. Nếu sau này BE nhận `code=` làm tham số **request** thì BẮT BUỘC thêm `code` vào queryKey, nếu không 9 màn dùng chung cache sai CÂM.
- ⚠️ **Chưa chạy (ngoài tay lane FE):** acceptance A1/A2b/A3/A4 là **UAT động** (bấm thật trên 8 leaf, sửa-tại-chỗ ⇒ 204, tạo v02 N1 kiểm 7 nhóm không mất badge). Lane này chỉ chứng được phần **tĩnh**. A3 phụ thuộc BE lane (tắt active per-CODE) — 2 nửa phải test CÙNG nhau.
-**Không nợ build/mirror:** 0 lỗi TS ×2 app · SHA-pair 2/2 · 4-place 4/4 · 27/27 proof + 3 control.
## §5 — vá MAJOR-1 (reviewer pre-commit, lượt sau)
**Bệnh:** `title=` nằm trên chính `<input type="checkbox" disabled={isKhkk}>`. Chromium (Chrome/Edge = trình duyệt đích của owner) **nuốt sự kiện chuột của form-control disabled** ⇒ tooltip KHÔNG bao giờ bật; `<label>` bao ngoài không mang `title` nên rê vào chữ cũng câm. Quyết định D3=C của owner là *"mờ ô chưa nghe **+ tooltip**"* ⇒ mất vế tooltip thì admin chỉ còn 7 ô xám vô cớ. Nặng nhất là ô **#4 `allowReturnToDrafter`**: câu *"KHKK hiện luôn trả về người soạn khi Trả lại — cờ chưa phân nhánh"* là thông tin **DUY NHẤT** phân biệt #4 với 6 ô "chưa áp dụng" — câm ⇒ #4 trông y hệt 6 ô kia = **ngược nghĩa**.
**(a) Dời `title` từ `<input>` lên `<label>` — 8/8 site** (label không disabled nên hover chữ là hiện):
| # | cờ | title cũ ở input | title mới ở label |
|---|---|---|---|
| 1 | `allowReturnOneLevel` | :1361 | :1366 (label @:1364) |
| 2 | `allowReturnOneStep` | :1372 | :1379 (@:1377) |
| 3 | `allowReturnToAssignee` | :1383 | :1392 (@:1390) |
| 4 | `allowReturnToDrafter` | :1399 | :1410 (@:1408) |
| 5 | `allowApproverEditDetails` | :1410 | :1432 (@:1430) |
| 6 | `allowApproverEditBudget` | :1421 | :1445 (@:1443) |
| 7 | `allowApproverSkipToFinal` | :1443 ← **tiền lệ CÙNG BỆNH từ K3**, reviewer chỉ đích danh | :1469 (@:1467) |
| 9 | `allowApproverDelete` | :1471 | :1499 (@:1497) |
**(b) RIÊNG ô #4 — thêm chữ NHÌN THẤY ĐƯỢC** (`fe-admin/.../ApprovalWorkflowsV2Page.tsx:1421-1429`, ngay dưới `</label>` #4 @:1420): `{isKhkk && <div className="col-start-2 -mt-0.5 pl-4 text-[10px] leading-snug text-slate-500">{khkkDrafterHint}</div>}`. Thông tin phân-biệt **không được phụ thuộc hover**. Dùng `col-start-2` (không phải sibling thường) vì #4 nằm ô **hàng 2 cột 2** của `grid grid-cols-2`: sibling auto-place sẽ rơi xuống **cột 1** ⇒ đọc nhầm sang ô #3 và đẩy lệch 5 ô `col-span-2` phía dưới. `col-start-2` ghim dòng chữ đúng dưới ô của nó; 5 ô `col-span-2` kế tiếp vẫn auto-place xuống hàng mới, 0 reflow.
**Chuỗi #4 gom thành 1 const** `khkkDrafterHint` (`:722`, cạnh `khkkNotYetHint`) vì nay dùng ở **2 chỗ** (tooltip + chữ hiện) — 2 bản literal sẽ trôi lệch nhau. Kèm comment tại `:715-721` ghi rõ **vì sao** `title` phải ở label, để lần sau không ai dời ngược lên input rồi làm câm lại.
**Vùng CẤM — đo lại sau khi vá, xác nhận nguyên vẹn:**
- 9/9 `checked=` **byte-identical** với bản trước khi vá (`:1371 :1384 :1397 :1415 :1437 :1450 :1474 :1488 :1504`) — gồm cả `checked={isKhkk ? false : entry.allowApproverSkipToFinal}` (quyết định K3, giữ) và `checked={entry.allowReturnToDrafter}` (**FLAG-B §4 vẫn treo y nguyên, lượt này KHÔNG động**).
- Ô **#8 `allowApproverFinalize`** (`:1484-1492`): **0** `disabled`, **0** `title` — không chạm.
- `levelFlagsPayload` (`:258`, call `:880/:920`): không chạm. `isKhkk` (`:714`): không chạm.
**Proof — checker có RĂNG (control-âm):** script parse tag-owner của mọi `title=` trong file (`scratchpad/check-title-owner.js`).
- Bản đã ship: `title=` tổng **22** · tham chiếu `isKhkk` **8** · owner=`<label>` **8/8** · owner=`<input>` **0**.
- **CONTROL-ÂM:** tiêm lại 1 `title` vào `<input>` ô #1 ⇒ checker **nổ đúng 1** (`owner=<input> :1373`). ⇒ 0-hit ở bản thật là **im-vì-đúng**, không phải im-vì-câm.
**Build:** `npx tsc --noEmit -p tsconfig.app.json`**exit 0** (🔸 dùng `-p`; `npx tsc --noEmit` TRẦN biên dịch 0 file = xanh giả — đúng FLAG-A §4). `npm run build` fe-admin → **✓ built in 30.18s, 0 lỗi TS**; warning `>500 kB` + `INEFFECTIVE_DYNAMIC_IMPORT realtime.ts` + `@import` = **PRE-EXISTING**.
**Mirror:** `ApprovalWorkflowsV2Page.tsx` **chỉ tồn tại ở fe-admin** (fe-user: file không có, `ls pages/system | grep -i workflow` = 0, `grep khkkNotYetHint|khkkDrafterHint fe-user/src` = **0 hit**) ⇒ **không có cặp để mirror**, đúng lệch-có-chủ-đích đã khai §3.6. Lượt này chạm **đúng 1 file**.
**⚠️ FLAG-C (cùng lớp, KHÔNG chạm — xin lead quyết):** grep cả file thấy **1 site nữa cùng bệnh** ngoài phạm vi MAJOR-1: nút **"+ Thêm NV"** (`:1226-1248`) có `disabled={addDisabled}` + `title=` giải thích **lý do bị khoá** (*"Cấp N-1 phải có ≥1 NV trước"* / *"Hết NV khả dụng…"*) đặt **trên chính `<button disabled>`** ⇒ cùng Chromium suppress, admin không biết vì sao nút xám. Đây là bug **có sẵn từ trước S182**, không thuộc D3=C, và TASK ghi rõ *"1 việc DUY NHẤT — KHÔNG làm gì khác"***để nguyên, chỉ khai**. (Hai nút `title="Lên"/"Xuống"` `:1155/:1164` cũng disabled ở rìa nhưng title chỉ là **nhãn**, không mang thông tin lý-do ⇒ không cùng lớp thiệt hại.)

View File

@ -0,0 +1,119 @@
# INVEST-BE — KHKK 8 nhóm workflow admin mirror DuyetNcc (S182, Fable deep-pass)
> Trạng thái: ĐANG ĐO — đổ ruột liên tục. Mỗi claim kèm file:line.
## §1 Endpoint map trang approval-workflows-v2 (admin)
**Controller duy nhất:** `src/Backend/SolutionErp.Api/Controllers/ApprovalWorkflowsV2Controller.cs` — route `api/approval-workflows-v2` (:17), class-level `[Authorize]` trần (:18).
| Action | Verb+Route | Authz | file:line |
|---|---|---|---|
| Overview (list all types + versions + history) | `GET ?applicableType=&isUserSelectable=` | **authenticated-only, 0 policy** (CỐ Ý — Drafter cần list để pick lúc create, comment :10-15) | :21-26 |
| Tạo version mới | `POST` | `Workflows.Create` | :28-34 |
| Sửa-tại-chỗ (giữ Id/Version/IsActive) | `PUT {id}` | `Workflows.Create` | :45-51 |
| Ghim/Bỏ ghim (`IsUserSelectable`) | `PATCH {id}/user-selectable` | `Workflows.Create` | :56-62 |
| Xoá version | `DELETE {id}` | `Workflows.Create` | :64-70 |
**Tham số phân loại = `ApplicableType` (int enum), KHÔNG code-prefix.** Enum `ApprovalWorkflowApplicableType` @ `Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:53-71`: DuyetNcc=1 · DuyetNccPhuongAn=2 · Contract=3 · Proposal/Leave/Ot/Vehicle/ItTicket/Travel=4-9 · **ContractSigningPlan=10** (:70). Trang DuyetNcc = `?applicableType=1`; "NCC và Giải pháp" = 2; KHKK = 10.
**Handler Overview:** `Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` — filter generic `d.ApplicableType == typeEnum` (:123-127) + `IsUserSelectable` (:129-132); khi `applicableType=null` group theo `Enum.GetValues<>()` toàn bộ (:199-213) ⇒ **GENERIC, 0 hardcode chặn type-10**. Label map `AwLabels.Type` :90-98 CÓ entry type-10 "Kế hoạch ký kết HĐ" (:97) — nhưng label này là 1-per-type, KHÔNG phân biệt 8 nhóm N1-N8 (8 nhóm cùng type=10 sẽ gộp chung 1 `AwTypeSummaryDto` với 1 `Active` duy nhất — xem §2 hệ quả).
**9 checkbox = 9 cột `bool` per-Level row** trên `ApprovalWorkflowLevel` (`ApprovalWorkflow.cs:99-176`) — schema DÙNG CHUNG mọi ApplicableType:
| # | Checkbox UI | Flag | line |
|---|---|---|---|
| 1 | Trả về 1 Cấp trước | `AllowReturnOneLevel` (F1m1) | :120 |
| 2 | Trả về 1 Bước trước | `AllowReturnOneStep` (F1m2) | :123 |
| 3 | Trả về Người chỉ định | `AllowReturnToAssignee` (F1m3) | :126 |
| 4 | Trả về Drafter (default) | `AllowReturnToDrafter` (F1m4, default TRUE) | :129 |
| 5 | Sửa Section 2 lúc duyệt | `AllowApproverEditDetails` (F3) | :133 |
| 6 | Sửa Section ngân sách | `AllowApproverEditBudget` (F4) | :139 |
| 7 | Duyệt thẳng Cấp cuối | `AllowApproverSkipToFinal` (F2) | :147 |
| 8 | Duyệt là KẾT THÚC (không CEO) | `AllowApproverFinalize` (F5) | :158 |
| 9 | NV XÓA phiếu khi đến lượt | `AllowApproverDelete` (F6, Mig 68) | :173 |
**🔴 Create handler ĐÃ CÓ nhánh type-10 đích danh** (`ApprovalWorkflowV2AdminFeatures.cs:387`): `AllowApproverSkipToFinal = typeEnum != ContractSigningPlan && l.AllowApproverSkipToFinal`**ép-false F2 CHO RIÊNG type-10** (K3-test O-1: KHKK service 0 lần đọc cờ này, echo true = config-lie #78). `AllowApproverFinalize` từng ép-false @S161 nhưng ĐÃ GỠ @S165 (service KHKK port level-finalize) — comment :376-386.
**PUT sửa-tại-chỗ (S155 F-1):** T1 khung-không-đổi (tập Step.Order + Level.Order bất biến, :552-559 + :572-574 + :593-595 + :636-648) + T2 luật-chơi-không-đổi (Code/ApplicableType/CeoApprovalThreshold/F5/F2 = ECHO-only, lệch ⇒ 409, :539-550 + :598-603). Guard chữ-ký 2 tầng (:665-724): `CountLiveOpinionsAsync` + `ListRetainedOpinionsAsync` — cần kiểm 2 helper này có đọc `ContractSigningPlanLevelOpinions` không (xem §2/§4). Transaction rào @ :744-745.
**Deactivate-per-type khi POST:** `:339-343` — deactivate MỌI workflow `ApplicableType == typeEnum && IsActive`**only-ONE-active-per-TYPE. Với 8 nhóm KHKK cùng type=10: tạo version mới cho N2 sẽ TẮT IsActive của N1..N8 còn lại** ⇒ đây là site chặn #1 (xem §2).
### §1-bis Phần sau của AdminFeatures (line 800-1205)
- **Guard chữ-ký 3-site ĐÃ phủ KHKK 3/3:** `CountLiveOpinionsAsync` :961-965 + `ListRetainedOpinionsAsync` :1033-1038 + `PurgeOrphanOpinionsAsync` :1099-1102 đều có nhánh `ContractSigningPlanLevelOpinions` (site liệt-kê-thủ-công, comment :957-960 tự khai luật "module mới PHẢI vào CẢ 3"). `DetachTrackedOpinionsOn` :1122-1136 quét theo TÊN CỘT metadata ⇒ tự phủ (comment :1120 nói "7 bảng" — stale nhẹ, thực tế 8).
- **🔴 GAP changelog PUT sửa-tại-chỗ:** `:863-893` chỉ ghi vết vào `PurchaseEvaluationChangelogs` khi `ApplicableType ∈ {DuyetNcc, DuyetNccPhuongAn}`**KHKK type-10 bị SKIP** (comment :857-862 tự khai: `ContractSigningPlanChangelogs` là changelog PHIẾU, "wire nếu cần ở W2/W3" — CHƯA wire). Sửa workflow nhóm KHKK đang có phiếu treo ⇒ phiếu 0 vết, chỉ có `def.UpdatedAt` (:851).
- **🔴 GAP DELETE version 0 usage-check:** `DeleteAwDefinitionCommandHandler` :1193-1205 = load + `Remove` + Save, **KHÔNG kiểm phiếu đang pin** (comment :1187-1189 tự khai "Sau UAT ... cần check usage trước khi delete" — nợ từ Mig 22 era). Hard-delete; hành vi thực tế phụ thuộc FK của cột pin từng module (cần đo FK `ContractSigningPlans.ApprovalWorkflowId` — xem §2).
- **SetUserSelectable** :1173-1185 generic by-Id ⇒ type-10 dùng được 0-đổi.
## §2 Seed 8 workflow nhóm KHKK + phân loại
**Seed = `DbInitializer.SeedKhkkGroupWorkflowsAsync`** (`Infrastructure/Persistence/DbInitializer.cs:549-687`, K3 nhịp-1 S164/S165) — KHÔNG phải Mig 71 (Mig 71 chỉ AddColumn `ApprovalGroup` lên phiếu + backfill=1; workflow ROWS do DbInitializer đẻ lúc boot, INFRASTRUCTURE seed not-gated DemoSeed, comment :539).
- **Phân loại 8 nhóm = `Code` string `KHKK-N{n}`** (:609), n=1..8 (:607). TẤT CẢ 8 cùng `ApplicableType=ContractSigningPlan(10)` (:616). Name `"Duyệt KHKK — Nhóm {n}"` (:617). Version=1, `IsActive=true` + `IsUserSelectable=true` cả 8 (:615,:622-623).
- **Idempotent PER-CODE** `AnyAsync(w.Code == code)` (:610) — comment :540-542 tự khai vì sao KHÔNG dùng khuôn per-type của 6 seeder mẫu: 8 workflow dùng chung 1 type, check type sẽ chặn 7 nhóm còn lại.
- **Đội hình 3 trạm PRO→CCM→BGĐ** sao từ QT-DN-V2-001 prod (roster :554-585): PRO C1 4-người OR-of-N + C2 tra.bui · CCM C1 4-người + C2 chuong.phan **`AllowApproverFinalize=true`** (:576-579) · BGĐ C1 truong.nguyen. Resolve theo EMAIL, thiếu user ⇒ bỏ slot + warn không throw (:593-600), cấp rỗng ⇒ nén Order (:645), workflow 0-bước vẫn tạo giữ mã (:673-676).
- **8 nhóm hiện SỐNG CHUNG IsActive=true cùng type=10** ⇒ bất biến "only-ONE-active-per-type" của POST create (`ApprovalWorkflowV2AdminFeatures.cs:339-343`) ĐÃ VỠ từ lúc seed (8 active cùng type). Hệ quả: **POST tạo version mới cho BẤT KỲ nhóm nào sẽ deactivate CẢ 8** — site chặn đích danh #1.
- Sidebar 8 menu-row `AwV2_KhkkN1..N8` seed @ `DbInitializer.cs:1988-1995`, parent `ApprovalWorkflowsV2`; MenuKeys VÀO `All` (`MenuKeys.cs:246-249`) ⇒ 32 policy `AwV2_KhkkN{n}.{R,C,U,D}` tồn tại.
## §3 Runtime duyệt KHKK — bảng 9-flag × {PE-runtime, KHKK-runtime}
KHKK runtime = `Infrastructure/Services/ContractSigningPlanWorkflowService.cs` (W3 S161 + K3 S165). 4 action cố định `submit/approve/return/reject` (:74-75), KHÔNG có `targetPhase` param. OR-of-N `ResolveActingLevel` (:455-469), UPSERT `ContractSigningPlanLevelOpinions` (:477-513), con-trỏ ĐÔI reset-Cấp-1-khi-sang-Bước (:309-310), submit reset về Bước1/Cấp1 cả 2 lối vào (:198-202).
| # | Flag | PE-runtime | KHKK-runtime | Evidence KHKK |
|---|---|---|---|---|
| 1 | `AllowReturnOneLevel` | TÔN TRỌNG — `PurchaseEvaluationWorkflowService.cs:421` (switch `WorkflowReturnMode`) | **BỎ QUA** — return luôn = `TraLai` toàn phần | `ContractSigningPlanWorkflowService.cs:48-50` comment (c) "4 return-mode per-level CỐ Ý chưa đọc"; `ReturnOrRejectAsync:354-400` 0 đọc flag |
| 2 | `AllowReturnOneStep` | TÔN TRỌNG — `:422` | **BỎ QUA** — nt | nt |
| 3 | `AllowReturnToAssignee` | TÔN TRỌNG — `:423` | **BỎ QUA** — nt | nt |
| 4 | `AllowReturnToDrafter` | TÔN TRỌNG — `:424` (mode Drafter phải bật cờ) | **BỎ QUA (hành vi = luôn-cho)** — return về Drafter cho MỌI NV đúng lượt, kể cả cờ tắt | `ReturnOrRejectAsync` chỉ guard đúng-lượt (:379-380), không đọc cờ |
| 5 | `AllowApproverEditDetails` (F3) | TÔN TRỌNG — `PurchaseEvaluationDetailFeatures.cs:84` + `PurchaseEvaluationSupplierFeatures.cs:45` | **BỎ QUA / CHƯA WIRE** — KHKK Lines/Dossier edit = drafter-only Nháp/TraLai | `EnsureDraftEditable` `ContractSigningPlanFeatures.cs:295-303` (mọi đường ghi W2); 0 hit `AllowApproverEditDetails` trong ContractSigningPlan* |
| 6 | `AllowApproverEditBudget` (F4) | TÔN TRỌNG — `PurchaseEvaluationFeatures.cs:411` | **BỎ QUA / PE-semantics** (KHKK không có section NS; giá dòng = `ApprovedAmount ??= ProposedAmount` chốt tại finalize `:345-349`) | 0 hit trong ContractSigningPlan* |
| 7 | `AllowApproverSkipToFinal` (F2) | TÔN TRỌNG — `PurchaseEvaluationWorkflowService.cs:810` (+ Contract `:335`) | **CHƯA WIRE + BỊ ÉP-FALSE tại POST create cho type-10** | service 0 đọc (comment `:47` "giữ nguyên không port"); ép-false `ApprovalWorkflowV2AdminFeatures.cs:387` |
| 8 | `AllowApproverFinalize` (F5) | TÔN TRỌNG — `:870` (opt-out `applyLevelFinalize`) | **TÔN TRỌNG** — port @K3 S165, opt-out param mirror PE S96, set `EndedByLevelFinalize` runtime-flag (Mig 71) | `ContractSigningPlanWorkflowService.cs:276-289`; seed bật cho TP.CCM (`DbInitializer.cs:576-579,:655`) |
| 9 | `AllowApproverDelete` (F6) | TÔN TRỌNG — `PeSoftDeleteFeatures.cs:110-114` (3 rào: policy ∧ đúng-lượt ∧ F6) | **BỎ QUA / CHƯA WIRE** — delete KHKK không có nhánh approver-đúng-lượt (xem §3-bis delete handler) | 0 hit `AllowApproverDelete` trong ContractSigningPlan* |
- `CeoApprovalThreshold` (workflow-level, NGOÀI 9 checkbox): PE đọc; KHKK **CỐ Ý KHÔNG port** (NĐUQ human-gate vĩnh viễn, OG-3 — comment `:46-47`).
- DTO detail KHKK ĐÃ expose `AllowApproverFinalize` per-level (`ContractSigningPlanFeatures.cs:140,:1013,:1030`) — các flag khác không expose (PE expose đủ 9 @ `PurchaseEvaluationDtos.cs:124-138`).
### §3-bis Surprises quanh pin/đổi workflow (evidence create/update path)
- **Rào (v) group-match CHỈ chạy lúc CREATE:** `ContractSigningPlanFeatures.cs:479-486` regex `^KHKK-N([1-8])$` so `approvalGroup`; workflow code NGOÀI họ (type-10 code khác) → CHO QUA (comment :477-478).
- **🔴 UpdateDraft re-pin (:603-607) chỉ gọi `EnsureWorkflowTypeAsync` (type=10, :307-318), KHÔNG re-check group-match** ⇒ phiếu nhóm 3 tạo đúng KHKK-N3 rồi PUT draft re-pin KHKK-N5 = 200 OK; SubmitAsync cũng không re-check workflow-code↔group (`ContractSigningPlanWorkflowService.cs:169-187` chỉ check Lines↔group) ⇒ **bypass rào (v) 2-bước** — phiếu nhóm 3 chạy đội hình nhóm 5.
- FK `ContractSigningPlans.ApprovalWorkflowId` → ApprovalWorkflows = **Restrict vật lý** (`ContractSigningPlanConfiguration.cs:48-53`).
## §4 Gap-analysis BE → wave đề xuất
### §4.0 Authz đối chiếu 32 policy `AwV2_KhkkN{n}.{R,C,U,D}` (gotcha #85)
**Đo:** grep `AwV2_` toàn `SolutionErp.Api` = **0-hit** (control-dương cùng thước: `KeHoachKyKet` cùng path = 29 hit / 1 file ⇒ vắng thật). ⇒ **32 policy KHÔNG controller nào tiêu thụ** — chúng chỉ sống ở tầng menu-display (sidebar row + Permission matrix). Đây KHÔNG phải bug mới của KHKK: 2 key cũ `AwV2_DuyetNcc`/`AwV2_DuyetNccPhuongAn` (MenuKeys.cs:116-117) cũng 0 consumer BE — trang DuyetNcc hiện hành chạy đúng mô hình này rồi.
- **Key gate THẬT của trang admin (mọi type):** GET = authenticated-only · POST/PUT/PATCH/DELETE = `Workflows.Create` (§1). "Y như trang DuyetNcc" ⇒ **0-đổi-authz**: admin nào có `Workflows.Create` sửa được CẢ 8 nhóm (và cả PE/Contract workflows).
- **Trade-off nếu muốn per-group write-authz** (nhóm N1 chỉ ai được cấp N1 sửa): controller phải resolve workflow→Code→group rồi check `AwV2_KhkkN{n}.Update` — đổi hình authz của controller DÙNG CHUNG 9 type ⇒ blast radius mọi module; và theo #85 endpoint phải gate bằng key CỦA CHÍNH NÓ, không OR key con. **Quyết định thuộc owner/lead — không tự quyết**; mặc định rẻ + nhất quán = giữ `Workflows.Create`.
- 🔸 Menu-row 8 leaf cần `CanRead` grant per-role (KHKK root CỐ Ý không inherit — `GetMyMenuTreeQuery.cs` 4-case; seeder grant explicit) — tầng display độc lập tầng API (memory `permission_grant_two_layers`).
### §4.1 Disposition 4+1 site chặn
| # | Site | Hiện trạng | Đề xuất wave | Rủi ro |
|---|---|---|---|---|
| (a) | **only-ONE-active-per-TYPE** `ApprovalWorkflowV2AdminFeatures.cs:339-343` | POST version mới nhóm N2 ⇒ deactivate CẢ 8 (bất biến đã vỡ từ seed: 8 active cùng type-10). Blast thực tế: pick-list KhkkCreatePage lọc `isUserSelectable` KHÔNG lọc IsActive (`fe-admin/src/pages/khkk/KhkkCreatePage.tsx:97`) + runtime load by-Id ⇒ thiệt hại = Overview `Active` badge/summary + mọi chỗ đọc IsActive về sau | **P1 (khuyên):** đổi scope deactivate = `w.Code == request.Code` khi `typeEnum == ContractSigningPlan` (đúng semantic "1 active per chuỗi-version", 8 chuỗi độc lập). **P2:** đổi per-Code cho MỌI type — sạch hơn nhưng đổi hành vi PE/Contract (hiện tạo Code MỚI cùng type tự tắt Code cũ — có thể có chỗ dựa vào) | P1: if-đặc-thù type-10 (thêm 1 nhánh rẽ); P2: regression PE/Contract khó lường — cần grep chỗ đọc IsActive trước |
| (b) | **Changelog PUT skip type-10** `:863-893` | Sửa workflow nhóm có phiếu KHKK treo ⇒ phiếu 0 vết (comment :857-862 tự khai defer W2/W3) | Wire block mirror PE: query `ContractSigningPlans` pin `def.Id` + Phase ∉ {DaDuyet,TuChoi} → add `ContractSigningPlanChangelogs` (EntityType Workflow, khuôn `ContractSigningPlanWorkflowService.cs:546-557`) | Thấp — bảng phiếu-changelog có sẵn FK Cascade; chỉ thêm 1 nhánh else-if |
| (c) | **DELETE version 0 usage-check** `:1193-1205` | FK `ContractSigningPlans.ApprovalWorkflowId` **Restrict** (`ContractSigningPlanConfiguration.cs:50-53`) ⇒ data KHÔNG mất, nhưng `DbUpdateException` KHÔNG được map (`GlobalExceptionMiddleware.cs:32-39` chỉ map 5 app-exception, `_`**500 "Lỗi hệ thống"**). Phiếu xoá-MỀM vẫn còn row ⇒ vẫn chặn. Workflow từng dùng rồi unpin: opinions FK Restrict vào Levels cũng chặn cascade ⇒ 500 | Guard usage-check trong `DeleteAwDefinitionCommandHandler`: đếm pin per-module (PE `ApprovalWorkflowId`, Contract, Proposal, 4 WorkflowApps, KHKK — 8/9 module có cột pin) + đếm LevelOpinions (tái dùng khuôn `CountLiveOpinionsAsync` NHƯNG theo workflow-id) → 409 đích danh "N phiếu đang pin" | Trung bình — liệt-kê-thủ-công 8 module (đúng class lỗi comment :957-960 đã cảnh báo); nợ này của Mig 22 era, sửa 1 lần lợi mọi type |
| (d) | **Bypass rào (v) 2-bước** `ContractSigningPlanFeatures.cs:603-607` | UpdateDraft re-pin chỉ check type, không check group-match; Submit không re-check ⇒ phiếu N3 chạy đội hình N5 | Extract rào (v) (:479-486) thành `ContractSigningPlanScope.EnsureWorkflowGroupMatchAsync(db, awId, group)` → gọi ở CẢ Create + UpdateDraft; cân nhắc gọi thêm ở `SubmitAsync` làm chốt-cuối (khuôn 2-tầng như hạng-mục :179-187) | Thấp; chú ý phiếu cũ đã lỡ pin lệch trước vá sẽ kẹt submit — message phải chỉ việc cần làm |
| (e) | **Ép-false F2 type-10** `:387` | ĐÚNG theo chống config-lie #78 (service KHKK 0 đọc F2 — `ContractSigningPlanWorkflowService.cs:47`) | **GIỮ** cho tới khi có lệnh owner port F2. Nếu port: copy nhánh PE `PurchaseEvaluationWorkflowService.cs:797-810` vào service KHKK + gỡ ép-false + gỡ khoá FE **CÙNG WAVE** (đúng tiền lệ F5 @K3: port + gỡ ép-false + gỡ FE-lock 1 wave, comment :376-386) | Mở mà không port = đẻ lại #78; port mà quên gỡ ép-false = cờ chết chiều ngược |
### §4.2 Việc 0-đổi-BE (đã generic sẵn — wave FE nối thẳng)
1. GET Overview `?applicableType=10` trả đủ 8 nhóm × versions × 9-flag per-level (§1) — FE lọc client-side theo `code == "KHKK-N{n}"` (payload nhỏ; muốn sạch hơn có thể thêm query param `code` — optional, không blocker).
2. PUT sửa-tại-chỗ (thêm/bớt người, đổi NV, 7 cờ tại-chỗ, đổi tên/phòng) — T1/T2 + guard chữ-ký 3-site ĐÃ phủ KHKK (§1-bis).
3. PATCH user-selectable + DELETE (khi chưa pin) — generic by-Id.
4. Lịch sử versions (History list per code) — có sẵn trong Overview DTO.
5. Runtime approve/return/reject/submit KHKK + F5 finalize + inbox + notify — W3/K3 xong (§3).
6. Authz: giữ `Workflows.Create` = y-hệt trang DuyetNcc (§4.0).
### §4.3 Việc PHẢI đổi BE (gom wave)
- **Wave-BE-1 (nhỏ, an toàn):** (a)-P1 deactivate-per-Code cho type-10 · (d) group-match guard 2-site · (b) changelog type-10 · (c) usage-check DELETE → 409. 4 việc đều trong 2 file `ApprovalWorkflowV2AdminFeatures.cs` + `ContractSigningPlanFeatures.cs` (+1 helper Scope), 0 migration.
- **Wave-BE-2 (chỉ khi owner duyệt semantics):** port return-modes F1×4 / F3-analog (sửa Lines lúc duyệt) / F6 approver-delete / F2 skip-to-final vào KHKK runtime — mỗi cái là ĐỔI HÀNH VI DUYỆT, thuộc quyền owner (comment (c) service :48-50 đã khai chủ-đích chưa đọc). Nếu KHÔNG port: FE phải ẩn/disable các ô chưa-wire cho type-10 (tiền lệ `earlyFinalizeLocked` W1 — đã có khuôn), không thì mỗi ô tick = config-lie #78 mới.
## §5 Surprises ngoài đề bài
1. **8 link sidebar KhkkN{n} hiện đổ về trang landing chung**`Layout.tsx:188` route `/system/approval-workflows-v2/KhkkN{n}` nhưng `ApprovalWorkflowsV2Page.tsx` `TYPE_CODE_TO_INT` (:164-172) KHÔNG có key `KhkkN*``selectedTypeInt=undefined` → rơi về grid landing (:299-301,:323). Menu có, trang chưa phân nhóm — wave FE là phần nặng nhất của đề bài, BE gần xong.
2. **GET Overview 0-policy trả đủ 9-flag + CeoApprovalThreshold + tên/email toàn bộ approver cho MỌI user authenticated** (kể cả fe-user token) — cố ý từ Mig 22 cho Drafter pick (:10-15), nhưng payload nay giàu hơn nhiều so 2026-05; đáng để owner ngó lại (information-disclosure mềm).
3. **Roster 8 workflow = SNAPSHOT lúc seed theo email hardcode** (`DbInitializer.cs:554-585`) — QT-DN-V2-001 prod đổi người thì 8 nhóm KHÔNG tự theo; idempotent per-Code nên restart không re-sync (đúng thiết kế, nhưng dễ bị hiểu nhầm "sao đội hình không khớp DuyetNcc nữa").
4. **Bất biến only-1-active-per-type đã vỡ từ seed** (8 active type-10) mà Overview `Active = versions.FirstOrDefault(IsActive)` (:210) chỉ hiện 1 — 7 nhóm còn lại "tàng hình" khỏi ô Active của type-summary; FE per-group phải đọc theo Code, đừng tin ô `Active`.
5. `DetachTrackedOpinionsOn` comment "7 bảng" stale (thực 8 — quét metadata nên vẫn đúng hành vi) `ApprovalWorkflowV2AdminFeatures.cs:1120`.
6. KHKK delete phiếu allow-list ĐÃ rộng hơn PE: {Nháp, TuChoi, **TraLai**} (S177 owner) vs PE {DangSoanThao, TuChoi} + F6-đúng-lượt (`ContractSigningPlanFeatures.cs:1733-1738` vs `PurchaseEvaluationFeatures.cs:1404-1406` + `PeSoftDeleteFeatures.cs:110-114`) — mirror "nút XÓA ở màn duyệt" cho KHKK là F6-wire (wave-BE-2), không phải nới allow-list thêm.
## VERDICT
BE cho 8 workflow "Duyệt KHKK — Nhóm 1..8" đã **generic ~85%**: controller + 5 action + PUT sửa-tại-chỗ + guard chữ-ký + ghim/xoá + seed 8 nhóm (Code `KHKK-N{n}`, cùng ApplicableType=10) + runtime F5 finalize đều SỐNG. **4 site BE phải sửa** (wave-BE-1, 2 file, 0-mig): deactivate-per-Code type-10 (`:339-343`) · group-match guard thêm 2-site (`:603-607`+Submit) · changelog type-10 (`:863-893`) · usage-check DELETE→409 (`:1193-1205`). **6/9 checkbox hiện chỉ PE tôn trọng** — tick cho type-10 = cờ nằm im (#78-class): hoặc port runtime (wave-BE-2, cần owner duyệt semantics), hoặc FE khoá ô theo tiền lệ `earlyFinalizeLocked`. Authz giữ `Workflows.Create` y trang DuyetNcc; 32 policy `AwV2_KhkkN*` 0 consumer BE (đo + control-dương). Phần nặng còn lại là FE (8 route đổ landing chung).

View File

@ -0,0 +1,150 @@
# SUB-INVEST-DB (Fable deep-pass, lens DATABASE) — S182 KHKK AWF mirror NCC
> Đề bài: 8 workflow "Duyệt KHKK — Nhóm 1..8" cần đủ chức năng như "Duyệt NCC" trên approval-workflows-v2.
> Câu hỏi trung tâm: **schema hiện tại ĐỦ chưa hay cần mig mới?** Kỳ vọng "máy rộng UI hẹp" ⇒ 0 mig — PHẢI CHỨNG.
> Trạng thái file: đổ ruột liên tục (chống-#53). Khu chưa đo ghi `CHƯA ĐO`.
## §1 Schema V2 — cột thật 3 bảng (từ ModelSnapshot + entity)
Nguồn: `ApplicationDbContextModelSnapshot.cs:128-313` + `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs` (1 file chứa cả 3 entity).
### `ApprovalWorkflows` (snapshot :128-187)
| Cột | Kiểu | Ghi chú |
|---|---|---|
| Id | uniqueidentifier PK | BaseEntity |
| Code | nvarchar(100) NOT NULL | "QT-DN-V2-001" |
| Version | int | monotonic per Code |
| ApplicableType | int | enum 1..10 — **ContractSigningPlan=10** (`ApprovalWorkflow.cs:70`) |
| Name | nvarchar(200) NOT NULL | |
| Description | nvarchar(1000) NULL | |
| IsActive | bit | |
| ActivatedAt | datetime2 NULL | |
| IsUserSelectable | bit | Mig 25 — Ghim/Bỏ ghim |
| CeoApprovalThreshold | decimal(18,2) NULL | Mig 53 — ngưỡng gói CEO (PE semantic) |
| CreatedAt/CreatedBy/UpdatedAt/UpdatedBy | | BaseEntity audit |
Index: `(ApplicableType, IsActive)` + **UNIQUE `(Code, Version)`** (:181-184). 🔴 **KHÔNG có IsDeleted**`ApprovalWorkflow : BaseEntity` (entity :21), KHÔNG AuditableEntity ⇒ 0 soft-delete, 0 query-filter trên cả 3 bảng V2.
### `ApprovalWorkflowSteps` (snapshot :274-313)
Id · ApprovalWorkflowId (FK) · Order int · Name nvarchar(200) NOT NULL · DepartmentId uniqueidentifier NULL (hint) · audit 4 cột. Index: `DepartmentId` + `(ApprovalWorkflowId, Order)`.
### `ApprovalWorkflowLevels` (snapshot :189-272)
Id · ApprovalWorkflowStepId (FK) · Order int (Cấp, OR-of-N rows cùng Order) · Name nvarchar(200) NULL · ApproverUserId NOT NULL (1 row = 1 NV slot) · audit 4 cột · **9 cờ bit Allow\***. Index: `ApproverUserId` + `(ApprovalWorkflowStepId, Order)`.
### §1b Bảng ánh xạ 9 checkbox ảnh ↔ cột — **9/9 CÓ ĐỦ, 0 cột thiếu**
Per-NV = per-row `ApprovalWorkflowLevels` (mỗi row = 1 ApproverUserId ⇒ cờ trên row chính là cờ per-NV).
| # | Checkbox (ảnh YC-013) | Cột thật | Default (snapshot) | Mig gốc |
|---|---|---|---|---|
| 1 | Trả về 1 Cấp trước | `AllowReturnOneLevel` | false (:220-223) | 29 |
| 2 | Trả về 1 Bước trước | `AllowReturnOneStep` | false (:225-228) | 29 |
| 3 | Trả về Người chỉ định | `AllowReturnToAssignee` | false (:230-233) | 29 |
| 4 | Trả về Drafter (mặc định) | `AllowReturnToDrafter` | **true** (:235-238) | 29 |
| 5 | Sửa Section 2 | `AllowApproverEditDetails` | false (:205-208) | 29 |
| 6 | Sửa Section ngân sách | `AllowApproverEditBudget` | false (:200-203) | 30 |
| 7 | Duyệt thẳng Cấp cuối | `AllowApproverSkipToFinal` | false (:215-218) | 31 |
| 8 | Duyệt là KẾT THÚC | `AllowApproverFinalize` | false (:210-213) | 58 |
| 9 | XÓA phiếu | `AllowApproverDelete` | false (:195-198) | 68 |
Cột nằm trên bảng V2 DÙNG CHUNG mọi ApplicableType ⇒ workflow KHKK (ApplicableType=10) **lưu được cả 9 cờ ngay hôm nay, 0 mig**. ⚠️ Ranh giới lens: cột TỒN TẠI ≠ service KHKK ĐỌC cờ (semantic #3 đọc `PeLevelOpinions`, #5/#6 đọc section PE — entity comment `ApprovalWorkflow.cs:119-173`; KHKK service có honor không = lens BE).
## §2 Versioning — đọc từ `ApprovalWorkflowV2AdminFeatures.cs` (KHÔNG đoán từ UI)
- **Lưu version:** cùng `Code` nhiều row, `Version int` monotonic; **UNIQUE (Code, Version)** (snapshot :183-184). "Archived" KHÔNG có cột riêng — chỉ là `IsActive=false` (nhãn UI suy từ bit).
- **Tạo phiên bản mới** = `CreateAwDefinitionCommandHandler` (:326-): INSERT row mới `Version = max(Code)+1` (:334-337), `IsActive=true, IsUserSelectable=true` (:352-353). 🔴 **LANDMINE cho KHKK:** deactivate cũ chạy theo **`ApplicableType`** chứ KHÔNG theo Code — `Where(w.ApplicableType == typeEnum && w.IsActive)` set `IsActive=false` TẤT CẢ (:339-343). Với DuyetNcc (1 code/type) đúng; với KHKK **8 code chung type=10** ⇒ tạo version mới cho `KHKK-N1` sẽ **tắt IsActive của cả KHKK-N2..N8**. Chính seeder đã né bẫy same-class ở check idempotent (`DbInitializer.cs:540-542` "check theo type sẽ chặn 7 nhóm còn lại") nhưng Create handler CHƯA vá — vá-tệp-A-quên-tệp-B. **Fix = code BE (đổi scope deactivate theo Code), 0 mig.**
- **Sửa tại chỗ** = `UpdateAwDefinitionCommandHandler` (:515-900): UPDATE in-place ĐÚNG row + **diff Levels theo `Level.Id` — GIỮ NGUYÊN Id** (:561-634, "F-3 CẤM delete-then-insert") ⇒ không đẻ Id mới, không mồ-côi con trỏ (khác nỗi lo F17/S155 — máy Update nay ĐÃ TỒN TẠI, memory cũ "WF-V2 0 lệnh Update" STALE). Cấm in-place: đổi Code/ApplicableType/CeoThreshold (:539-550), thêm/bớt/đảo Bước (:553-574), đổi Order dòng (:593), toggle 2 cờ đổi-đường-đi F5/F2 (:598-603), bỏ người giữ F5/F2 (:627-632), làm rỗng Cấp (:639-647) — tất cả 409 + gợi ý tạo version mới. 7 cờ còn lại + đổi người + thêm/bỏ người trong Cấp = hợp lệ in-place (:789-797).
- **Guard chữ ký khi bỏ/đổi người:** 2 tầng quét **CẢ 8 bảng LevelOpinions** gồm `ContractSigningPlanLevelOpinions` — count-sống (:961-965) + retained (:1033-1038); transaction rào (:744-745, :895-899). ⇒ máy sửa-tại-chỗ **ĐÃ KHKK-aware ở tầng guard**.
- ⚠️ Vết changelog khi sửa-tại-chỗ chỉ ghi cho **PE** (`ApplicableType ∈ {DuyetNcc, DuyetNccPhuongAn}` :863-892); KHKK khai-giới-hạn defer F-6b (:857-862) — bảng `ContractSigningPlanChangelogs` ĐÃ TỒN TẠI (Mig 69) ⇒ wire = code BE thuần, **0 mig**.
- **Ghim/Bỏ ghim** = `SetAwUserSelectableCommandHandler` (:1173-1185): flip 1 bit, generic mọi type — KHKK dùng ngay.
- **Xoá version** = `DeleteAwDefinitionCommandHandler` (:1193-1205): **HARD delete** (`Remove` + BaseEntity không soft-delete), **0 guard usage** — comment tự khai ":1187-1189 Sau UAT … cần check usage trước khi delete". Phiếu pin (PE/HĐ/KHKK Restrict) hoặc LevelOpinion tồn tại ⇒ SqlException 547 → 500. Version không ai đụng ⇒ xoá sạch (cascade Steps→Levels).
## §3 KHKK wiring — Mig 70/71 + ContractSigningPlans pin/pointer/finalize-flag
### FK map V2 (snapshot :6210-6323) — quyết định risk xoá
| Quan hệ | Behavior |
|---|---|
| Steps → ApprovalWorkflows | **Cascade** (:6229-6233) |
| Levels → Steps | **Cascade** (:6212-6216) |
| Levels.ApproverUserId → Users | Restrict (:6218-6222) |
| Steps.DepartmentId → Departments | Restrict (:6235-6238) |
| `ContractSigningPlans.ApprovalWorkflowId` → ApprovalWorkflows | **Restrict** (:6245-6248) |
| `Contracts.ApprovalWorkflowId` → | Restrict (:6327-6330) |
| `PurchaseEvaluations.ApprovalWorkflowId` → | Restrict (:6755-6758) |
| `ContractSigningPlanLevelOpinions.ApprovalWorkflowLevelId` → Levels | **Restrict** (:6297-6301) |
| `ContractSigningPlanApprovals.ApprovalWorkflowLevelId` | ⚠️ **LOOSE Guid — 0 FK** (cột + index :416/:455 nhưng relationship section :6251-6260 chỉ có 1 HasOne về Plan Cascade) |
⇒ Hard-delete 1 workflow version bị DB CHẶN (SqlException 547) nếu: (a) bất kỳ phiếu PE/HĐ/KHKK pin nó, HOẶC (b) bất kỳ LevelOpinion (PE/HĐ/KHKK) trỏ Level của nó — cascade Steps→Levels đụng Restrict của LevelOpinions.
### Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` (`20260731085624_*.cs`)
- **KHÔNG có bảng catalog-nhóm-duyệt riêng** — "catalog" trong tên mig = gắn `ContractSigningPlanLines.CatalogEntryId` (loose-Guid, KHÔNG FK, :70-74) + `TenHangMuc` denorm (:76-81) trỏ về `ContractCatalogEntries` (Mig 70, bảng danh mục 86 dòng).
- `ContractSigningPlans.ApprovalGroup int NOT NULL default 0` + backfill vô-điều-kiện `SET = 1` (:47-61) — nhóm duyệt 1..8 lưu TRÊN PHIẾU dạng int trần (0 bảng, 0 FK, 0 CHECK constraint 1..8).
- `EndedByLevelFinalize bit` runtime-flag (:63-68) — ghost-window khai trước, K3 tiêu thụ.
- Index-swap: UNIQUE `(PlanId, SupplierId)` → UNIQUE `(PlanId, SupplierId, CatalogEntryId)` filtered `[IsDeleted]=0` (:43-45, :93-98). Down có bẫy F-8 (:132-141).
### `ContractSigningPlans` pin + pointer (snapshot :315-408) — mirror ĐỦ khuôn PE
`ApprovalWorkflowId Guid?` (pin, Restrict) · `CurrentWorkflowStepIndex int?` (:336) · `CurrentApprovalLevelOrder int?` (:333) · `EndedByLevelFinalize bit` (:351) · `ApprovalGroup int` (:321) · `Phase int` · AuditableEntity (IsDeleted/DeletedAt/DeletedBy — phiếu SOFT-delete, ≠ workflow) · UNIQUE filtered `MaKeHoach` (:395-397) · index `(ApprovalGroup, Phase)` (:403).
### 8 workflow nhóm = 8 row `ApprovalWorkflows` phân loại thế nào?
- Seed `SeedKhkkGroupWorkflowsAsync` (`DbInitializer.cs:549-687`): 8 row `Code="KHKK-N{n}"` n=1..8, `Version=1`, `ApplicableType=10`, `Name="Duyệt KHKK — Nhóm {n}"`, `IsActive=true`, `IsUserSelectable=true` (:609-625). 3 Bước PRO→CCM→BOD, Cấp OR-of-N, riêng Cấp 2 CCM (a Chương) `AllowApproverFinalize=true` (:575-579, :655). Idempotent **PER-CODE** (:610) — cố ý né bẫy check-theo-type (:540-542). Resolve người theo EMAIL, thiếu user → bỏ slot + log, workflow 0 bước vẫn TẠO (:544-548).
- **Liên kết nhóm↔workflow = QUY ƯỚC CODE, không phải cột/FK:** phiếu chốt `ApprovalGroup` lúc tạo (default 1, validator 1..8 `ContractSigningPlanFeatures.cs:415-418, :461`); rào (v) regex `^KHKK-N([1-8])$` trên Code của workflow user pick — lệch nhóm = 409, Code NGOÀI họ (type-10 chung) **cho qua** (:475-486). `ContractCatalogEntry.ApprovalGroup int` 1..8 (validator `ContractCatalogFeatures.cs:120`, KHÔNG CHECK constraint) quyết nhóm của hạng mục; line-gán guard khớp nhóm phiếu (`:691, :802`).
- Con trỏ duyệt KHKK: service `ContractSigningPlanWorkflowService.cs` GHI đủ cặp `CurrentWorkflowStepIndex` (0-based index list sorted) + `CurrentApprovalLevelOrder` (1-based) (:201-202, :295, :309-310) + `EndedByLevelFinalize=true` nhánh finalize (:275-282 — ghost-window Mig 71 ĐÃ ĐÓNG, K3 live) + null-out khi terminal (:323-324). Đọc pointer từ CHÍNH workflow đã pin, KHÔNG đụng `IsActive` (:422-446, có bounds-check) ⇒ **version Archived (IsActive=false) không làm phiếu treo kẹt**.
- Mig 72 `AddMaHangMucToContractSigningPlanLines` (`20260806070242`): +`MaHangMuc nvarchar(20)` denorm + backfill từ catalog Code, idempotent (:13-34) — mig CUỐI trên đĩa.
## §4 Dữ liệu thật LocalDB Dev (sqlcmd -E, đo 2026-08-08)
| Phép đo | Kết quả |
|---|---|
| `ApprovalWorkflows` theo type | chỉ type {3,4,5,6,7,9} × 1 row (6 seeder mẫu). **Type 1/2 (DuyetNcc): 0 row. Type 10 (KHKK): 0 row** |
| 8 workflow `KHKK-N*` | **0/8 trên Dev local** — seeder idempotent sẽ tạo ở lần boot API kế; QT-DN-V2-001 v01-v04 là dữ liệu PROD (admin tạo tay), local không có |
| `ContractSigningPlans` | 0 phiếu · 0 LevelOpinions · 0 Approvals |
| `__EFMigrationsHistory` Dev | top = `20260731085624` (**Mig 71**) — **Mig 72 trên đĩa CHƯA áp** |
| `__EFMigrationsHistory` Design | top = Mig 71 — cùng drift |
**Câu "8 row đủ chưa, IsUserSelectable bao nhiêu" trên PROD: CHƯA ĐO** (SSH chết khi load SQL-client — S134/S148/S177). Local suy được từ seed-code: lần boot prod sau commit K3 đã tạo 8 row `IsActive=1, IsUserSelectable=1`; ảnh sidebar YC-013 hiện 8 mục = seed đã chạy prod. Query bàn giao implementer chạy trên prod:
```sql
SELECT Code, Version, IsActive, IsUserSelectable FROM ApprovalWorkflows WHERE ApplicableType=10 ORDER BY Code, Version;
SELECT ApprovalGroup, Phase, COUNT(*) n FROM ContractSigningPlans GROUP BY ApprovalGroup, Phase;
SELECT COUNT(*) FROM ContractSigningPlanLevelOpinions;
```
## §5 Verdict migration + risk
### VERDICT: **0 MIGRATION.** Schema V2 dùng chung đã đủ 100% cho mirror
1. 9/9 checkbox = 9 cột bit có sẵn trên `ApprovalWorkflowLevels` (§1b), bảng dùng chung mọi ApplicableType.
2. Versioning v01→v04 + Archived: UNIQUE `(Code, Version)` + `IsActive` bit — đã phục vụ đúng cơ chế này cho `QT-DN-V2-001` v04; `KHKK-N{n}` là Code như mọi Code.
3. Ghim = `IsUserSelectable` sẵn; Sửa-tại-chỗ = máy Update diff-by-Id ĐÃ KHKK-aware ở guard chữ ký (3/3 site :961/:1033/:1099); Xoá = máy Delete sẵn (thiếu guard là thiếu CODE, không thiếu cột).
4. Pin + con trỏ + finalize-flag phiếu KHKK: đủ từ Mig 69/71, service đã ghi/đọc sống.
5. Hạng-mục/nhóm: `ApprovalGroup` (phiếu + catalog) + regex Code — 0 nhu cầu cột mới.
### Nhưng 3 QUẢ MÌN CODE-LEVEL lộ ra từ scan DB-semantics (fix = BE code, vẫn 0 mig)
| # | Mìn | Bằng chứng | Hệ quả nếu bỏ qua |
|---|---|---|---|
| M1 | **Create-new-version deactivate theo `ApplicableType`** — tạo v02 cho `KHKK-N1` sẽ `IsActive=false` cả active-version của N2..N8 (8 Code chung type=10; giả định 1-Code-1-type vỡ) | `ApprovalWorkflowV2AdminFeatures.cs:339-343`; đối chiếu seeder đã né same-class `DbInitializer.cs:540-542` | Overview 7 nhóm kia mất "Active" (thành Archived-oan); mọi consumer lọc IsActive theo type sai |
| M2 | **Overview API `Active = FirstOrDefault(IsActive)` per-TYPE + KHÔNG có param Code** — type 10 có 8 chuỗi version trộn chung 1 `History`, `Active` lấy 1 trong 8 | `:107-109` (query 2 param), `:203-212` | Trang admin 8 mục sidebar không tách được version-list per nhóm nếu FE không tự group by Code |
| M3 | **Delete = hard-delete 0 guard usage** — phiếu pin (Restrict) / opinion (Restrict) → SqlException 547 → 500 thô; version rác thì xoá êm | `:1187-1204`; FK snapshot :6245-6248, :6297-6301 | UX 500 khó hiểu thay 409; data KHÔNG mất (DB chặn được) |
### Risk integrity/concurrency chi tiết
- **Sửa workflow đang có phiếu KHKK treo:** an toàn cấu trúc — mọi mutation đổi-đường-đi bị 409; đổi người/cờ giữ nguyên `Level.Id` ⇒ pointer (StepIndex, LevelOrder) vẫn giải được; guard chữ ký quét cả `ContractSigningPlanLevelOpinions` (sống + retained); transaction rào :744/:899. **Lỗ nhỏ:** vết sửa KHÔNG ghi vào `ContractSigningPlanChangelogs` (chỉ PE :863-892, defer F-6b) — phiếu KHKK đang chạy bị sửa quy trình mà lịch sử phiếu im lặng.
- **Xoá version Archived có phiếu treo/xong trỏ vào:** bị Restrict chặn (pin `ApprovalWorkflowId`) — kể cả phiếu soft-deleted (row còn, FK còn). Pointer phiếu KHÔNG chết vì không xoá được. Riêng `ContractSigningPlanApprovals.ApprovalWorkflowLevelId` LOOSE (0 FK) — nếu tương lai có đường hard-delete phiếu/level thì history giữ Guid treo (hiện không có đường đó).
- **Race 2 admin cùng "Tạo version mới" cùng Code:** UNIQUE (Code,Version) đỡ — kẻ thua 2601/2627 → 500 (không silent-dup). Chấp nhận được cho màn quản trị.
- **Race tạo 2 phiếu cùng (PE, nhóm):** rào handler check-then-insert, CỐ Ý không filtered-unique (`ContractSigningPlanFeatures.cs:456-460`, gate-K2 F-7) — giữ nguyên quyết định cũ, ngoài scope.
## §6 Đề xuất wave DB
- **W-DB-0 (prep local, bắt buộc trước test):** `dotnet ef database update` ×2 DB (Dev + Design đang kẹt Mig 71, đĩa 72) + boot API 1 lần để `SeedKhkkGroupWorkflowsAsync` đẻ 8 row local. 0 mig mới.
- **W-DB-1 (BE, đi cùng wave mirror):** vá M1 — scope deactivate trong `CreateAwDefinitionCommandHandler` đổi `ApplicableType``Code` (hoặc Code∧Type); test: tạo v2 `KHKK-N1` ⇒ N2..N8 `IsActive` bất biến + case DuyetNcc regression.
- **W-DB-2 (BE):** overview/API thêm chiều Code (param `code=` hoặc group per-Code trong `AwTypeSummaryDto`) để 8 trang nhóm đọc đúng chuỗi version của mình.
- **W-DB-3 (BE, khuyến nghị):** guard lịch sự cho Delete — pre-count pin (PE/HĐ/KHKK/5 office) + LevelOpinions → 409 kể tên hồ sơ (tái dùng khuôn `CountLiveOpinionsAsync`), thay 547→500.
- **W-DB-4 (BE, optional):** wire changelog KHKK khi sửa-tại-chỗ (bảng `ContractSigningPlanChangelogs` sẵn — mirror khối PE :863-892).
- **Verify prod (bàn giao implementer):** 3 query §4.
## §7 Surprises ngoài đề bài
1. **Local DB drift:** Mig 72 committed trên đĩa (2026-08-06) nhưng CẢ Dev lẫn Design local dừng Mig 71 — pattern committed-but-unapplied-local; test wave nào chạm Lines sẽ lệch schema nếu quên update.
2. **Dev local 0 row type-1/2 (DuyetNcc)** — workflow NCC prod tạo tay qua UI, không seed ⇒ mọi test local về trang DuyetNcc cần tự tạo workflow trước.
3. Memory cũ S155 "WF-V2 KHÔNG có lệnh Update" đã STALE — `UpdateAwDefinitionCommand` (sửa-tại-chỗ diff-by-Id, giữ Id Level) đã land sau đó, guard rất dày.
4. Seeder KHKK resolve người theo EMAIL cứng (11 email prod `DbInitializer.cs:556-584`) — DB thiếu user (local!) ⇒ workflow RỖNG 0 bước vẫn được tạo + log warning; trang designer local sẽ thấy 8 workflow trống người, KHÔNG phải bug.
5. `HaveSequentialOrders` (Create :306-316) — validator jagged-levels per-step chỉ chạy path Create; Update path dùng bộ guard riêng (T1 set-equality) — 2 bộ luật song song cùng file, drift-risk nếu wave sau chỉnh 1 quên 1.
---
*Đo: ModelSnapshot + entity + 2 handler-file + DbInitializer + Mig 69/70/71/72 + sqlcmd Dev/Design. Prod: CHƯA ĐO (SSH giới hạn). ~62K token.*

View File

@ -0,0 +1,87 @@
# sub-invest-fe-fable — S182 KHKK AWF-v2 mirror NCC (lens FE UI, "ko lệch")
> Fable deep-pass đơn lẻ, /fable-real investigator-codebase. Đề: 8 mục sidebar "Duyệt KHKK — Nhóm 1..8" phải ra ĐÚNG trang `system/approval-workflows-v2/DuyetNcc` khuôn ảnh anh gửi. 0 sửa code.
## §1 Trang DuyetNcc render từ đâu (route + page + KIND_CONFIG + dialog)
**Route:** `fe-admin/src/App.tsx:79-80``/system/approval-workflows-v2` + `/system/approval-workflows-v2/:typeCode` → cùng 1 component `ApprovalWorkflowsV2Page` (named export, `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` 1355 dòng).
**KIND map (KHÔNG phải KIND_CONFIG object đầy đủ kiểu S29/S35 — chỉ 2 Record thưa):**
- `TYPE_CODE_TO_INT` `:164-172` = `{ DuyetNcc:1, DuyetNccPhuongAn:2, Contract:3, ContractSigningPlan:10 }`**0 entry `KhkkN*`**.
- `DEFAULT_CODE_BY_TYPE` `:173-180` = `{1:'QT-DN-V2-001', 2:'QT-DN-PA-V2-001', 3:'QT-HD-V2-001', 10:'QT-KHKK-V2-001'}` (chỉ gợi-ý ô Mã).
- Nhãn hiển thị KHÔNG có bảng FE — lấy từ BE `applicableTypeLabel` (`AwLabels.Type`, `ApprovalWorkflowV2AdminFeatures.cs:97` có entry type-10 "Kế hoạch ký kết HĐ").
**Cơ chế render `:286-353`:** `selectedTypeInt = TYPE_CODE_TO_INT[typeCode]`; unknown typeCode ⇒ `undefined``currentType=null` ⇒ render **LANDING GRID** mọi type (`:323-343`) thay vì panel. Panel thật = `TypePanel` `:357-459` (active card + "Lịch sử versions" + nút "Tạo quy trình mới") — mỗi panel khoá vào ĐÚNG 1 `applicableType`, active = `type.active` (BE tính), history render **filter `!d.isActive`** (`:419,:426`).
**Cột nút phải (khuôn ảnh):** `DefinitionCard:577-612` — Sửa tại chỗ `:587` / Tạo phiên bản mới `:596` / Ghim-Bỏ ghim `:606` / Xoá version `:610`. Badge: Đang áp dụng `:490` · Cho user chọn `:502` · Ngưỡng CEO `:506-513`. Badge NS PRO/CCM per NV `:560-565` (display-only suy từ role).
**Dialog Sửa tại chỗ / Tạo mới:** cùng component `Designer:620-1354`, `isEdit = editTarget !== null` `:638`; title "Sửa tại chỗ — {code} v{NN} (giữ nguyên phiên bản)" `:868`. Khối cam "QUYỀN DUYỆT {TÊN}" `:1228-1329` = **9 checkbox** map field TS (`EditLevelEntry:100-123` / API `LevelDto:37-62`):
| # | Label UI | Field |
|---|---|---|
|1| Trả về 1 Cấp trước | `allowReturnOneLevel` |
|2| Trả về 1 Bước trước | `allowReturnOneStep` |
|3| Trả về Người chỉ định | `allowReturnToAssignee` |
|4| Trả về Drafter (mặc định) — tick sẵn | `allowReturnToDrafter` (default true `:231`) |
|5| Chỉnh sửa **Section 2 (Hạng mục/NCC/Báo giá)** | `allowApproverEditDetails` `:1275` |
|6| Chỉnh sửa Section ngân sách | `allowApproverEditBudget` `:1284` |
|7| Duyệt thẳng Cấp cuối | `allowApproverSkipToFinal` — 🔴 **DISABLED khi type=10** `:1292-1302` (K3: service KHKK chưa port SkipToFinal, BE ép-false) |
|8| Duyệt là KẾT THÚC quy trình (xanh emerald) | `allowApproverFinalize` `:1307-1315` |
|9| Cho phép NV này XÓA phiếu (đỏ rose) | `allowApproverDelete` `:1320-1328` |
9 cờ gửi qua 1 hàm chung `levelFlagsPayload:255-267` (2 call-site POST `:820` + PUT `:788` — chống silent-drop F6/F-7 S155). Rule-box xanh cuối dialog `:1343-1350`. Ô "Ngưỡng CEO" `:944-961` render cho MỌI type (kể cả 10).
## §2 8 mục KHKK sidebar hiện map đi đâu (4-chỗ pattern 16-bis)
| Chỗ | File:line | Trạng thái |
|---|---|---|
| 1. BE const + `All` | `MenuKeys.cs:127-134` + `:248-249` | ✅ 8 key `AwV2_KhkkN1..8` VÀO `All` (⇒ 32 policy) |
| 2. BE menu-row seed | `DbInitializer.cs:1988-1995` | ✅ label "Duyệt KHKK — Nhóm {n}", parent `ApprovalWorkflowsV2` (root :1981 dưới System), order 3..10, icon FileSignature; Admin auto-grant qua `SeedAdminPermissionsAsync` (comment :1984-1987) |
| 3. FE `lib/menuKeys.ts` mirror | `fe-admin:31-32` / `fe-user:31-32` | ❌ chỉ có `AwV2_DuyetNcc(+PhuongAn)`**0 KhkkN**. VÔ HẠI runtime: sidebar render BE-tree + `resolvePath` string thô; 0 call-site `can('AwV2_*')`; route `/system/approval-workflows-v2` **KHÔNG PermissionGuard** (`App.tsx:79-80` route trần). Optional mirror-completeness |
| 4. FE `resolvePath` | `fe-admin/src/components/Layout.tsx:188` | ✅ `/^KhkkN[1-8]$/``/system/approval-workflows-v2/KhkkN{n}` (K3 S164/S165, CHỈ nới họ `AwV2_*`) |
**Bấm vào hôm nay ra gì:** route khớp `App.tsx:80``ApprovalWorkflowsV2Page`, nhưng `TYPE_CODE_TO_INT['KhkkN3'] = undefined``currentType=null`**LANDING GRID mọi type** (`:323-343`, header "Quy trình duyệt (Mới)" + hint "chọn loại từ menu"). **CẢ 8 mục ra CÙNG landing — KHÔNG ra khuôn DuyetNcc panel. Đây chính là "lệch".** (Không phải ComingSoon, không 404.)
**Sibling:** root AwV2 có leaf 1-2 = DuyetNcc/PhuongAn (`:1982-1983`); KHÔNG có leaf `AwV2_Contract` seed (Layout `:178` chỉ phục vụ deep-link).
## §3 Delta "ko lệch" — cần thêm gì + hardcode-PE-label sites
**Danh tính nhóm = `Code`, KHÔNG phải ApplicableType.** 8 workflow seed `SeedKhkkGroupWorkflowsAsync` (`DbInitializer.cs:549-625`): `Code="KHKK-N{n}"`, **CÙNG `ApplicableType=10`**, Version=1, `IsActive=true` cả 8, `IsUserSelectable=true`, 3 trạm PRO→CCM→BGĐ sao roster Duyệt NCC prod. BE guard pin: regex `^KHKK-N([1-8])$` phải khớp `plan.ApprovalGroup` (`ContractSigningPlanFeatures.cs:479-486`). FE helper sẵn **2 app identical**: `khkkGroupWorkflowCode(n)='KHKK-N{n}'` + `khkkGroupMenuLabel(n)` (`fe-{admin,user}/src/types/khkk.ts:159-168`). Precedent slice-by-code đã chạy ở fe-user `WorkflowMatrixViewPage.tsx:84-99` (K4c: client-side filter `wf.code === khkkGroupWorkflowCode(g)`, không thêm trục group vào queryKey).
**Delta CHÍNH — 1 file `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx`** (page đã generic theo `:typeCode`; KHÔNG thêm route/menu/BE-seed):
1. `:164-172` — thêm chiều parse: `const khkkKindMatch = typeCode?.match(/^KhkkN([1-8])$/)``khkkGroup: 1..8 | null`; `selectedTypeInt = khkkGroup ? 10 : TYPE_CODE_TO_INT[typeCode]` (overview query giữ nguyên `applicableType=10`).
2. `:299-301``currentType` cho kind nhóm = **synthetic slice**: từ `types.find(t=>t.applicableType===10)` lấy `versions = history.filter(d => d.code === khkkGroupWorkflowCode(g))`; `active = versions.find(v=>v.isActive) ?? null`; `applicableTypeLabel = 'Duyệt KHKK — Nhóm {g}'` (menu-label BE `:1988`) — **D4** anh chọn nhãn này hay nhãn nghiệp vụ `khkkGroupMenuLabel(g)` = "{g}. Thiết bị, máy móc…" (đề xuất: title menu-label + phụ đề nghiệp vụ).
3. `TypePanel:357` + `Designer:620` — prop mới `lockedCode?: string`: Designer `defaultCode → lockedCode ?? DEFAULT_CODE_BY_TYPE[...]` (`:650-651`), ô Mã `disabled={isEdit || !!lockedCode}` (`:927`), POST gửi `lockedCode ?? code` (`:798`). **Thiếu khoá này thì "Tạo quy trình mới" từ panel nhóm 3 với mã tự gõ sẽ đẻ version rơi khỏi slice → biến mất khỏi panel** (và không được guard `:482` nhận là workflow nhóm).
4. Không đổi gì khác của khuôn: 4 nút phải, badge, Lịch sử versions, dialog 9-checkbox, rule-box — dùng nguyên `TypePanel`/`DefinitionCard`/`Designer` (đúng "ko lệch"). SkipToFinal đã tự disable cho type-10 (`:1292-1302`) — 8 kind thừa hưởng vì cùng applicableType=10.
**Hardcode-label sites trong khuôn chung (đánh dấu — KHÔNG tự quyết):**
- `:1275` "Cho phép chỉnh sửa **Section 2 (Hạng mục/NCC/Báo giá)** lúc đang duyệt" — từ vựng PE. KHKK service **KHÔNG đọc cờ này** (0-hit `AllowApproverEditDetails` trong `ContractSigningPlan*.cs`).
- `:1284` "Section ngân sách" — tương tự, 0-hit `AllowApproverEditBudget`.
- `:944-961` ô "**Ngưỡng giá trị gói CEO**" render mọi type — KHKK service **CỐ Ý KHÔNG port** `CeoApprovalThreshold` (`ContractSigningPlanWorkflowService.cs:46-47`: NĐUQ = human-gate vĩnh viễn, OG-3) ⇒ nhập cho kind KHKK = config-lie class #78. Khuôn disable-có-tooltip sẵn ở `:1292`.
- Badge "✎ NS PRO/NS CCM" (`:560-565`) — suy từ role PE-budget; hiển thị trên panel KHKK như đang hiển thị cho type-10 hiện nay.
- **Răng thật của 9 checkbox với KHKK hôm nay: CHỈ #8 `AllowApproverFinalize` sống** (`ContractSigningPlanWorkflowService.cs:276`); 4 cờ Trả-về không đọc — Trả lại KHKK = TOÀN PHẦN luôn-được (`:48,:354-362`); Delete/EditDetails/EditBudget/SkipToFinal: 0-hit. **D3:** giữ nguyên khuôn (đúng "ko lệch", như panel ContractSigningPlan đang render) hay disable 7 ô chết theo khuôn SkipToFinal — anh/lead chốt; mặc định nghiêng GIỮ (uniform, cờ nằm chờ port).
## §4 fe-user — IsUserSelectable tại create KHKK + designer admin-only proof
- **Tạo phiếu KHKK KHÔNG có dropdown chọn quy trình** — workflow GÁN CỨNG theo nhóm (owner S174): `fe-user/src/pages/khkk/KhkkCreatePage.tsx:96-116` query `applicableType=10`**filter `w.isUserSelectable` `:104`**`autoWorkflow = find(code === 'KHKK-N{group}')` `:113-116`; thiếu mã ⇒ CHẶN tạo + báo "Chưa có quy trình KHKK-N{n}" (`:128,:155`). `fe-admin/src/pages/khkk/KhkkCreatePage.tsx` mirror cùng khuôn (`:97-100`). ⇒ **Bỏ ghim (`IsUserSelectable=false`) 1 workflow nhóm = CHẶN TẠO PHIẾU nhóm đó** ở cả 2 app — nút Ghim/Bỏ ghim trên 8 panel mới có hệ quả nghiệp vụ thật.
- Consumer thứ 2 `fe-user/src/pages/khkk/KhkkDetailPage.tsx:214-227` = bridge KHKK→HĐ, query `applicableType=3` lọc `isUserSelectable && isActive` — không liên quan nhóm.
- **Designer admin-only — CHỨNG:** grep `approval-workflows-v2` toàn `fe-user/src` = chỉ API-consumer (Create/Detail/Matrix/Proposal/WorkflowApp) — **0 page designer, 0 route `/system/approval-workflows-v2`**, Layout fe-user **0 nhánh `AwV2_*`** (grep 0-hit ngoài `lib/menuKeys.ts:31-32` const chết). fe-user chỉ có view read-only `pages/pe/WorkflowMatrixViewPage.tsx` (`/khkk/workflow-matrix?type=10&group=n`, K4c slice cùng convention code).
## §5 Wave FE đề xuất + acceptance
**W-FE1 (fe-admin, 1 file):** `ApprovalWorkflowsV2Page.tsx` — 4 chạm §3 (parse kind `:164-172` · synthetic slice `:299-301` · `lockedCode` xuyên `TypePanel:345-350/:357/:447-455``Designer:620/:650/:798/:927` · title). Import từ `types/khkk.ts` (đã có sẵn trong fe-admin). Ước ~60-90 dòng diff.
**W-FE2 (optional):** `fe-{admin,user}/src/lib/menuKeys.ts` +8 const `AwV2_KhkkN*` (mirror-completeness, 0 hệ quả runtime).
**SHA-mirror 2 app: KHÔNG áp dụng** — trang system Designer là fe-admin-only (chứng §4); `types/khkk.ts` 2 app đã identical sẵn, không chạm.
**BE:** 0 bắt buộc cho khuôn hiển thị; **D1 (khuyến nghị làm cùng wave):** `ApprovalWorkflowV2AdminFeatures.cs:339-343` deactivate-per-TYPE — sửa thành per-Code khi code khớp `^KHKK-N[1-8]$` (hoặc per-Code toàn cục — semantic đổi, anh chốt).
**Acceptance đo được:**
1. Login admin → sidebar Hệ thống Quy trình duyệt (Mới) hiện 10 leaf; bấm "Duyệt KHKK — Nhóm 3" → URL `/system/approval-workflows-v2/KhkkN3` → header "Quy trình duyệt (Mới): Duyệt KHKK — Nhóm 3" + card `KHKK-N3 v01` badge "Đang áp dụng"+"Cho user chọn" + 3 Bước (PRO 4NV+TP·CCM 4NV+TP·BGĐ CEO) + cột 4 nút phải + "Lịch sử versions"/"Tạo quy trình mới". 8/8 leaf → 8 slice khác nhau, hết landing grid.
2. "Sửa tại chỗ" → dialog title "Sửa tại chỗ — KHKK-N3 v01 (giữ nguyên phiên bản)", per-NV khối cam "QUYỀN DUYỆT …" 9 checkbox 2 cột, "Trả về Drafter (mặc định)" tick sẵn, dòng xanh KẾT THÚC + dòng đỏ XÓA phiếu, SkipToFinal mờ + tooltip, rule-box cuối.
3. "Tạo quy trình mới" từ panel nhóm: ô Mã khoá `KHKK-N3` → lưu ra v02 nằm ĐÚNG panel nhóm 3; (nếu D1 làm) badge "Đang áp dụng" 7 nhóm khác KHÔNG mất.
4. Regression: `/DuyetNcc` `/DuyetNccPhuongAn` `/Contract` `/ContractSigningPlan` render như cũ; fe-user tạo phiếu nhóm 3 vẫn auto-pin `KHKK-N3` (không đổi API).
## §6 Surprises ngoài đề
1. 🔴 **Panel `ContractSigningPlan` (đích của `Khkk_WfView` admin, `Layout.tsx:40`) hôm nay GIẤU 7/8 workflow nhóm:** BE `Active = FirstOrDefault(IsActive)` per-type (`ApprovalWorkflowV2AdminFeatures.cs:210`, tie-break Version DESC không xác định giữa 8 bản v1) + FE history filter `!d.isActive` (`:419,:426`) ⇒ 8 bản đều active thì 1 hiện card, 7 KHÔNG hiện đâu cả. Slice-by-code của wave này vá đúng bệnh; cân nhắc re-point `Khkk_WfView` hoặc để panel aggregate liệt theo code.
2. 🔴 **Bom deactivate-per-TYPE** (`:339-343`): bấm "Tạo quy trình mới"/"Lưu + kích hoạt" cho BẤT KỲ mã nào thuộc type-10 ⇒ `IsActive=false` CẢ 8 nhóm (seeder không re-set — idempotent per-Code `:610` skip). Runtime tạo phiếu KHÔNG hỏng (pin theo `IsUserSelectable`, guard type `EnsureWorkflowTypeAsync:307-318` không đòi IsActive) nhưng badge/active-slice sai toàn bộ. = D1.
3. Seeder KHKK resolve người duyệt THEO EMAIL, thiếu user ⇒ bỏ slot/cấp/bước + log warning (`DbInitializer.cs:544-548,:597-600`) — DB dev thiếu người thật thì 8 panel có thể hiện bước rỗng/thiếu NV; không phải bug FE.
4. `AwV2_Contract` không có menu-leaf seed (chỉ deep-link `Layout.tsx:178`) — sibling lệch nhẹ, không thuộc scope.
5. Route FE `/system/approval-workflows-v2` KHÔNG PermissionGuard (App.tsx:79-80) — 2-tầng gotcha #82: hiển thị dựa menu-grant, authz thật ở BE controller.

View File

@ -0,0 +1,153 @@
# REVIEW-1 (Fable, adversarial ĐÚNG-SAI) — S182 khkk-awf-mirror-ncc
> Vai: `/fable-real reviewer` #1/2 — lens SAI/SÓT/MÂU-THUẪN trên spec DRAFT + 3 invest.
> Trạng thái: HOÀN TẤT (3 đợt đo trực tiếp trên code + findings + wave-final).
## VERDICT
**REVIEW-1: PASS_WITH_FIXES — 2C/5M** (+5m)
- Kiến trúc spec ĐÚNG HƯỚNG, đa số claim load-bearing HELD khi đo lại trên code (History-full-list, deactivate-site, PUT-guards, rào-v, delete-0-guard, middleware, seeder, 0-consumer AwV2_). Verdict không phải FAIL vì không có finding nào lật kiến trúc — nhưng 2C (F1 unbuildable W1b, F2 IgnoreQueryFilters) PHẢI vào spec final trước khi execute, 5M nên vào cùng đợt.
- C: **F1** (W1b helper internal ⟂ Submit Infrastructure — không biên dịch được như viết) · **F2** (W1d thiếu `IgnoreQueryFilters` ⇒ claim "hết 500" sai ở ca phổ biến nhất).
- M: **F3** (5 office-pin LOOSE 0-FK — DB-invest M3 sai, W1d là rào duy nhất) · **F4** (khoá công thức W1a + belt + test code-lạ) · **F5** (KHKK-create không check IsActive — răng nút Ghim mô tả sai sau wave) · **F6** (D3=C: ô #4 phải forced-true, không mờ-chưa-có) · **F7** (ô Ngưỡng CEO tách khỏi D3, disable vô điều kiện).
- m: F8 (khai mâu-thuẫn 3 lens) · F9 (baseline 645⟂644 — đo tươi) · F10 (môi trường acceptance #3) · F11 (audit pin-lệch tồn đọng) · F12 (hint lockedCode · notify-parity · comment `:339` · invalidate-keys OK).
## Checklist việc (8 mục từ lệnh)
- [x] 1. W1a deactivate-per-Code — đọc `:326-398` + grep IsActive consumer toàn BE/Office → F4, kết luận P1 đủ (P2 không cần — 0 consumer server-side dựa active-per-type-10)
- [x] 2. W2b synthetic slice — BE `:199-216` + FE `:286-353` → HELD (History = full list; slice đúng shape; queryKey/invalidate OK)
- [x] 3. W2c lockedCode — PUT Code-echo 409 `:539-541` HELD; đường rơi-khỏi-slice còn lại = POST panel khác (chấp nhận, per-Code fix F4 khiến vô hại) → F12a hint
- [x] 4. W1b group-match Submit — Submit `:163-213` không re-check; extract-helper UNBUILDABLE → F1; phiếu pin lệch tồn đọng → F11
- [x] 5. W1d DELETE — đếm từ Domain+snapshot: 8 cột pin, chỉ 3 FK, 5 LOOSE; ItTicket không pin → F2, F3
- [x] 6. D3 chấm A/B/C — A: giữ #78 6-ô + đẻ "sleeping-config" khi port sau; B: nặng, thuộc owner (service comment `:48-50` tự khai); C: khả thi có tiền lệ NHƯNG phải theo danh-sách F6 (#4 forced-true) + F7 tách CEO-box. Không chấm thay anh — cả 3 sống, C rẻ+trung thực nhất, A đúng "ko lệch" nhất (khai trade-off)
- [x] 7. Mâu thuẫn 3 lens → F8 (a/b/c)
- [x] 8. Sót chung → F12 (notify parity-PE không làm · invalidate keys ĐO OK · optimistic không dùng — trang này invalidate-only)
## GHI NHẬN SAU KHI ĐỌC 4 PIN (trước khi mở code)
- Spec DRAFT 68 dòng, 3 invest đọc đủ. Nghi vấn dựng sẵn để đo:
- (i) W2b: BE `History` trong `AwTypeSummaryDto` chứa TẤT CẢ version hay LOẠI active? Nếu loại → `versions.find(v=>v.isActive)` chết → active=null giả. PHẢI đọc `:199-213`.
- (ii) W1a P1: scope `w.Code == request.Code` CÓ kèm `ApplicableType == typeEnum` không? UNIQUE(Code,Version) là GLOBAL — Code trùng xuyên-type khả thi về schema.
- (iii) FE-invest D1 đề xuất scope theo REGEX `^KHKK-N[1-8]$`, BE-invest P1 theo typeEnum==10 — 2 công thức KHÁC NHAU (code `QT-KHKK-V2-001` rơi vào nhánh nào?). Spec chọn P1 mà không khai lệch lens.
- (iv) W1b: rào (v) "code NGOÀI họ → CHO QUA" — extract giữ nguyên lỗ này? Phiếu ChoDuyet đã pin lệch TRƯỚC vá không bị đụng — cần SQL audit prod, spec chưa có.
- (v) W1d: module pin loose-Guid (không FK) → hard-delete THÀNH CÔNG vật lý → dangling pin. Danh sách FK-Restrict của DB-invest chỉ có 3 (PE/Contract/KHKK) — WorkflowApps pins = FK hay loose? PHẢI đếm ModelSnapshot.
- (vi) D3=C: ô #4 Trả-về-Drafter hành-vi = LUÔN-CHO dù cờ tắt → mờ kiểu "chưa áp dụng" là NÓI DỐI CHIỀU NGƯỢC; phải hiển thị forced-true.
- (vii) Acceptance #3 (tạo v02 N1) chạy Ở ĐÂU? Prod = mutate config thật, local = workflow rỗng người. Không có activate-endpoint để rollback IsActive.
## ĐO TRỰC TIẾP (đợt 1 — AdminFeatures:80-420 + FE page:150-364)
- **HELD (i):** `AwTypeSummaryDto.History` = FULL list mọi version (BE `:206-211``versions` dùng cho CẢ `Active` lẫn `History`) ⇒ synthetic slice W2b `history.filter(code)` + `.find(isActive)` ĐÚNG shape. Ordering: query `OrderByDescending(Version)` ⇒ slice giữ Version DESC per code — đủ cho render.
- **HELD:** deactivate `:340-343` đúng như invest tả: `Where(ApplicableType == typeEnum && IsActive)`. Comment dòng 339 tự khai "only ONE active per type".
- **Đo thêm:** `nextVersion` `:334-337` đã tính theo **Code** (per-Code sẵn) — chỉ deactivate là per-type ⇒ vá P1 là nhất quán hoá scope với chính hàm.
- **Đo FE:** queryKey `['approval-workflow-v2-overview', selectedTypeInt]` (`:292`) — 8 kind nhóm sẽ share key với panel ContractSigningPlan (cùng 10) = cache chung, invalidate `:348` prefix-match toàn key ⇒ mutation từ panel nhóm tự làm tươi panel type-10 và ngược lại. KHÔNG cần key mới.
- **Đo FE:** `DEFAULT_CODE_BY_TYPE[10]='QT-KHKK-V2-001'` (`:179`) — nút "Tạo quy trình mới" từ panel ContractSigningPlan (không lockedCode) vẫn đề xuất code NGOÀI-họ type-10. Sau P1 (scope theo typeEnum==10 ⇒ per-Code) code lạ này KHÔNG tắt 8 nhóm. Nếu executor lỡ implement theo công-thức FE-invest D1 (regex `^KHKK-N[1-8]$`) thì code lạ rơi nhánh per-type ⇒ NUKE cả 8 nhóm — 2 công thức KHÁC HÀNH VI, spec phải khoá công thức.
## ĐO TRỰC TIẾP (đợt 2 — guards/delete/FK/middleware/scope/fe-user)
- **HELD:** PUT T2 Code-echo 409 (`:539-541`) · CEO-echo (`:547-550`) · F5/F2-echo (`:598-603`) · ô Mã FE `disabled={isEdit}` (`:927`) · CEO box `disabled={isEdit}` only (`:954` — CREATE-mode mở cho MỌI type kể cả 10).
- **HELD:** DELETE handler `:1196-1204` = load+Remove+Save, 0 guard; comment `:1187-1189` tự khai nợ. Middleware chỉ map 5 app-exception (`GlobalExceptionMiddleware.cs:32-40`), `_` → 500.
- 🔴 **MỚI — pin FK thật:** grep `HasForeignKey("ApprovalWorkflowId")` snapshot = **4 hit** (Steps-Cascade + KHKK/Contract/PE-Restrict). Domain có **8 cột pin** (thêm Proposal/Leave/Ot/Vehicle/Travel — ItTicket KHÔNG có) ⇒ **5 module Office pin = LOOSE 0-FK** ⇒ DELETE hôm nay với office-pin THÀNH CÔNG vật lý → dangling pin. DB-invest M3 "data KHÔNG mất (DB chặn được)" = SAI cho 5 module này. W1d usage-check là guard DUY NHẤT cho office.
- 🔴 **MỚI — query filter:** PE (`:84`) + Contract (`:44`) + KHKK (`:55`) đều `HasQueryFilter(!IsDeleted)` ⇒ đếm-pin naive sẽ BỎ SÓT phiếu xoá-mềm (FK vẫn chặn 547) ⇒ W1d PHẢI `IgnoreQueryFilters()` cho 3 module này, không thì đúng ca "đã xoá phiếu rồi mà vẫn 500" tồn tại tiếp.
- **HELD:** 8 bảng `*LevelOpinion` (8 FK `ApprovalWorkflowLevelId`) khớp claim "CẢ 8".
- 🔴 **MỚI — W1b unbuildable như viết:** `ContractSigningPlanScope` = `internal static` (`ContractSigningPlanFeatures.cs:263`); `InternalsVisibleTo` CHỈ có `SolutionErp.Infrastructure.Tests` (csproj:25). Submit KHÔNG có Application-handler (comment `:19` — controller gọi thẳng service Infrastructure) ⇒ helper internal KHÔNG gọi được từ `SubmitAsync`. Executor phải chọn: public-guard-class mới (Application) / inline-duplicate / InternalsVisibleTo-Infrastructure. Spec chưa khai.
- **HELD:** `ContractSigningPlanEntityType.Workflow = 4` tồn tại + changelog entity đủ cột ⇒ W1c 0-enum-extend. Phase filter {DaDuyet=3, TuChoi=99} đúng tên enum.
- **HELD:** rào (v) `:479-486` cho-qua code ngoài-họ LÀ CHỦ ĐÍCH (comment :477-478). UpdateDraft `:603-607` chỉ EnsureWorkflowTypeAsync. Submit `:163-213` chỉ check Lines↔group.
- 🔴 **MỚI — IsActive server-side cho KHKK-create = KHÔNG CÓ:** bridge KHKK→HĐ đã vá đòi `IsActive ∧ IsUserSelectable` (vá-3, `CreateContractFromSigningPlanFeatures.cs:141-147` — chính comment nêu "khuôn ContractFeatures thiếu 2 vế ⇒ forge POST pin được quy trình retired"); Create/UpdateDraft phiếu KHKK thì CHỈ check type ⇒ pin bản archived = 200. fe-user KhkkCreatePage `:104` filter `isUserSelectable` KHÔNG lọc `isActive`, `:114` `find(code)` ăn theo ordering Version-DESC. Hệ quả SAU W1a: (a) tạo v02 xong, v01 vẫn selectable=true (POST không đụng cờ này) — auto-pin trúng v02 chỉ NHỜ ordering; (b) "Bỏ ghim v02" ⇒ rơi về v01 archived im lặng, KHÔNG chặn tạo như spec mô tả răng nút Ghim.
- **Grep IsActive consumer (W1a regression):** Office features 0 lọc IsActive trên ApprovalWorkflows (mọi hit IsActive = Users/MeetingRooms/OtPolicies/Catalog) · V1 `WorkflowDefinitions.IsActive` không liên quan · consumer THẬT của IsActive-V2: Overview `Active` (display) + bridge type-3 vá-3 + FE dropdowns (PE/Contract create lọc client-side). KHÔNG AI resolve "active-per-type-10" server-side ⇒ P1 đổi scope type-10 an toàn, P2 không cần.
## ĐO TRỰC TIẾP (đợt 3 — service/seeder/status/FE-designer)
- **HELD:** service KHKK comment `:36-65` tự khai (c) 4 return-mode CỐ Ý chưa đọc + "mở thêm mode là đổi hành vi duyệt, thuộc quyền lead" · finalize `:276-289` = #8 sống thật · Submit là entry-point controller-gọi-thẳng (`:60-62` — xác nhận KHÔNG có MediatR handler cho Submit).
- **HELD:** seeder `DbInitializer.cs:539-625` đúng như invest (idempotent per-Code `:610`, roster email prod, `IsActive=true + IsUserSelectable=true` `:622-623`, thiếu user → bỏ slot + warn).
- **HELD (control):** grep `AwV2_` trong `SolutionErp.Api` = **0 hit** — khớp claim "32 policy 0-consumer BE".
- **Đo FE Designer:** "Tạo quy trình mới" (`TypePanel:413`) set `cloneFrom = type.active` ⇒ ở panel nhóm (synthetic slice) code state tự prefill `KHKK-N{g}` từ card active — lockedCode là RÀO chống sửa tay + chống case active=null (rơi về `DEFAULT_CODE_BY_TYPE[10]='QT-KHKK-V2-001'` `:650`). Archived card cũng đủ 4 nút (`:426-443`) — slice pass-through by-Id OK.
- **Đo STATUS.md:** canonical dòng 8 = **"644 test PASS (45D+599I — CI #444 KHỚP local)"** @S168; mốc sống S179 không ghi đổi test. Spec W4 ghi "baseline 645" — lệch 1, không rõ nguồn.
## FINDINGS
> Ký hiệu: `[C]` phải sửa spec trước khi execute · `[M]` lỗ/claim-sai phải vá trong spec · `[m]` khai/polish. Mỗi finding: chỗ sai (spec/invest) → bằng chứng file:line → cách vá.
**F1 [C] — W1b không biên dịch được như viết (helper internal ⟂ Submit nằm Infrastructure).**
Spec W1b: *"extract rào (v) → `EnsureWorkflowGroupMatchAsync(db, awId, group)`; gọi ở Create + UpdateDraft + Submit"*. Đo: `ContractSigningPlanScope` = `internal static` (`ContractSigningPlanFeatures.cs:263`); `InternalsVisibleTo` duy nhất = `SolutionErp.Infrastructure.Tests` (`SolutionErp.Application.csproj:25`); Submit KHÔNG có Application-handler — controller gọi thẳng service Infrastructure (`ContractSigningPlanFeatures.cs:19` + `ContractSigningPlanWorkflowService.cs:60-62`) ⇒ helper để trong Scope thì site thứ 3 KHÔNG gọi được, executor sẽ tự chế đường khác ngoài review.
**Vá:** tạo `public static class ContractSigningPlanWorkflowGuards` (Application/ContractSigningPlans, file cạnh Scope) chứa helper; Create/UpdateDraft (Application) + SubmitAsync (Infrastructure — đã reference Application) cùng gọi. KHÔNG dùng `InternalsVisibleTo(Infrastructure)` (mở toàn bộ internal surface 2 assembly) · KHÔNG inline-duplicate (drift 3 bản regex).
**F2 [C] — W1d claim "hết cảnh 547 → 500" là claim-mạnh-hơn-fix: thiếu `IgnoreQueryFilters()`.**
PE + Contract + KHKK đều có `HasQueryFilter(!IsDeleted)` (`PurchaseEvaluationConfiguration.cs:84` · `ContractConfiguration.cs:44` · `ContractSigningPlanConfiguration.cs:55`). Đếm-pin naive sẽ KHÔNG THẤY phiếu xoá-mềm, nhưng FK vật lý vẫn chặn (row còn) ⇒ đúng ca PHỔ BIẾN NHẤT (drafter xoá phiếu nháp/TraLai — allow-list KHKK S177 — rồi admin xoá version "không ai dùng") vẫn 547→500. Test trong spec ("DELETE version có pin ⇒ 409") PASS mà bug ship — acceptance đo hình-dạng không đo hành-vi (@S179 class).
**Vá:** mọi count pin/opinion trong guard dùng `IgnoreQueryFilters()` (3 module có filter; 5 office không filter — đếm thẳng); opinions đếm CẢ row phiếu-đã-xoá (FK không quan tâm IsDeleted). Test bổ sung: phiếu KHKK đã xoá-mềm pin version → DELETE 409.
**F3 [M] — DB-invest M3 "data KHÔNG mất (DB chặn được)" SAI cho 5 module Office — pin LOOSE 0-FK; W1d là guard DUY NHẤT.**
Đo snapshot: `HasForeignKey("ApprovalWorkflowId")` = **4 hit** (Steps-Cascade `:6231` + KHKK `:6247` + Contract `:6329` + PE `:6757`). Domain có **8 cột pin** (`Proposal/LeaveRequest/OtRequest/VehicleBooking/TravelRequest` thêm vào 3 module trên; ItTicket KHÔNG có — "8/9" của BE-invest ĐÚNG). ⇒ DELETE hôm nay với phiếu Office pin (0 opinions) **thành công vật lý** → dangling pin → phiếu treo chết lúc load workflow. Spec W1d đếm đủ 8 module (đúng), nhưng rationale kế thừa claim sai của DB-invest — executor đọc M3 có thể thu hẹp còn 3 module FK "vì DB chặn rồi".
**Vá spec:** sửa rationale W1d thành 2 vế: (a) 3 module FK = nâng UX 547→409; (b) 5 module Office = **chống mất-toàn-vẹn dữ liệu** (không có FK đỡ). Test BẮT BUỘC ca office: Leave/Proposal phiếu pin → DELETE 409 (hôm nay = 200-xoá-êm).
**F4 [M] — W1a: 2 công thức scope KHÁC HÀNH VI giữa 2 invest, spec phải KHOÁ công thức + thiếu 1 test + thiếu belt.**
BE-invest P1 = `typeEnum == ContractSigningPlan ⇒ scope Code`; FE-invest D1 = *"per-Code khi code khớp `^KHKK-N[1-8]$`"*. Khác nhau ở code type-10 NGOÀI-họ (`QT-KHKK-V2-001` — chính là `DEFAULT_CODE_BY_TYPE[10]` `:179` mà panel ContractSigningPlan đề xuất): công thức BE → per-Code (an toàn); công thức FE-regex → rơi nhánh per-type → **NUKE cả 8 nhóm**. Spec chọn đúng (typeEnum) nhưng khai "3 lens hội tụ" — che lệch; executor đọc FE-invest có thể implement regex.
**Vá:** (a) KHOÁ công thức trong spec: `w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive` (vế `ApplicableType` = belt: UNIQUE(Code,Version) là GLOBAL nên 1 Code có thể span 2 type qua version khác nhau — hiếm nhưng rẻ để chặn); (b) thêm test thứ 3: POST type-10 code-lạ `QT-KHKK-V2-001` ⇒ IsActive 8 nhóm bất biến; (c) ghi chú P1 nhất quán với chính hàm — `nextVersion` `:334-337` đã per-Code sẵn.
**F5 [M] — KHKK-create KHÔNG check `IsActive` server-side ⇒ sau wave này, nút Ghim mất răng như spec mô tả + pin-bản-archived = 200.**
Đo: Create/UpdateDraft phiếu KHKK chỉ `EnsureWorkflowTypeAsync` (type) + rào (v) — KHÔNG đòi IsActive/IsUserSelectable; đối chứng bridge KHKK→HĐ ĐÃ vá đúng lỗ này cho type-3 (vá-3, `CreateContractFromSigningPlanFeatures.cs:141-147`, comment tự khai "forge POST pin được quy trình retired"). FE `KhkkCreatePage` fe-user `:104` filter `isUserSelectable` KHÔNG lọc `isActive`, `:114-116` `find(code)` ăn may theo ordering Version-DESC của BE. Hệ quả CHỈ LỘ SAU W2 (mở đường tạo v02): (a) v01 sau deactivate vẫn `IsUserSelectable=true` (POST `:343` không đụng cờ này) ⇒ "Bỏ ghim v02" làm auto-pin **rơi về v01 archived im lặng** thay vì CHẶN tạo — câu regression của spec (*"Bỏ ghim ⇒ CHẶN TẠO PHIẾU nhóm đó"*) chỉ đúng khi nhóm có đúng 1 version; (b) forge POST pin bản archived.
**Vá (khuyến nghị làm trong W1):** Create + UpdateDraft-khi-đổi-pin thêm check `IsActive ∧ IsUserSelectable` 2 message tách (mirror vá-3); UpdateDraft giữ-nguyên-pin không đụng (guard chỉ chạy `awId != plan.ApprovalWorkflowId` `:603` — phiếu cũ pin bản archived vẫn sửa GhiChu bình thường). FE ×2 app: `find(w.isActive && code-match)` (fe-user `:114` + fe-admin mirror `:113-116`). Nếu anh muốn giữ nguyên semantics cũ thì tối thiểu PHẢI sửa câu răng-nút-Ghim trong spec thành "chặn khi bỏ ghim TẤT CẢ version của nhóm".
**F6 [M] — D3=C như spec viết sẽ NÓI DỐI CHIỀU NGƯỢC ở ô #4 Trả-về-Drafter; danh sách ô mờ phải CHÍNH XÁC.**
Bảng BE §3 dòng #4: KHKK **BỎ QUA cờ nhưng hành-vi = LUÔN-CHO** (return về Drafter cho mọi NV đúng lượt kể cả cờ tắt — `ReturnOrRejectAsync` không đọc cờ). Mờ ô #4 kiểu "chưa áp dụng cho KHKK" = bảo tính năng KHÔNG CÓ trong khi nó LUÔN BẬT.
**Vá spec-C (danh sách chốt, gate `applicableType === 10` — áp cả panel ContractSigningPlan cho nhất quán):**
- Mờ-**false**-disabled + tooltip "chưa áp dụng cho KHKK": #1 `allowReturnOneLevel` · #2 `allowReturnOneStep` · #3 `allowReturnToAssignee` (`:1235-1266` vùng render) · #5 `allowApproverEditDetails` `:1268-1276` · #6 `allowApproverEditBudget` `:1277-1285` · #9 `allowApproverDelete` `:1320-1328` — 6 ô.
- **Forced-true**-disabled + tooltip "KHKK luôn trả về người soạn khi Trả lại — cờ không tắt được": #4 `allowReturnToDrafter` `:1256-1267`.
- Giữ nguyên: #7 đã disabled sẵn (`:1292-1302`) · #8 sống (`:1307-1315`).
- Payload: ô mờ gửi giá-trị-đang-lưu (echo, không ép) để không vấp guard PUT.
**F7 [M] — Ô "Ngưỡng giá trị gói CEO" phải TÁCH KHỎI D3 — disable cho type-10 VÔ ĐIỀU KIỆN.**
`:944-961` chỉ `disabled={isEdit}` ⇒ create-mode NHẬP ĐƯỢC cho type-10, trong khi `CeoApprovalThreshold` là quyết-đã-chốt-vĩnh-viễn KHÔNG port (OG-3 NĐUQ human-gate — `ContractSigningPlanWorkflowService.cs:46-47`). Lý lẽ của phương án A ("cờ nằm chờ port") KHÔNG áp dụng cho ô này — nó là #78 vĩnh viễn, không phải tạm thời. Spec hiện bó nó vào C ⇒ anh chọn A hoặc B là lỗ này sống tiếp.
**Vá:** W2 thêm mục riêng (mọi phương án D3): type-10 ⇒ ô CEO disabled + tooltip "KHKK không dùng ngưỡng máy — NĐUQ quyết" + POST gửi `null`; tiền lệ cùng-file `:1292`.
**F8 [m] — Mâu-thuẫn 3 lens spec chưa khai (mục 7 của đề):** (a) công-thức W1a lệch BE⟂FE (đã gộp F4); (b) D3: FE-invest §3 *"mặc định nghiêng GIỮ (A)"* ⟂ BE-invest §4.3 *"KHÔNG port thì FE PHẢI ẩn/disable"* (= C) — spec trình trung lập mà không khai 2 lens ngược nhau; (c) DB-invest W-DB-2 (thêm chiều `code=` phía BE) bị DROP im lặng — spec chọn client-side slice mà không ghi disposition. **Vá:** 3 dòng khai trong MỤC 3.
**F9 [m] — Baseline test hardcode "645" ⟂ STATUS canonical "644" (@S168 CI #444).** Một trong hai stale. **Vá:** W0/W4 đo tươi + ghi vào run.md, spec không chép số (B1).
**F10 [m] — Acceptance #3 chưa chỉ định môi trường; prod-create v02 là mutate config THẬT và KHÔNG có đường re-activate** (controller 5 action — không có activate; PUT cấm đổi IsActive; seeder idempotent per-Code không re-set). **Vá:** chứng chính = integration test W1a; acceptance UI #3 chạy LOCAL sau W0 (chấp nhận workflow rỗng người — đo cấu-trúc slice/badge); nếu muốn prod thì v02 = clone-y-hệt và khai với anh "v02 thành bản sống, v01 archived" TRƯỚC khi bấm.
**F11 [m] — Thiếu data-audit phiếu ĐÃ pin lệch trước vá.** Guard W1b chỉ chặn Create/UpdateDraft/Submit TỪ NAY; phiếu ChoDuyet pin lệch từ trước chạy tiếp đội hình lệch im lặng. **Vá W5:** thêm query `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)` — >0 row thì báo anh xử tay. Cùng W5: nếu query 8-nhóm cho thấy nhóm nào 0-active (bom đã nổ trước vá) → recovery = "Tạo phiên bản mới" từ panel nhóm (lockedCode tự điền mã đúng).
**F12 [m] — Sót nhỏ (mục 8):** (a) hint ô Mã `:929-932` khi `lockedCode` nên đổi chữ ("Mã khoá theo nhóm — version auto-tăng"); (b) sửa-workflow-tại-chỗ KHÔNG notify approver phiếu treo — parity PE (PE cũng không notify), KHÔNG làm wave này, khai vào Nợ; (c) invalidate keys ĐÃ ĐO ĐỦ: key `['approval-workflow-v2-overview', selectedTypeInt]` + mutations invalidate prefix ⇒ 8 panel nhóm và panel type-10 dùng chung cache tự tươi, 0 việc; fe-user cache là app khác — không cross-invalidate được, chấp nhận (hành vi sẵn có); (d) comment BE `:339` "only ONE active per type" phải sửa CÙNG COMMIT với W1a kẻo comment nói dối.
## TỔNG HỢP WAVE FINAL ĐỀ XUẤT
> Bản wave-plan ĐÃ SỬA theo F1-F12 — đủ chi tiết cho executor Opus MAX. Chỗ nào giữ nguyên spec DRAFT thì ghi "như spec".
### W0 — prep local (như spec) + 2 bổ sung
1. `dotnet ef database update` ×2 DB (Dev + Design — đo lại drift tại chỗ, đừng tin số cũ) → boot API 1 lần → 8 row `KHKK-N*` local (RỖNG NGƯỜI là ĐÚNG).
2. **[F9]** `dotnet test SolutionErp.slnx` đo baseline TƯƠI, ghi số vào `run.md` (spec 645 ⟂ STATUS 644 — không chép, đo).
3. **[F10]** Chốt môi trường acceptance: #1/#2 local + prod; #3 = integration test (chứng chính) + UI local; prod-#3 CHỈ khi anh gật "v02 clone thành bản sống".
### W1 — BE: 5 fix, 2 file + 1 file MỚI, 0-mig
| # | Fix | Site | Cách (đã sửa theo finding) |
|---|---|---|---|
| a | Deactivate-per-Code type-10 **[F4]** | `ApprovalWorkflowV2AdminFeatures.cs:339-343` | CÔNG THỨC KHOÁ: `typeEnum == ContractSigningPlan``Where(w => w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive)`; type khác giữ per-type. CẤM biến-thể regex `^KHKK-N[1-8]$` của FE-invest D1 (code lạ type-10 sẽ rơi per-type → nuke 8 nhóm). Sửa comment `:339` cùng commit **[F12d]** |
| b | Group-match guard 3-site **[F1]** | Create `:479-486` · UpdateDraft `:603-607` · `SubmitAsync` (`ContractSigningPlanWorkflowService.cs`, sau guard pin `:163-165`) | File MỚI `Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs``public static class`, method `EnsureWorkflowGroupMatchAsync(IApplicationDbContext, Guid awId, int approvalGroup, CancellationToken)` (move nguyên logic rào (v), GIỮ semantics cho-qua-ngoài-họ + khai trong spec). 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 | **MỚI [F5]** IsActive∧IsUserSelectable cho pin KHKK | Create (sau rào iv) + UpdateDraft (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) |
| c | Changelog type-10 khi PUT **(như spec, chi tiết chốt)** | `:863-893` | Nhánh `else if (def.ApplicableType is ContractSigningPlan)`: query `db.ContractSigningPlans.Where(p => p.ApprovalWorkflowId == def.Id && p.Phase != DaDuyet && p.Phase != TuChoi)` (query-filter tự loại phiếu xoá-mềm — ĐÚNG cho changelog) → add `ContractSigningPlanChangelogs { EntityType = Workflow /*=4, enum sẵn*/, Action = Update, EntityId = def.Id, PhaseAtChange, UserId/UserName, Summary + ContextNote }` mirror block PE |
| d | DELETE usage-check → 409 **[F2][F3]** | `:1193-1205` | Đếm pin **8 module** (PE/Contract/KHKK **BẮT BUỘC `IgnoreQueryFilters()`**; 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) + đếm opinions 8 bảng theo level-ids của workflow (IgnoreQueryFilters — FK không quan tâm IsDeleted) → 409 kể tên "N phiếu đang gắn + M chữ ký". Rationale 2 vế: FK-modules = UX 547→409; office = data-integrity |
**Test W1 (test-before a/b/b2, test-after c/d):**
1. Tạo v2 `KHKK-N1` ⇒ N2..N8 IsActive bất biến. 2. DuyetNcc tạo Code-mới-cùng-type vẫn tắt Code cũ (regression per-type). 3. **[F4]** POST type-10 code-lạ `QT-KHKK-V2-001` ⇒ 8 nhóm bất biến. 4. UpdateDraft re-pin lệch nhóm ⇒ 409; re-pin bản archived ⇒ 409 **[F5]**; Submit phiếu pin lệch ⇒ 409. 5. PUT workflow nhóm có phiếu treo ⇒ 1 row `ContractSigningPlanChangelogs`. 6. DELETE version có pin KHKK ⇒ 409; **phiếu KHKK ĐÃ XOÁ MỀM pin ⇒ vẫn 409 [F2]**; **phiếu Leave (office, 0-FK) pin ⇒ 409 [F3]**; version 0-usage ⇒ xoá OK.
### W2 — FE `ApprovalWorkflowsV2Page.tsx` (~70-100 dòng) + KhkkCreatePage ×2
| # | Chạm | Site | Cách |
|---|---|---|---|
| a | Parse kind (như spec) | `:164-172` | `match(/^KhkkN([1-8])$/)``khkkGroup`; `selectedTypeInt = khkkGroup ? 10 : TYPE_CODE_TO_INT[typeCode]` |
| b | Synthetic slice (như spec — ĐÃ VERIFY `History` = full list kể cả active, BE `:206-211`) | `:299-301` | `versions = type10.history.filter(d => d.code === khkkGroupWorkflowCode(g))` · `active = versions.find(v => v.isActive) ?? null` · label = "Duyệt KHKK — Nhóm {g}" + phụ đề `khkkGroupMenuLabel(g)` (D4) |
| c | `lockedCode` (như spec) + hint **[F12a]** | `TypePanel:357``Designer:620/:650-651/:798/:920-933` | prop `lockedCode?: string`; ô Mã `disabled={isEdit \|\| !!lockedCode}` + hint "Mã khoá theo nhóm"; POST gửi `lockedCode ?? code` |
| d | D3 — thi hành theo anh chốt; **nếu C thì theo danh-sách-chốt [F6]** | `:1235-1328` | 6 ô mờ-FALSE (#1#2#3 return-modes · #5 `:1268-1276` · #6 `:1277-1285` · #9 `:1320-1328`) + **#4 FORCED-TRUE-disabled** tooltip "KHKK luôn trả về người soạn — cờ không tắt được" + #7 giữ disabled sẵn + #8 sống. Gate `applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN` (áp cả panel ContractSigningPlan). Payload ô mờ = echo giá-trị-đang-lưu |
| e | (optional, như spec) mirror menuKeys +8 const | `fe-{admin,user}/src/lib/menuKeys.ts:31-32` | 0 hệ quả runtime |
| f | **MỚI [F7]** — Ô Ngưỡng CEO: disable type-10 VÔ ĐIỀU KIỆN (mọi phương án D3) | `:944-961` | `disabled={isEdit \|\| applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}` + tooltip "KHKK không dùng ngưỡng máy — NĐUQ quyết" + POST type-10 gửi `null`. Tiền lệ `:1292` |
| g | **MỚI [F5]** auto-pin lọc isActive ×2 app | fe-user `KhkkCreatePage.tsx:114` + fe-admin `:113-116` | `find(w => w.isActive && codeMatch)` — giữ filter `isUserSelectable` `:104`. Răng nút Ghim đúng lại: bỏ ghim BẢN ACTIVE = chặn tạo phiếu nhóm |
**Regression (như spec) + sửa câu răng-Ghim theo F5.** SHA-mirror: KHÔNG áp cho Designer (fe-admin-only, đã chứng); RIÊNG W2g là cặp file 2-app — diff phải mirror logic (không cần byte-equal vì 2 file vốn khác nhau).
### W3 — CHỈ KHI D3=B (như spec; anchors `PeSoftDeleteFeatures.cs:110-114` / `PurchaseEvaluationWorkflowService.cs:421-424/:810` CHƯA re-đo trong review này — executor verify trước khi port; mỗi cờ 1 test RED→GREEN; `CeoApprovalThreshold` vĩnh viễn KHÔNG port — F7 vẫn áp)
### W4 — test-specialist đóng suite: baseline = SỐ ĐO Ở W0 (không phải 645) + N test mới W1/W3
### W5 — reviewer pre-commit → commit → cicd 3-chân (BE api + bundle admin rotate; +eoffice nếu W2e/W2g fe-user) → acceptance + SQL verify
- 3 query DB §4 (như spec) + **[F11]** query audit pin-lệch: `... WHERE w.Code LIKE 'KHKK-N%' AND w.Code <> CONCAT('KHKK-N', p.ApprovalGroup)` — >0 báo anh xử tay.
- Query 8-nhóm: nhóm nào 0-active (bom nổ trước vá) → recovery = "Tạo phiên bản mới" từ panel nhóm đó (lockedCode tự điền mã, sinh vNext active).
- Acceptance 1-4 như spec, #3 theo môi trường đã chốt ở W0.3 **[F10]**.
### Điểm quyết cho anh (cập nhật theo review)
- **D3** giữ 3 phương án NHƯNG: (i) khai 2 lens khuyến nghị ngược nhau (FE nghiêng A, BE nghiêng C) **[F8b]**; (ii) nếu C → dùng danh-sách-chốt F6 (ô #4 forced-true, KHÔNG mờ-kiểu-chưa-có); (iii) ô Ngưỡng CEO KHÔNG thuộc D3 — disable vô điều kiện **[F7]**.
- **D4** (title panel) giữ như spec.
- **MỚI D5 [F5]:** chốt hành vi pin: (khuyến nghị) thêm check IsActive∧IsUserSelectable + FE lọc isActive — hay giữ nguyên semantics cũ và sửa lại câu răng-nút-Ghim trong spec.
### Nợ NGOÀI scope (giữ như spec, +2)
GET Overview 0-policy (information-disclosure mềm) · roster snapshot-email · `Khkk_WfView` panel aggregate giấu 7/8 · comment "7 bảng" `:1120` · **+ sửa-workflow-tại-chỗ không notify approver phiếu treo (parity PE) [F12b]** · **+ W-DB-2 (param `code=` phía BE) = KHÔNG LÀM, chọn client-side slice — disposition khai [F8c]**.

View File

@ -0,0 +1,159 @@
# 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.<flag>}` 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.<flag>}` 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.

View File

@ -0,0 +1,292 @@
# sub-reviewer-precommit — S182 YC-013 đợt-2 (PRE-COMMIT, diff thật)
**VERDICT: `PASS_WITH_FIXES — 0C / 3M`** (+ 6 minor). Không có blocker. 3 MAJOR đều **propose-only**, không chặn commit nếu lead chấp nhận rủi ro có khai.
---
## 0. Diff scope (đo tươi, KHÔNG chép từ lane)
`git status --porcelain` + `git diff --stat`:
| Nhóm | File | Ghi chú |
|---|---|---|
| BE | `ApprovalWorkflowV2AdminFeatures.cs` (+184) · `ContractSigningPlanFeatures.cs` (+55) · `ContractSigningPlanWorkflowService.cs` (+10) · **`ContractSigningPlanWorkflowGuards.cs` (untracked `??`, 72 dòng)** | 4/4 khớp spec |
| FE | `ApprovalWorkflowsV2Page.tsx` (+213) · `menuKeys.ts` ×2 · `KhkkCreatePage.tsx` ×2 | 5/5 khớp spec |
| Test | `UpdateAwDefinitionTests.cs` (+567) · `ContractSigningPlanApprovalTests.cs` (+326) · **`ContractSigningPlanGroupAndCatalogTests.cs` (+14)** | **3 file, spec/lệnh khai 2** → mục 9 |
| Ngoài scope | `.claude/governance/.session-counter.json` (+54/-53) · `docs/governance/so-yeu-cau-chu-du-an.md` (+2) | → mục 9 |
Tổng tracked: **+1400 / 106**, 13 file M + 1 file `??`.
### Máy — đo LẠI độc lập (không nhận số của lane)
| Phép đo | Lệnh chạy | Kết quả |
|---|---|---|
| Domain tests | `dotnet test SolutionErp.slnx` | **45 P / 0 F** |
| Infrastructure tests | (cùng lệnh) | **614 P / 0 F** |
| **Tổng** | | **659 P / 0 F** — khớp claim; baseline 645 (45+600) ⇒ **+14 đúng ở Infrastructure** |
| `npm run build` fe-admin | exit 0 | ✓ built 15.36s |
| `npm run build` fe-user | exit 0 | ✓ built 1.04s |
| tsc fe-admin | `npx tsc --noEmit -p tsconfig.app.json` | 0 err (225 file) |
| tsc fe-user | `npx tsc --noEmit -p tsconfig.app.json` | 0 err (214 file) |
---
## 1. W1a — công thức R-6 · **HELD**
`ApprovalWorkflowV2AdminFeatures.cs:355-361`:
```csharp
var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan
? await db.ApprovalWorkflows
.Where(w => w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct)
: await db.ApprovalWorkflows
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct);
```
- ✅ Đúng **3 vế** R-6, đúng thứ tự, **KHÔNG** có biến-thể regex `^KHKK-N[1-8]$` ở site này (grep `KHKK-N` trong `AdminFeatures.cs` chỉ ra ở **comment** + gọi `ParseGroup` cho D5, không ở vị-ngữ W1a).
- ✅ Comment `:339` cũ ("only ONE active per type") **đã bị xoá**, thay bằng khối 20 dòng nêu cả 2 nhánh + cấm regex. Không còn neo thối.
- ✅ Nhánh `else` **giữ nguyên byte** semantics per-type cho mọi loại khác.
- ✅ Ăn khớp với `nextVersion :336-338` vốn đã per-Code (`Where(w => w.Code == request.Code)`), nên 2 vế cùng một trục.
---
## 2. W1b — Guards 3-site · **HELD**
- ✅ File MỚI `ContractSigningPlanWorkflowGuards.cs``public static class`**Application** (đúng F1 của review-1: không nhét vào `Scope` internal, không `InternalsVisibleTo`, không inline-duplicate). Đây đúng là finding "spec-unbuildable vì assembly-ranh" đợt-1 và nó **đã được đóng đúng cách**.
- ✅ 3 call-site, **đối số đúng thứ tự ở cả 3** (tôi đối chiếu tay từng site):
- Create — `ContractSigningPlanFeatures.cs:516-517``(db, request.ApprovalWorkflowId, approvalGroup, ct)`
- UpdateDraft — `:643-644``(db, awId, plan.ApprovalGroup, ct)`
- Submit — `ContractSigningPlanWorkflowService.cs:196-197``(db, awId, plan.ApprovalGroup, ct)`
- ✅ Semantics **cho-qua code ngoài họ** GIỮ NGUYÊN: `ParseGroup` trả `null``if (wfGroup is int k && ...)` không vào ⇒ pass. Ghi chú `:49-57` nêu đúng lý do gốc `:477-478`.
- ✅ Điểm chèn Submit: **sau** 2 rào hạng-mục (`:177-187`), **ngay trên** `LoadStepsAsync(awId, ct)` `:199`. Cũng nằm sau guard phase `:160` và sau `plan.ApprovalWorkflowId is not Guid awId` `:163``awId` luôn hợp lệ, không có nhánh nào bỏ qua.
- ✅ Thân rào cũ ở Create **đã xoá hẳn** (không để 2 bản luật song song).
---
## 3. W1b2 / D6 · **HELD**
-`EnsureWorkflowPinnableAsync` (`ContractSigningPlanFeatures.cs:331-353`) chỉ có **2 caller**: Create `:507`, UpdateDraft `:642` (grep toàn repo — 3 hit gồm 1 định-nghĩa).
- ✅ UpdateDraft: cả 3 rào (`Type` / `Pinnable` / `GroupMatch`) nằm **TRONG** `if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId)` `:638`. Đúng D6 — giữ-nguyên-pin không bị hỏi. T7 chân (4) đo đúng vế này.
-**2 message TÁCH** (`:346-352`): "đã ngừng hoạt động" ⟂ "chưa được ghim cho người dùng chọn". Không gộp `!IsActive || !IsUserSelectable`.
- ✅ Không gọi ở đường Submit — đúng "D6 chốt 2 site".
---
## 4. W1c — changelog shape · **HELD**
`AdminFeatures.cs:916-955`:
-`EntityType = ContractSigningPlanEntityType.Workflow` — enum **=4** (verified `ContractSigningPlanChangelog.cs:12`), slot có sẵn, không đẻ slot mới.
-`Action = ChangelogAction.Update` · `EntityId = def.Id` · `PhaseAtChange = plan.Phase` · `UserId` · `UserName` · `Summary` · `ContextNote` — đủ 8 field mirror khối PE.
- ✅ Filter: `ApprovalWorkflowId == def.Id && Phase != DaDuyet && Phase != TuChoi``TraLai` **KHÔNG** bị loại (đúng spec: phiếu trả lại sẽ trình lại).
-**CỐ Ý không `IgnoreQueryFilters()`** ở đây (ngược với DELETE) — có khai lý do tại chỗ `:930-934`. Đây là điểm mà đợt-1 tôi FAIL, nay đã được **phân biệt đúng 2 chiều** (changelog = ngữ nghĩa "sống"; DELETE = ngữ nghĩa "FK còn thật").
-`note`/`summary`/`actorName` hoist ra ngoài 2 nhánh — không đổi hành vi (chỉ string op), khối PE dùng lại y hệt giá trị cũ.
- ✅ Ghi nằm **trước** `await db.SaveChangesAsync(ct)` `:958` + `tx.CommitAsync(ct)` `:962` ⇒ atomic cùng lượt PUT.
- ✅ Câu cũ xếp KHKK vào rọ "chỉ có UpdatedAt/UpdatedBy" đã gỡ (`:875-882`) — không để lại neo thối.
---
## 5. W1d — usage-count + D5 · **HELD** (2 minor)
### (i) pin-count 8 module — **đếm ĐÚNG 8, không thiếu không thừa**
Tôi grep Domain thay vì tin comment:
```
grep -E "public (Guid|Guid\?) ApprovalWorkflowId" src/Backend/SolutionErp.Domain
→ Contract · ContractSigningPlan · LeaveRequest · OtRequest · Proposal
· TravelRequest · VehicleBooking · PurchaseEvaluation = 8
(+ ApprovalWorkflow.cs:76 = FK nội bộ của Step, không phải module)
```
-`IgnoreQueryFilters()` áp đúng **PE / Contract / KHKK** (`:1287-1292`). Tôi grep `HasQueryFilter` toàn `src/Backend`: trong 8 module pin, **chỉ 3 entity đó** có global filter (`ContractConfiguration.cs:44` · `ContractSigningPlanConfiguration.cs:55` · `PurchaseEvaluationConfiguration.cs:84`). 5 module Office **không** có ⇒ đếm thẳng là đúng. Claim trong comment `:1281-1285` HELD bằng phép đo độc lập.
- ✅ Đây chính là finding 🔴 đợt-1 của tôi ("usage-count trên bảng có HasQueryFilter thiếu IgnoreQueryFilters") — **đã đóng**, và T11 đo đúng cái bẫy đó.
### (ii) opinions 8 bảng
- ✅ Đủ 8 (`:1300-1319`): PE · Contract · ContractSigningPlan · Proposal · LeaveRequest · OtRequest · TravelRequest · VehicleBooking.
-`levelIds` lấy qua `l.Step!.ApprovalWorkflowId == def.Id` + `IgnoreQueryFilters()`.
-`IgnoreQueryFilters()` trên 8 bảng opinion = **no-op** (không bảng nào có `HasQueryFilter`; `ContractSigningPlanLevelOpinion.cs:18` khai rõ "CỐ Ý KHÔNG"). Vô hại + đồng hình, đúng như comment nói. Và **KHÔNG** lọc `!IsDeleted` là ĐÚNG cho câu hỏi FK (khác `CountLiveOpinionsAsync :969` hỏi câu khác — "chữ ký còn hiệu lực"). Hai hàm hỏi 2 câu khác nhau, không phải mâu thuẫn.
### (iii) 404
-`throw new NotFoundException("ApprovalWorkflow", request.Id)` thay `KeyNotFoundException` (`:1275`). T13 có control dương.
### (iv) D5
- ✅ Chỉ áp cho họ, qua `ContractSigningPlanWorkflowGuards.ParseGroup(def.Code) is int group` `:1337`**dùng lại đúng 1 nguồn regex**, không chép chuỗi lần 3.
- ✅ Thứ tự: usage (i+ii) TRƯỚC, D5 SAU — khai lý do `:1259-1261`.
---
## 6. W2d — danh-sách-chốt 6 ô · **HELD trên trục dữ-liệu**, hở trên trục hiển-thị (→ MAJOR-1)
### 🔴 #8 `allowApproverFinalize` — **0-CHẠM, chứng minh bằng grep trên DIFF**
```
git diff -- fe-admin/.../ApprovalWorkflowsV2Page.tsx | grep "allowApproverFinalize"
→ exit 1 (0 hit)
```
Control dương: grep trên **file** ra 7 hit (`:57 :123 :217 :238 :267 :1456 :1457`) ⇒ lưới grep có răng, và ô #8`:1456` **không**`disabled`, **không**`title`, `checked={entry.allowApproverFinalize}` nguyên vẹn. **Prod 8/88 cờ chịu lực AN TOÀN.**
### #7 `allowApproverSkipToFinal` — giữ nguyên hành vi
Diff chỉ đổi **3 biểu thức trùng lặp** `applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN``isKhkk`. `isKhkk` định nghĩa `:715` = **cùng biểu thức**, cùng scope, cùng prop. Giá trị y hệt. `checked={isKhkk ? false : ...}` giữ ép-false (đúng spec "GIỮ NGUYÊN").
### `levelFlagsPayload` — 0-chạm
`git diff | grep levelFlagsPayload`**1 hit duy nhất, nằm trong COMMENT** (`:486` của diff). Payload không đổi ⇒ ô mờ vẫn echo state thật lên BE.
### 6 ô disabled — GIỮ state thật, **CẤM ép false: tuân thủ**
| # | Cờ | Dòng | `checked` | `disabled` | tooltip |
|---|---|---|---|---|---|
| 1 | `allowReturnOneLevel` | 1358-1362 | `entry.…` ✅ | `isKhkk` | chung |
| 2 | `allowReturnOneStep` | 1369-1373 | `entry.…` ✅ | `isKhkk` | chung |
| 3 | `allowReturnToAssignee` | 1380-1384 | `entry.…` ✅ | `isKhkk` | chung |
| 5 | `allowApproverEditDetails` | 1407-1411 | `entry.…` ✅ | `isKhkk` | chung |
| 6 | `allowApproverEditBudget` | 1418-1422 | `entry.…` ✅ | `isKhkk` | chung |
| 9 | `allowApproverDelete` | 1468-1472 | `entry.…` ✅ | `isKhkk` | chung |
Không ô nào bị `checked={false}`. ✅ ERRATA D3 (state-thật, không forced-true) được thi hành **đúng** ở ô #4 `:1396-1400`: `checked={entry.allowReturnToDrafter}` + `disabled` + **tooltip RIÊNG** + label **KHÔNG** bị `text-slate-400` (đúng ý "không mờ kiểu chưa-có").
### Ô Ngưỡng CEO (W2f)
-`disabled={isEdit || isKhkk}` `:1052` — vô điều kiện cho type-10.
- ✅ POST gửi `ceoApprovalThreshold: isKhkk ? null : …` `:889-891`.
- ✅ helper-text riêng `:1057-1058`.
---
## 7. W2g — `find(w => w.isActive && …)` · **HELD** (hợp-đồng FE↔BE đã đo)
- ✅ 2 app **mirror LOGIC** — 2 diff byte-identical ở phần code (`fe-admin/.../KhkkCreatePage.tsx:125-127``fe-user/.../KhkkCreatePage.tsx:125-127`).
- ✅ 🔴 **`isActive` CÓ THẬT trong DTO BE** — đây là chỗ dễ vỡ nhất (nếu BE không trả, `w.isActive` = `undefined` ⇒ falsy ⇒ `autoWorkflow` LUÔN null ⇒ **chết đường tạo phiếu KHKK**, mà tsc + build + 659 test đều xanh). Đo: `AwDefinitionDto` nhận `d.IsActive`**`ApprovalWorkflowV2AdminFeatures.cs:178`** (đối số thứ 8), serialize camelCase ⇒ `isActive`. **Hợp đồng khớp.**
-`history` = FULL list kể cả active — verified `:205-213`: `versions` = mọi definition của type, `active` = `versions.FirstOrDefault(v => v.IsActive)`. Claim của cả W2b lẫn W2g HELD.
- ✅ Filter `isUserSelectable` client-side ở `:109` **vẫn giữ**.
---
## 8. 14 test — **đúng 14 ca spec, KHÔNG có ca nào nới để xanh**
| Ca | File | Nhận xét đối-kháng |
|---|---|---|
| T1 | `UpdateAwDefinitionTests.cs` | Có **tiền-đề đo** (`Be(8)` trước khi act) — chống "xanh ở thế giới sai" |
| T2 | ″ | Regression type-1 + chứng-nhân xuyên-type |
| T3 | ″ | **2 chân**: (a) code lạ không nuke 8 nhóm; (b) code lạ vẫn ratchet chính nó ⇒ giết được cả bản vá regex lẫn bản vá "type-10 đừng tắt gì" |
| T4 | `ContractSigningPlanApprovalTests.cs` | 3 vế: chặn + **đĩa bất động** (`ReadPinAsync` từ context MỚI + changelog=0) + control dương |
| T5 | ″ | **Seed phiếu pin lệch thẳng DbContext** ✅ + chứng-nhân phiếu đúng nhóm submit LỌT cùng lượt; fixture kéo hạng-mục theo `approvalGroup` ⇒ loại 2 cửa 409 khác |
| T6 | ″ | Vế âm + **chân (2) chứng minh rào đang sống** (không phải xanh-mù) |
| T7 | ″ | 4 chân, đủ cả (4) giữ-nguyên-pin-vẫn-sửa-được |
| T8 | `UpdateAwDefinitionTests.cs` | Đo **SHAPE** 9 field + sanity `changes ≠ rỗng` + assert PE changelog = 0 (không rải chéo module) |
| T9 | ″ | **Control dương TRONG CÙNG LƯỢT** (4 phiếu: 1 sống → có vết; DaDuyet/TuChoi/xoá-mềm → không) |
| T10 | ″ | Control dương + **vi-sai message** (1 vs 2 hồ sơ ⇒ câu phải khác — giết "câu hằng") + chứng-nhân `DbUpdateException` |
| T11 | ″ | Có **tiền-đề 2 chiều**: filter-on = 0, filter-off = 1 ⇒ đo đúng cái bẫy F2 |
| T12 | ″ | **Seed `LeaveRequest` thật** ✅ + chứng-nhân xoá thẳng module 2 ⇒ dangling THẬT trong DB |
| T13 | ″ | Control dương id thật |
| T14 | ″ | **3 chân** (không phải 2): (1) không-phải-cuối ⇒ OK · (2) cuối ⇒ 409 · (3) ngoài họ 1-version ⇒ OK. Tiền-đề `0 usage` tách bạch D5 với rào (i) |
**Đếm:** 10 `[Fact]` mới ở `UpdateAwDefinitionTests` + 4 ở `ContractSigningPlanApprovalTests` = **14** ⟂ suite +14 (600→614). Khớp 2 nguồn.
**Có test nào bị NỚI không?****đúng 1** assertion đổi, ở file thứ 3 (`ContractSigningPlanGroupAndCatalogTests.cs:858-868`):
`Contain("KHKK-N5")``Contain("N5")`. Đây là **spec-change bắt buộc**, không phải nới-để-xanh: message mới do W1b chốt bỏ mã, giữ số nhóm; câu cũ sẽ ĐỎ. Có ghi lý do 8 dòng tại chỗ. **Chấp nhận** — nhưng xem MINOR-1 (assertion mù thứ-tự).
---
## 9. Residual — 3 khoản NGOÀI danh sách
1. **`tests/…/ContractSigningPlanGroupAndCatalogTests.cs`** (+14/6) — file test **thứ 3**, lệnh chỉ khai 2. Chính-đáng (hệ quả tất yếu của đổi message W1b), nhưng **phải khai trong commit-body**, đừng để người đọc log sau này tưởng có ai lén sửa test cũ.
2. **`.claude/governance/.session-counter.json`** (+54/53) — tick S182 của lead. **Không liên quan YC-013.** → xem MINOR-2 (BOM).
3. **`docs/governance/so-yeu-cau-chu-du-an.md`** (+2) — thêm dòng `YC-013` + `YC-014`. Đúng luật sổ (GHI NGAY), không liên quan code.
**`#8` / `levelFlagsPayload` / `SkipToFinal` có bị đụng ngoài ý không?** — KHÔNG. Đã grep trên **diff** (không phải trên file) cho cả 3, kết quả ở mục 6.
---
## 10. FLAG-A của FE lane — **CLAIM ĐÚNG, và tôi đo được**
| Lệnh | fe-admin | fe-user |
|---|---|---|
| `npx tsc --noEmit` (nguyên văn acceptance cũ) | exit 0 — **`--listFiles` = 0 dòng** | exit 0 — **`--listFiles` = 0 dòng** |
| `npx tsc --noEmit -p tsconfig.app.json` | exit 0 — `--listFiles` = **225 dòng** | exit 0 — `--listFiles` = **214 dòng** |
`fe-admin/tsconfig.json` = `{"files": [], "references": [...]}` ⇒ dạng trần **biên dịch 0 file**, luôn xanh **theo cấu trúc**, không phải may mắn. FLAG-A **HELD**.
🔸 Bổ sung có ích cho lane: `npm run build` = `tsc -b && vite build``tsc -b` (build mode) **CÓ** đi theo project references ⇒ acceptance `npm run build` là thước THẬT. Chỉ acceptance `npx tsc --noEmit` trần là rỗng. Đề nghị: mọi acceptance FE về sau ghi `npm run build` hoặc `-p tsconfig.app.json`, và **kèm `--listFiles | wc -l` một lần** để chứng thước không rỗng.
---
# FINDINGS
## 🔴 MAJOR-1 — tooltip gắn trên `<input disabled>`: Chrome/Edge không hiện ⇒ nửa D3=C có thể chết câm
**Vị trí:** `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:1361, 1372, 1383, **1399**, 1410, 1421, 1471` (và tiền lệ cũ `:1443`).
`title` được đặt trên chính `<input type="checkbox" disabled={isKhkk}>`. Trình duyệt Chromium (Chrome/Edge — đúng trình duyệt đích, xem `feedback_hosolink_open_from_web_browser_block`) **chặn sự kiện chuột trên form-control bị disabled**, nên native tooltip thường **không** bật lên; Firefox thì có. `<label>` bao ngoài KHÔNG mang `title`, nên rê chuột vào phần chữ cũng không ra gì.
**Vì sao là MAJOR chứ không phải nit:** D3=C của anh là *"mờ ô chưa nghe **+ tooltip**"*. Nếu tooltip không hiện thì admin chỉ thấy 7 ô xám không lý do — đúng thứ mà rule-box `:1088-1096` cố tránh. Nặng nhất là **ô #4** `:1399`: câu *"KHKK hiện luôn trả về người soạn khi Trả lại — cờ chưa phân nhánh"***thông tin DUY NHẤT** phân biệt ô #4 với 6 ô kia (rule-box chỉ nói #4 "có tác dụng thật", không nói *"cờ chưa phân nhánh"*). Tooltip không hiện ⇒ ô #4 trông y hệt 6 ô "chưa áp dụng", tức **ngược nghĩa** — đúng class lỗi mà ERRATA D3 vừa sửa xong ở tầng `checked`.
**Đây là máy dựng mà không có cửa gọi** (`feedback_guard_built_but_never_called`).
**Acceptance đề nghị (chọn 1):**
- (a) chuyển `title=` từ `<input>` lên `<label>` bao ngoài — label không disabled nên tooltip sống; hoặc
- (b) render câu của ô #4 thành **chữ nhìn thấy được** (một dòng `text-[10px]` dưới ô), giữ tooltip cho 6 ô kia.
- Kiểm chứng: mở dialog nhóm bất kỳ trên Chrome, rê chuột **vào chữ** của ô #4 → phải đọc được câu riêng.
- 🔸 Tiền lệ `:1443` (#7, có từ K3) **cùng bệnh** — vá thì vá cùng lớp (bài `citation-trap` gen-2: vá 1 CLASS phải grep MỌI matcher cùng-lớp TRONG diff).
---
## 🔴 MAJOR-2 — 3 thành phần bất đồng về HOA/thường của `Code`; test mù theo CẤU TRÚC (SQLite ≠ SQL Server)
**Vị trí:** `ContractSigningPlanWorkflowGuards.cs:37-38``ApprovalWorkflowV2AdminFeatures.cs:357``KhkkCreatePage.tsx:126` (×2 app).
Ba chỗ hỏi cùng một câu "code này có phải nhóm N{n} không", bằng **ba luật khác nhau**:
| Chỗ | Phép so | Với `khkk-n1` (thường) |
|---|---|---|
| `GroupCodePattern = new(@"^KHKK-N([1-8])$", CultureInvariant)`**KHÔNG** `IgnoreCase` | case-**SENSITIVE** | → `null` = **ngoài họ** ⇒ rào nhóm 3-site TẮT, D5 TẮT |
| `w.Code == request.Code` (EF → SQL Server) | theo **collation DB**, mặc định `*_CI_AS` = case-**INSENSITIVE** | → **khớp** `KHKK-N1` ⇒ per-Code **vẫn hạ IsActive** bản thật |
| `(w.code ?? '').trim().toUpperCase() === 'KHKK-N1'` | case-**INSENSITIVE** | → **khớp** ⇒ FE auto-pin nó cho phiếu nhóm 1 |
Hệ quả cụ thể: admin gõ `khkk-n1` trên panel type-10 gộp (ô Mã ở panel đó **không** khoá — `lockedCode = null`) ⇒ bản mới **tắt** `KHKK-N1` thật, FE **vẫn** auto-pin sang nó, còn rào nhóm + D5 **im lặng tắt** cho toàn bộ phiếu nhóm 1. Không có lỗi nào báo.
**Vì sao test không thấy:** 14 ca chạy trên **SQLite** (`SqliteDbFixture`), nơi `=` trên TEXT mặc định là **BINARY = case-sensitive**. Tức phép so ở tầng DB trong test có ngữ nghĩa **NGƯỢC** với prod. Đây không phải test viết ẩu — nó **mù theo cấu trúc**, không assertion nào bịt được.
**Acceptance đề nghị:** chuẩn hoá tại **đường ghi**`Code = request.Code.Trim().ToUpperInvariant()` trong `CreateAwDefinitionCommandHandler` (và dùng cùng giá trị đó cho `nextVersion` + `actives`), **hoặc** thêm `RegexOptions.IgnoreCase` cho `GroupCodePattern` + khai rõ "W1a phụ thuộc collation" ngay tại `:350`. Kèm 1 ca test `khkk-n1` để bất-biến này có chứng nhân (dù SQLite, nó vẫn bắt được vế regex).
---
## 🔴 MAJOR-3 — rào D6 ở đường TẠO có 0 test; T7 chỉ phủ đường SỬA NHÁP
**Vị trí:** `ContractSigningPlanFeatures.cs:507` (`EnsureWorkflowPinnableAsync` tại Create).
Spec W1b2 nói rõ **2 site** (Create + UpdateDraft). Danh sách T1T14 **chỉ có T7**, và T7 gọi `UpdateDraftAsync` ở cả 4 chân — **không chân nào đi qua Create**. Vậy một guard **hard-409 mới cắm vào write-path chính của prod** (mọi phiếu KHKK đều sinh ra qua đây) đang có **0 răng đo**.
Rủi ro cụ thể: nếu ai đó về sau dời `EnsureWorkflowPinnableAsync` ra khỏi Create, hoặc đảo nó lên **trước** `EnsureWorkflowTypeAsync` (làm 404 đổi thành 409), hoặc gỡ nó khi refactor — **659 test vẫn xanh**. Và chiều ngược cũng không được đo: rào này có thể đang **chặn oan** một luồng tạo hợp lệ nào đó mà chưa ai chạm tới.
Ghi chú công bằng: đây là **lỗ của SPEC** (spec liệt 14 ca và thiếu ca này), implementer làm đúng spec. Nhưng luật §7 nói *"New endpoint / guard mới ở write-path → test"*, và tôi không hạ thước theo spec.
**Acceptance đề nghị:** thêm **1 ca** vào `ContractSigningPlanGroupAndCatalogTests` (nơi đã có sẵn `CreateAsync` helper + seed PE): Create pin workflow `IsActive=false` ⇒ 409 · pin `IsUserSelectable=false` ⇒ 409 **message khác** · control dương pin bản active+ghim ⇒ tạo được **và không đốt mã** (`ReadPlanScopesAsync` rỗng ở 2 ca đầu — khuôn đã có ở `:869`).
---
# MINOR
- **MINOR-1 — assertion mù THỨ TỰ toán hạng.** `Contain("N5").And.Contain("N3")` (`GroupAndCatalogTests:865`, `ApprovalTests:188` + `:246`) vẫn xanh nếu ai đó **đảo 2 đối số** ở call-site (`EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)` — 2 tham số kề nhau, 3 call-site). Tôi đã đối chiếu tay: **cả 3 site hiện ĐÚNG**. Nhưng lưới không giữ được bất-biến đó. Đề nghị pin nguyên câu có thứ tự: `Contain("Phiếu nhóm N3 đang gắn quy trình N5")` ở ít nhất 1 ca.
- **MINOR-2 — `.session-counter.json` bị thêm BOM + đổi sang CRLF.** HEAD: `7b 0a` (`{`+LF). Worktree: `ef bb bf 7b 0d 0a` (**BOM** + `{` + CRLF). CRLF sẽ được git normalize, **BOM thì được commit**. Hiện chưa có script nào parse file này (`.claude/hooks/` chỉ có `wal-flush.ps1`, không đọc), nên rủi ro thấp — nhưng đây đúng là bẫy `BOM-json` đã ghi trong `feedback_resume_premise_reverify`, và nó vào commit **kèm theo code** mà không ai chủ ý. Đề nghị: `git add` **từng file cụ thể** (không `-A`), hoặc tách thành commit `wal:`/governance riêng.
- **MINOR-3 — D5 đếm anh-em KHÔNG lọc `ApplicableType`.** `AdminFeatures.cs:1339-1340`: `Count(w => w.Code == def.Code && w.Id != def.Id)`. Một dòng `KHKK-N1` ở type khác (UNIQUE là `(Code, Version)` nên chỉ cần Version khác) sẽ được tính là "anh em" và cho phép xoá bản type-10 cuối cùng. Ca hiếm, nhưng vá là **1 mệnh đề**: `&& w.ApplicableType == def.ApplicableType`.
- **MINOR-4 — panel nhóm tạo version MỚI ⇒ 6 cờ khoá bị đóng băng ở DEFAULT của FE, không phải "state thật".** Trên panel nhóm, "Tạo phiên bản mới" (không clone) khởi tạo entry từ `makeEmptyStep`; 6 ô `disabled` ⇒ admin **không thể** sửa ⇒ POST luôn ghi đúng bộ mặc định FE. Với KHKK thì vô hại (BE không đọc 6 cờ đó), nhưng chữ "state THẬT" trong D3=C **chỉ đúng cho đường CLONE/EDIT**. Đề nghị: khai 1 dòng trong commit-body hoặc nợ-khai, đừng để lượt sau đọc "state thật" rồi suy sang ca tạo-mới.
- **MINOR-5 — bỏ ghim bản active = đóng cửa tạo phiếu nhóm (không có rào).** Spec §54 đã khai; sau W2g còn chặt hơn (`isActive && isUserSelectable`). Không đòi vá đợt này, chỉ xác nhận **răng đã sắc hơn spec mô tả** ⇒ khi đóng sổ nên ghi vào accepted-risk cho đủ 2 vế (bỏ-ghim **và** bỏ-active).
- **MINOR-6 — `EnsureWorkflowGroupMatchAsync` cho qua khi `awId` không tồn tại.** Có khai chủ đích `:55-57` và ở Create/UpdateDraft thì `EnsureWorkflowTypeAsync` chạy trước nên vô hại. Ở **Submit** không có rào type đứng trước — nhưng `ContractSigningPlans.ApprovalWorkflowId`**FK Restrict** (`ContractSigningPlanConfiguration.cs:50-53`) nên `awId` luôn resolve. Ghi lại để lượt sau đừng gỡ FK mà quên chỗ này.
---
# Điểm CHỊU ĐƯỢC soi (positive validation — ghi để lượt sau đừng phá)
1. **3/3 finding 🔴 của tôi ở review đợt-1 đều được đóng ĐÚNG BẢN CHẤT**, không phải đóng bằng chữ: (a) assembly-ranh → file `public static class` riêng; (b) `IgnoreQueryFilters` cho bảng có `HasQueryFilter` → đúng 3 bảng, và **phân biệt được 2 chiều** (DELETE cần, changelog cố ý không); (c) FK-thật → grep 8 module, 5 module loose-0-FK được khai là "rào DUY NHẤT" + T12 chứng minh bằng dangling THẬT trong DB.
2. **Mọi ca test đều có control dương CÙNG LƯỢT** — T9/T10/T11/T13/T14 và cả T6 chân (2). Không có ca "vế âm xanh-mù" nào.
3. **T5 dựng lại đúng trạng thái không-thể-tạo-qua-API** (seed thẳng DbContext) và **loại trước 2 cửa 409 nhiễu** bằng cách kéo hạng-mục theo `approvalGroup`. Đây là chống "xanh vì nhầm cửa" ở mức tôi không bẻ được.
4. **`key={typeCode ?? 'all'}` trên `TypePanel`** (`:400`) — remount khi đổi nhóm, chặn state-bleed giữa 8 panel dùng chung component. Chi tiết nhỏ nhưng đúng chỗ.
5. **POST gửi `code: lockedCode ?? code`** (`:882`) chứ không tin ô input đã `disabled` — bất-biến theo CẤU TRÚC, không theo kỷ luật UI.

View File

@ -0,0 +1,90 @@
# sub-test-specialist-2 — ĐỢT-1 lane TEST W1 (14 test T1-T14)
> ⚠️ TASK header ghi artifact = `sub-test-specialist-0.md`, nhưng khối RUN-TRACE (ISOLATION B6) ra lệnh
> **CHỈ ĐƯỢC GHI** `sub-test-specialist-2.md`. Ưu tiên luật isolation ⇒ ghi file NÀY, KHÔNG tạo `-0`.
- ts-mở: 2026-08-08 · lane: test-specialist (WRITE `tests/**` ONLY, 0 chạm `src/**`)
- SPEC: `spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1 khối Test (:37-39)
- Homes: `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs` (T1-T3, T8-T14)
· `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs` (T4-T7)
- 🔴 implementer-backend land 5 fix SONG SONG ⇒ test viết theo **HÀNH VI ĐÍCH**; ca đỏ-vì-BE-chưa-land = RED hợp lệ.
## §0 — Ghi liên tục (chống-#53). Mục CHƯA CHẠY chỉ có tiêu-đề + "CHƯA ĐO".
## §1 — Đọc nền (prod code sites) — ĐÃ ĐỌC
| Vế | Site trên đĩa (HEAD hôm nay) | Hành vi HÔM NAY | ⇒ test |
|---|---|---|---|
| W1a deactivate | `ApprovalWorkflowV2AdminFeatures.cs:339-343` `Where(w.ApplicableType == typeEnum && w.IsActive)` + comment `:339` "only ONE active per type" | tắt **MỌI** active cùng type ⇒ POST 1 nhóm = tắt cả 8 | T1 ĐỎ · T3 ĐỎ · T2 XANH (type-1 giữ per-type) |
| nextVersion | `:334-337` `Where(w.Code == request.Code).Max(Version)` | ĐÃ per-Code sẵn (bất đối xứng với `:340`) | nền của T1/T3 |
| Group-match Create | `ContractSigningPlanFeatures.cs:479-486` regex `^KHKK-N([1-8])$`, code ngoài họ CHO QUA (`:477-478` chủ đích) | CÓ ở Create | (đã có test cũ, xem §4) |
| Group-match UpdateDraft | `:603-607`**chỉ** `EnsureWorkflowTypeAsync` | KHÔNG check nhóm | T4 ĐỎ |
| Group-match Submit | `ContractSigningPlanWorkflowService.cs:176-187` (rào hạng mục) → `:189 LoadStepsAsync` | KHÔNG check nhóm | T5 ĐỎ |
| Pin-check IsActive/Selectable | `:603-607` không đọc 2 cờ | pin bản archived OK | T7 ĐỎ |
| Changelog PUT | `:863-865` gate `ApplicableType is DuyetNcc or DuyetNccPhuongAn` | type-10 KHÔNG ghi vết | T8 ĐỎ · T9 (vế 0-row) XANH-mù ⇒ phải kèm control-dương |
| DELETE | `:1193-1205` `KeyNotFoundException` + `Remove` trần, 0 usage-check | KHKK/PE/Contract = **FK Restrict**`DbUpdateException` 500 · Leave/Ot/… = **loose 0-FK****200 xoá êm** | T10-T14 ĐỎ |
Sự-thật nền dùng để dựng lưới:
- 8 module có cột `ApprovalWorkflowId`: Contract · ContractSigningPlan · LeaveRequest · OtRequest · Proposal · TravelRequest · VehicleBooking · PurchaseEvaluation (`grep --include=*.cs src/Backend/SolutionErp.Domain/`).
- `ContractSigningPlanConfiguration.cs:50-53` FK **Restrict** + `:55` `HasQueryFilter(!IsDeleted)` ⇒ T11 phân biệt được "guard đọc soft-deleted" (409) vs "guard mù" (DbUpdateException).
- `WorkflowAppsConfiguration.cs:12-26` LeaveRequest **KHÔNG khai FK** cho `ApprovalWorkflowId`/`LeaveTypeId` ⇒ T12 seed Guid tự do; và guard là RÀO DUY NHẤT (chứng-nhân trong test).
- `NotFoundException : Exception` (`Common/Exceptions/ApplicationException.cs:3`) — KHÔNG họ hàng `KeyNotFoundException` ⇒ T13 đỏ thật hôm nay.
- `ApprovalWorkflowConfiguration.cs:26` UNIQUE (Code, Version) ⇒ T14 seed được 2 version cùng Code.
## §2 — Baseline đo tươi
- `git status` lúc mở lane: **`src/` SẠCH** (implementer-backend chưa land) ⇒ số đo dưới là nền TRƯỚC cả 5 fix.
- Lệnh: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (chạy nền).
- Kết quả: xem §7 (điền khi job xong).
## §3 — T1-T3 (deactivate-per-Code, home UpdateAwDefinitionTests)
CHƯA ĐO
## §4 — T4-T7 (group-match guard + pin-check, home ContractSigningPlanApprovalTests)
CHƯA ĐO
## §5 — T8-T9 (changelog PUT type-10)
CHƯA ĐO
## §6 — T10-T14 (DELETE usage-check 409/404 + D5 last-version)
CHƯA ĐO
## §7 — Bảng kết {test → RED | GREEN | đợi-BE}
CHƯA ĐO
## §8 — Bug prod phát hiện / spec drift
CHƯA ĐO
## §9 — VÁ MAJOR-3 (reviewer pre-commit) — D6 ở đường TẠO · ĐÃ ĐO
- ts: 2026-08-08 (lượt resume riêng, sau khi reviewer-precommit ra finding). Scope: **đúng 1 `[Fact]`**, 0 chạm `src/**`, 0 sửa 14 ca cũ, 0 commit.
- **Lỗ:** `EnsureWorkflowPinnableAsync` cắm 2 site (Create `ContractSigningPlanFeatures.cs:507` · UpdateDraft `:642`) mà T7 (`ContractSigningPlanApprovalTests.cs:1402`) gọi `UpdateDraftAsync` ở CẢ 4 chân ⇒ đường TẠO 0 răng đo.
- **Nhà:** `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs`
— 1 `[Fact]` `Create_PinQuyTrinhDaNgungHoacChuaGhim_ThiBiChan_VaKhongDotMa` (đặt cạnh ca rào (v) để dùng chung `SeedPeForCreateAsync` + `CreateAsync` + `ReadPlanScopesAsync`).
`SeedWorkflowAsync` +3 tham số **ĐUÔI** optional (`version` / `isActive` / `isUserSelectable`, default giữ hành vi cũ) ⇒ **15 call-site cũ 0 đổi chữ**; cùng khuôn `ContractSigningPlanApprovalTests.cs:135-140`.
**Thiết kế phép đo (vì sao 3 chân này phân biệt được 2 thế giới):**
| Chân | Seed (khác nhau ĐÚNG Version + 1 cờ) | Kỳ vọng | Bịt cái "xanh giả" nào |
|---|---|---|---|
| (1) | `KHKK-N1` v2 `IsActive=false` | 409 chứa "ngừng hoạt động" | — |
| (2) | `KHKK-N1` v3 `IsUserSelectable=false`, **`IsActive` VẪN true** | 409 chứa "ghim" **∧ message ≠ chân (1)** | 2 cờ cùng false ⇒ `!IsActive` nổ trước ⇒ chân (2) đo lại chân (1) = phép đo RỖNG |
| (3) | `KHKK-N1` v1 active+ghim | tạo được, mã **`KHKK/2026/001`** | rào chặn-tuốt (chân 3 đỏ) ⟂ rào bị gỡ khỏi Create (chân 1-2 xanh giả) |
- **Code cùng họ `KHKK-N1` + phiếu nhóm 1** ⇒ rào (v) `:515` KHỚP nên im lặng; mọi 409 quan sát được chỉ có thể từ D6. Ba message của các rào khác trong đường tạo đều khác chữ ("không thuộc loại…" / "đã có kế hoạch ký kết cho nhóm duyệt N…" / "Phiếu nhóm N{g} đang gắn quy trình N{k}") ⇒ 2 phép `Contain` định danh D6 duy nhất.
- **Ordering ĐO TRỰC TIẾP, không suy luận:** sau 2 lần 409, assert `WorkflowAppCodeSequences.Any(Prefix=="KHKK/2026") == false` — nếu ai dời D6 xuống SAU `codeGen.GenerateAsync` (`:524`) thì hàng sequence đã tồn tại với `LastSeq=2` và chân (3) ra `003`. Codegen **tự commit** transaction riêng (`ContractSigningPlanCodeGenerator.cs:23-43`) ⇒ số mất vĩnh viễn, không rollback theo request.
- **Không để lại phiếu nửa vời:** `ReadPlanScopesAsync` rỗng sau 2 lần 409 (D6 ném trước `db.ContractSigningPlans.Add` `:577`), rồi `BeEquivalentTo` đúng 1 `PlanScope("KHKK/2026/001", 1, dangDung)` sau chân (3).
- 🔴 **CỐ Ý KHÔNG pin chuỗi `wf.Code` trong message** dù prod có chèn (`:342`/`:345`): bất-biến mà spec tuyên bố là "**2 câu TÁCH**", pin thêm chữ Code là biến chi-tiết trình bày thành yêu cầu (bài S164 không-pin-config-lie). Vế `NotBe` giữa 2 message mới là thứ khoá đúng tuyên bố đó.
**Đo (lệnh chuẩn `-p:BuildInParallel=false -maxcpucount:1`):**
- Ca mới chạy CÔ LẬP (`--filter`): `Passed: 1, Failed: 0`.
- Full-suite `dotnet test SolutionErp.slnx`: **Domain 45 + Infra 615 = 660 PASS, 0 FAIL** (nền 659 → **Δ+1**, 0 hồi quy).
- `git status`: đổi DUY NHẤT `…/ContractSigningPlanGroupAndCatalogTests.cs` (+106/7). `git diff --stat -- src/` = 3 file của lane implementer-backend (có TRƯỚC lượt này), **0 dòng của tôi**.
**Bug prod / spec drift lượt này:** KHÔNG có. Guard hành xử đúng như spec W1b2 mô tả ở cả 3 chân.

View File

@ -43,6 +43,8 @@ Dữ-liệu cũ (WAL · HANDOFF · auto-memory · migration-todos) **KHÔNG** đ
| `YC-010` | "OK làm hết rồi khép trọn đi" | 2026-08-07 · S181 | đóng | **2** | `WAL` chain W5b/W3b/W6 | Khép trọn gói: W5b + W3b + phần còn lại W6 | `YC-008` |
| `YC-011` | "1,2 -> làm đi, OK sau đó /session-end" | 2026-08-07 · S181 | mở | 1 | `WAL` `next:` | (1) push 42 commit · (2) cổng đồng-bộ + báo cáo cuối · rồi đóng phiên | |
| `YC-012` | "nâng cap" *(🔸 trích từ `spec-…-07-08-2026.md:64`, cửa-sổ **S180**, KHÔNG bắt trực tiếp ở cửa-sổ này — khai đúng nấc)* | 2026-08-07 · S180 | đóng | 1 | `memory-budget.json` `_cap_raised_S181` | Lệnh dứt khoát nâng `hmw_width.cap` 20→30. 🔴 @S179 lead **tự thu-hẹp** chữ "OK" thành "chưa phải lệnh đổi số" — lần này không tự thu-hẹp | |
| `YC-013` | "Hiện tạo muốn cac quy trình này giống như duyệt NCC. Có thể thêm sửa xóa edit stick tương tự như duyệt NCC. Cho 2 /fable-real invest cách làm BE và Database, 1 /fable-real invest cách cấu hình giao diện front-end đúng nhất về UI lẫn giao diện, ko lệch. -> Ra các wave xử lý chi tiết nhất. cho 2 /fable-real review và tổng hợp các wave xử lý chi tiết. Cuối cùng fan-out cho Opus 5 MAX xử lý nhé." *(kèm 4 ảnh trang `approval-workflows-v2/DuyetNcc` + dialog Sửa-tại-chỗ)* | 2026-08-08 · S182 | **mở** | 1 | `runs/2026-08-08-S182-khkk-awf-mirror-ncc/` | 8 workflow "Duyệt KHKK — Nhóm 1..8" phải có ĐỦ chức năng như trang "Duyệt NCC": xem steps/cấp/NV + per-NV quyền duyệt + Sửa-tại-chỗ + Tạo-phiên-bản-mới + Ghim/Bỏ-ghim + Xoá-version + lịch-sử-versions. UI **không lệch** khuôn DuyetNcc. Pipeline anh chỉ định: 3 `/fable-real investigator-codebase` (BE · DB · FE-UI) → wave-plan chi tiết → 2 `/fable-real reviewer` → hmw fan-out **Opus 5 MAX** execute | |
| `YC-014` | "C — Mờ ô chưa nghe (Recommended)" *(AskUser D3 @S182 — sau khi invest phát hiện 6/9 checkbox KHKK-runtime bỏ qua)* | 2026-08-08 · S182 | đóng | 1 | `spec-khkk-awf-mirror-ncc-DRAFT.md` MỤC 3 D3 | Dialog KHKK giữ layout y khuôn; ô máy-chưa-nghe **mờ + tooltip "chưa áp dụng cho KHKK"** (tiền lệ ô Duyệt-thẳng-Cấp-cuối @K3). KHÔNG port semantics đợt này (W3 hủy); port sau thì gỡ mờ dần | |
> 🔴 **MỘT yêu-cầu, nhắc 4 lần.** Đây là dữ-liệu **TC-01** sinh ra để bắt, và nó tố-giác chính lead: hiểu sai ở `YC-002` nên anh phải nói lại 3 lần. **Ghi lại chứ không làm đẹp.**
>

View File

@ -30,6 +30,21 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// [W2e S182] 8 leaf Designer theo NHÓM DUYỆT của Kế hoạch ký kết HĐ.
// Mirror BE `MenuKeys.cs:127-134` (8 key này VÀO `All` ⇒ 32 policy AwV2_KhkkN{n}.{R,C,U,D}).
// ⚠️ File này là bản chép TAY của BE và hiện chỉ dùng cho `can(menuKey, action)`;
// 8 key dưới đây CHƯA có call-site nào — chép vào để bảng đối chiếu FE↔BE không
// khuyết, tránh lượt sau đọc chỗ này rồi kết luận "BE chưa có key nhóm".
// Đường vào sidebar KHÔNG đi qua đây: sidebar dựng từ `/api/menus/me` rồi
// `Layout.tsx:188` (`/^KhkkN[1-8]$/`) mới đổi key thành đường dẫn.
AwV2_KhkkN1: 'AwV2_KhkkN1',
AwV2_KhkkN2: 'AwV2_KhkkN2',
AwV2_KhkkN3: 'AwV2_KhkkN3',
AwV2_KhkkN4: 'AwV2_KhkkN4',
AwV2_KhkkN5: 'AwV2_KhkkN5',
AwV2_KhkkN6: 'AwV2_KhkkN6',
AwV2_KhkkN7: 'AwV2_KhkkN7',
AwV2_KhkkN8: 'AwV2_KhkkN8',
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
// "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)

View File

@ -59,6 +59,11 @@ interface WorkflowDto {
code: string
name: string
isUserSelectable: boolean
// [W2g S182] Cùng một mã `KHKK-N{n}` có NHIỀU phiên bản (admin bấm "Tạo phiên bản
// mới" thì bản cũ chuyển sang lưu trữ). `history` mà BE trả về là DANH SÁCH ĐẦY ĐỦ
// — cả bản đang áp dụng lẫn bản đã lưu trữ — nên thiếu cờ này thì màn tạo phiếu
// không phân biệt được, xem thêm chỗ dùng bên dưới.
isActive: boolean
}
// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
@ -110,9 +115,15 @@ export function KhkkCreatePage() {
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
// [W2g S182] 🔴 Phải lọc thêm `isActive`. Danh sách trả về gồm CẢ phiên bản đã lưu
// trữ, mà `find` lấy phần tử ĐẦU TIÊN khớp mã — hễ admin tạo phiên bản mới cho nhóm
// này là phiếu mới có thể bị gắn vào bản CŨ (thứ tự do BE trả, không do màn này
// quyết), đi theo đội hình duyệt đã bị thay. Gắn sai quy trình thì lúc phát hiện
// phiếu đã chạy được vài trạm rồi. Ràng buộc `isUserSelectable` ở query trên vẫn giữ
// (admin ghim bản nào thì user mới được dùng bản đó).
const wantWorkflowCode = `KHKK-N${approvalGroup}`
const autoWorkflow = workflows.data?.find(
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
(w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
) ?? null
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
@ -125,7 +136,11 @@ export function KhkkCreatePage() {
const create = useMutation({
mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
// [W2g S182] Câu này nói ĐIỀU KIỆN, không nói "không tồn tại": quy trình có thể
// đang nằm đó nhưng ở phiên bản đã lưu trữ hoặc chưa được ghim.
if (!autoWorkflow) {
throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode} đang áp dụng và được ghim cho user chọn`)
}
const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId: autoWorkflow.id,
@ -349,9 +364,10 @@ export function KhkkCreatePage() {
</div>
{!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa có quy trình <strong>{wantWorkflowCode}</strong> (hoặc chưa đưc ghim
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống
KHÔNG tự ng quy trình của nhóm khác.
Chưa dùng đưc quy trình <strong>{wantWorkflowCode}</strong>: phải
phiên bản <strong>đang áp dụng</strong> <strong>đã ghim</strong> cho user chọn.
Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống KHÔNG tự dùng
phiên bản đã lưu trữ, cũng KHÔNG tự dùng quy trình của nhóm khác.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">

View File

@ -31,6 +31,9 @@ import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Department, Paged } from '@/types/master'
// [W2 S182] 2 hàm dùng chung với màn nghiệp vụ (fe-user) — KHÔNG chép chuỗi
// `KHKK-N{n}` / nhãn nhóm vào file này, để 1 nguồn duy nhất khi BE đổi.
import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk'
// ===== Types (mirror BE AwAdminOverviewDto) =====
@ -286,7 +289,21 @@ function isLevelEnabled(step: EditStep, order: LevelOrder): boolean {
export function ApprovalWorkflowsV2Page() {
const qc = useQueryClient()
const { typeCode } = useParams<{ typeCode?: string }>()
const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null
// [W2a S182] 8 leaf `AwV2_KhkkN1..8` deep-link về CHÍNH trang này với typeCode
// `KhkkN{1..8}` (`Layout.tsx:188` — chỗ-thứ-4 Pattern 16-bis đã nối từ K3 S164).
// `KhkkN{n}` KHÔNG phải một ApplicableType riêng: cả 8 nhóm đều là type 10
// (Kế hoạch ký kết HĐ), phân biệt nhau bằng MÃ quy trình `KHKK-N{n}`. Nên chỗ này
// tách 2 khái niệm:
// · `khkkGroup` = nhóm duyệt 1..8 (null nếu không vào từ leaf nhóm)
// · `selectedTypeInt` = ApplicableType gửi BE — nhóm nào cũng là 10.
// Hệ quả CÓ CHỦ ĐÍCH: queryKey của 8 panel nhóm + panel `/ContractSigningPlan`
// TRÙNG NHAU ⇒ 1 lần tải dùng chung, và invalidate sau khi lưu làm tươi cả 9 màn.
const khkkGroupMatch = typeCode?.match(/^KhkkN([1-8])$/) ?? null
const khkkGroup = khkkGroupMatch ? Number(khkkGroupMatch[1]) : null
const selectedTypeInt = khkkGroup !== null
? TYPE_INT_CONTRACT_SIGNING_PLAN
: typeCode ? TYPE_CODE_TO_INT[typeCode] : null
const overview = useQuery({
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
@ -296,25 +313,60 @@ export function ApprovalWorkflowsV2Page() {
},
})
const currentType = selectedTypeInt
? overview.data?.types.find(t => t.applicableType === selectedTypeInt)
const typeBucket = selectedTypeInt
? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null
: null
// [W2b S182] Lát cắt TỔNG HỢP cho panel nhóm. BE trả 1 bucket cho CẢ type 10
// (`active` = 1-trong-8 quy trình nhóm, tuỳ bản nào kích hoạt sau cùng), nên
// panel nhóm KHÔNG được đọc `bucket.active` — sẽ hiện nhầm quy trình của nhóm khác.
// Cắt lại từ `history`:
// 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE
// `ApprovalWorkflowV2AdminFeatures.cs:206-211` — không lọc `!IsActive`);
// chính `TypePanel` mới lọc `!isActive` lúc render mục "Lịch sử versions".
// · `active` suy TỪ chính lát cắt (`find(v => v.isActive)`), không mượn ô ngoài.
// Giữ nguyên shape `TypeSummaryDto` ⇒ `TypePanel`/`Designer` không cần biết
// chúng đang xem một nhóm hay cả loại.
const currentType: TypeSummaryDto | null = useMemo(() => {
if (khkkGroup === null) return typeBucket
if (!typeBucket) return null
const wantCode = khkkGroupWorkflowCode(khkkGroup)
const versions = typeBucket.history.filter(d => d.code === wantCode)
return {
applicableType: typeBucket.applicableType,
applicableTypeLabel: typeBucket.applicableTypeLabel,
active: versions.find(v => v.isActive) ?? null,
history: versions,
}
}, [khkkGroup, typeBucket])
// [W2c S182] Mã quy trình của panel nhóm là KHOÁ, không phải gợi ý: mỗi nhóm đúng
// MỘT chuỗi phiên bản `KHKK-N{n}`. Gõ mã khác trên panel nhóm = đẻ quy trình mồ côi
// (phiếu nhóm đó vẫn tìm `KHKK-N{n}` — `KhkkCreatePage.tsx:113`) ⇒ khoá thẳng ô Mã.
const lockedCode = khkkGroup !== null ? khkkGroupWorkflowCode(khkkGroup) : null
return (
<div className="p-6">
<PageHeader
// [W2h/D4 S182] Vào từ leaf nhóm thì tiêu đề phải nói ĐÚNG nhóm nào —
// `applicableTypeLabel` của BE chỉ có một chuỗi chung cho cả 8 nhóm
// ("Kế hoạch ký kết HĐ"), 8 panel sẽ trông y hệt nhau.
title={
<span className="flex items-center gap-2">
<Workflow className="h-5 w-5" />
{currentType
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
: 'Quy trình duyệt (Mới)'}
{khkkGroup !== null
? `Quy trình duyệt (Mới): ${khkkGroupMenuLabel(khkkGroup)}`
: currentType
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
: 'Quy trình duyệt (Mới)'}
</span>
}
description={
currentType
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
khkkGroup !== null
? `${currentType?.applicableTypeLabel ?? 'Kế hoạch ký kết HĐ'} — nhóm duyệt N${khkkGroup}. Mã quy trình khoá theo nhóm: ${lockedCode}; mỗi lần lưu là một phiên bản mới của CHÍNH nhóm này, 7 nhóm còn lại không đổi.`
: currentType
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
}
/>
@ -344,7 +396,11 @@ export function ApprovalWorkflowsV2Page() {
{currentType && (
<TypePanel
// [W2b S182] Panel nhóm và panel loại dùng CHUNG component: khác nhau duy
// nhất ở lát cắt truyền vào + `lockedCode` (null = panel loại, tự do đặt mã).
key={typeCode ?? 'all'}
type={currentType}
lockedCode={lockedCode}
onSaved={() => qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })}
/>
)}
@ -354,7 +410,17 @@ export function ApprovalWorkflowsV2Page() {
// ===== Per-type panel =====
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
function TypePanel({
type,
// [W2c S182] `null` = panel theo LOẠI (admin tự đặt mã, hành vi cũ).
// Khác null = panel theo NHÓM KHKK: mã bị khoá, mọi version đều thuộc chuỗi đó.
lockedCode,
onSaved,
}: {
type: TypeSummaryDto
lockedCode: string | null
onSaved: () => void
}) {
const [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id +
@ -448,6 +514,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<Designer
applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel}
lockedCode={lockedCode}
cloneFrom={cloneFrom}
editTarget={editTarget}
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
@ -620,6 +687,7 @@ function DefinitionCard({
function Designer({
applicableType,
applicableTypeLabel,
lockedCode,
cloneFrom,
editTarget,
onClose,
@ -627,6 +695,8 @@ function Designer({
}: {
applicableType: number
applicableTypeLabel: string
// [W2c S182] Mã bị KHOÁ theo nhóm KHKK (`KHKK-N{n}`), null = tự do như cũ.
lockedCode: string | null
cloneFrom: DefinitionDto | null
// [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom.
editTarget: DefinitionDto | null
@ -638,16 +708,38 @@ function Designer({
const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom
// [K3 S164GỠ 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`).
// [W2d/W2f S182D3=C] Kế hoạch ký kết HĐ (type 10, gồm cả 8 panel nhóm) chưa
// dùng tới một phần cờ quyền của khuôn Duyệt NCC. Gom về ĐÚNG MỘT cờ để 8 ô
// (7 ô quyền + ô Ngưỡng CEO) không mỗi chỗ tự viết lại điều kiện rồi lệch nhau.
const isKhkk = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN
// Câu dùng chung cho các ô CHƯA có đường chạy ở KHKK. Ô nào có lý do RIÊNG thì
// viết riêng tại chỗ (xem ô "Trả về Drafter") — trạng thái tick vẫn là THẬT.
// 🔴 Câu nhắc phải gắn `title` trên <label>, TUYỆT ĐỐI KHÔNG trên <input disabled>:
// Chromium (Chrome/Edge — trình duyệt đích) nuốt sự kiện chuột của form-control
// disabled ⇒ tooltip KHÔNG bao giờ bật, admin chỉ còn thấy mấy ô xám vô cớ. Label
// không disabled nên rê vào chữ là hiện. Ai dời ngược lên input = làm câm lại.
const khkkNotYetHint = 'Chưa áp dụng cho KHKK'
// Ô "Trả về Drafter" (#4) khác 6 ô kia về NGHĨA, nên câu riêng — và câu này còn
// hiện thành chữ NHÌN THẤY ĐƯỢC ngay dưới ô: đây là thông tin PHÂN BIỆT #4 với 6 ô
// "chưa áp dụng", không được để nó phụ thuộc vào việc người dùng có rê chuột hay không.
// Một chuỗi dùng cho cả tooltip lẫn dòng chữ, để hai chỗ không trôi lệch nhau.
const khkkDrafterHint = 'KHKK hiện luôn trả về người soạn khi Trả lại — cờ chưa phân nhánh'
// [K3 S164 — GỠ KHOÁ · vá chữ W2h S182] Cờ `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN
// và ô "Duyệt là KẾT THÚC" (#8) nay SỐNG cho mọi loại — BE đã port đường finalize ở K3
// cùng wave, owner chốt TP.CCM là người quyết lên CEO hay kết thúc.
// 🔴 Đính chính câu cũ ("2 ô duyệt sớm sống cho MỌI loại"): chỉ có MỘT trong hai ô đó
// sống cho KHKK. Ô còn lại — "Duyệt thẳng Cấp cuối" (#7) — vẫn KHOÁ cho type 10 từ K3
// S165 (service KHKK chưa port nhánh SkipToFinal, BE Create còn ép-false), xem ngay
// tại ô đó. 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],
)
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
// Panel nhóm: mã đã khoá thì ô Mã phải hiện ĐÚNG mã sẽ gửi đi, không phải mã gợi ý
// chung của loại (`QT-KHKK-V2-001`) — hiện một đằng gửi một nẻo là kiểu bẫy tệ nhất.
const defaultCode = lockedCode ?? DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(source?.code ?? defaultCode)
const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(source?.description ?? '')
@ -795,11 +887,19 @@ function Designer({
// ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============
await api.post('/approval-workflows-v2', {
applicableType,
code,
// [W2c S182] Panel nhóm: mã lấy từ prop khoá, KHÔNG lấy từ state ô nhập.
// Ô nhập đang disabled nên state không đổi được qua UI — nhưng gửi thẳng
// `lockedCode` là bất-biến theo CẤU TRÚC, không phụ thuộc kỷ luật của ô input.
code: lockedCode ?? code,
name,
description: description || null,
// S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số.
ceoApprovalThreshold: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
// [W2f S182] KHKK KHÔNG dùng ngưỡng máy (người được uỷ quyền quyết lên CEO hay
// không, qua ô "Duyệt là KẾT THÚC") ⇒ gửi thẳng null, không để giá trị lỡ tay
// nằm lại trong state rồi âm thầm bật một nhánh rẽ mà quy trình KHKK không chạy.
ceoApprovalThreshold: isKhkk
? null
: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
steps: steps.map((s, i) => ({
order: i + 1,
name: s.name,
@ -924,12 +1024,16 @@ function Designer({
required
className="font-mono"
// Mã là khoá của chuỗi phiên bản ⇒ sửa tại chỗ KHÔNG đổi được (BE :521 → 409).
disabled={isEdit}
// [W2c S182] Panel nhóm KHKK cũng khoá: đổi mã ở đây = đẻ quy trình mồ côi
// (phiếu của nhóm vẫn đi tìm đúng `KHKK-N{n}`).
disabled={isEdit || lockedCode !== null}
/>
<div className="text-[11px] text-slate-400">
{isEdit
? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
: lockedCode !== null
? 'Mã khoá theo nhóm — version tự tăng.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
</div>
</div>
<div className="space-y-1.5">
@ -951,12 +1055,17 @@ function Designer({
// 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh
// cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng
// không gõ vào rồi mới nhận lỗi. Giá trị gửi lên lấy thẳng từ bản đang lưu.
disabled={isEdit}
// [W2f S182] KHKK khoá VÔ ĐIỀU KIỆN (kể cả tạo mới): quy trình KHKK không
// rẽ nhánh theo số tiền — người được uỷ quyền quyết. Tiền lệ cùng cách làm
// là ô "Duyệt thẳng Cấp cuối" bên dưới.
disabled={isEdit || isKhkk}
/>
<div className="text-[11px] leading-relaxed text-slate-400">
{isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
{isKhkk
? 'KHKK không dùng ngưỡng máy — NĐUQ quyết. Ai được kết thúc quy trình thì tick ô "Duyệt là KẾT THÚC" ở đúng NV đó bên dưới.'
: isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
</div>
</div>
@ -984,6 +1093,17 @@ function Designer({
<div className="rounded-lg border border-violet-200 bg-violet-50/30 px-3 py-2 text-[11px] leading-relaxed text-violet-800">
Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới
(Trả lại / Edit Section 2 / Edit Budget / Duyệt thẳng Cấp cuối).
{/* [W2d S182 — D3=C] Nói thẳng lý do các ô bị mờ, thay vì để admin tự đoán
là "mất quyền" hay "lỗi". Ô mờ vẫn hiện ĐÚNG giá trị đang lưu trong DB. */}
{isKhkk && (
<div className="mt-1 text-violet-900">
Riêng Kế hoạch kết : một số ô bị khoá luồng duyệt KHKK chưa dùng tới
(ô vẫn hiển thị đúng giá trị đang lưu, không bị đi). Hai ô đang tác dụng
thật <span className="font-semibold">&quot;Trả về Drafter&quot;</span> (KHKK luôn trả về
người soạn) <span className="font-semibold">&quot;Duyệt KẾT THÚC&quot;</span> tick cho NV
đưc uỷ quyền quyết dừng hay trình tiếp CEO.
</div>
)}
</div>
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
@ -1228,57 +1348,107 @@ function Designer({
<div className="mb-1 text-[10px] font-medium uppercase text-amber-700">
Quyền duyệt {usersList.data?.find(u => u.id === entry.approverUserId)?.fullName ?? 'Chưa chọn NV'}
</div>
{/* [W2d S182 — D3=C] Với KHKK (type 10, gồm 8 panel nhóm) các ô
dưới đây bị KHOÁ vì luồng KHKK chưa chạy tới chúng.
🔴 HAI điều KHÔNG được làm, vì đã có tiền lệ hỏng:
(1) KHÔNG ép `checked={false}`. Ô phải nói đúng thứ ĐANG NẰM
TRONG DB — bản seed có cờ nào bật thì phải thấy nó bật,
nếu không admin sẽ tưởng cờ đã tắt trong khi BE vẫn đọc.
(2) KHÔNG động vào payload (`levelFlagsPayload`): nó tiếp tục
echo NGUYÊN giá trị đang lưu. Khoá là chuyện MÀN HÌNH,
không phải chuyện dữ liệu — khoá bằng cách ghi đè dữ liệu
thì mở lại sau này không còn gì để mở.
Ô nào KHKK có chạy thì để nguyên: "Trả về Drafter" (hành vi thật
là LUÔN trả về người soạn) và "Duyệt là KẾT THÚC" (#8, sống). */}
<div className="grid grid-cols-2 gap-1">
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneLevel}
disabled={isKhkk}
onChange={e => updateField('allowReturnOneLevel', e.target.checked)}
/>
<span>Trả về 1 Cấp trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneStep}
disabled={isKhkk}
onChange={e => updateField('allowReturnOneStep', e.target.checked)}
/>
<span>Trả về 1 Bước trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToAssignee}
disabled={isKhkk}
onChange={e => updateField('allowReturnToAssignee', e.target.checked)}
/>
<span>Trả về Người chỉ đnh</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
{/* [W2d S182] Ô này KHÁC 6 ô kia: KHKK có chạy nhánh trả-về-người-soạn,
và chạy KHÔNG ĐIỀU KIỆN — cờ chưa được phân nhánh ở service nên bỏ
tick cũng không tắt được. Vì vậy khoá nhưng KHÔNG làm mờ kiểu
"chưa có": mờ ở đây sẽ đọc thành "tính năng chưa chạy", ngược hẳn
sự thật. Câu nhắc viết riêng, không dùng câu chung. */}
<label
className="flex items-center gap-1 text-[11px] text-slate-700"
title={isKhkk ? khkkDrafterHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToDrafter}
disabled={isKhkk}
onChange={e => updateField('allowReturnToDrafter', e.target.checked)}
/>
<span>Trả về Drafter (mặc đnh)</span>
</label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
{/* [W2d S182 — vá MAJOR-1] Câu này là thứ DUY NHẤT phân biệt ô trên
với 6 ô "chưa áp dụng" cùng khối, nên nó phải NHÌN THẤY ĐƯỢC chứ
không nằm sau một cú rê chuột. `col-start-2` để dòng chữ rơi đúng
dưới ô của nó (cột phải), không bị đọc nhầm sang ô bên trái. */}
{isKhkk && (
<div className="col-start-2 -mt-0.5 pl-4 text-[10px] leading-snug text-slate-500">
{khkkDrafterHint}
</div>
)}
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditDetails}
disabled={isKhkk}
onChange={e => updateField('allowApproverEditDetails', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span>
</label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditBudget}
disabled={isKhkk}
onChange={e => updateField('allowApproverEditBudget', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span>
@ -1289,13 +1459,20 @@ function Designer({
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'}`}>
{/* [W2d S182] GIỮ NGUYÊN hành vi ô này (kể cả `checked` ép false —
khác 6 ô trên, đây là quyết định cũ của K3 S165 và BE Create
ĐANG THẬT SỰ ép-false cờ này cho type 10, nên màn hình đang
khớp DB). Chỉ đổi 3 biểu thức trùng lặp sang cờ dùng chung
`isKhkk` — giá trị y hệt, để 8 chỗ khoá không lệch nhau. */}
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? '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}
>
<input
type="checkbox"
className="h-3 w-3"
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}
checked={isKhkk ? false : entry.allowApproverSkipToFinal}
disabled={isKhkk}
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
/>
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
@ -1317,11 +1494,15 @@ function Designer({
per-Cấp: 1 dòng NV = 1 ô tick riêng. Tick cho NV nào thì
CHỈ NV đó xóa được, và chỉ khi phiếu đang Chờ duyệt ĐÚNG
lượt của họ. Cấp có 3 NV mà tick 1 ⇒ vẫn chỉ 1 người xóa. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-rose-700">
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-rose-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverDelete}
disabled={isKhkk}
onChange={e => updateField('allowApproverDelete', e.target.checked)}
/>
<span>Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình</span>

View File

@ -30,6 +30,20 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// [W2e S182] 8 leaf Designer theo NHÓM DUYỆT của Kế hoạch ký kết HĐ.
// Mirror BE `MenuKeys.cs:127-134` (8 key này VÀO `All` ⇒ 32 policy AwV2_KhkkN{n}.{R,C,U,D}).
// ⚠️ Chép cho ĐỦ bảng đối chiếu FE↔BE, KHÔNG phải để dùng ở app này: toàn bộ họ
// `AwV2_*` là màn CẤU HÌNH bên fe-admin — fe-user cố ý không có trang Designer,
// cũng không có nhánh `AwV2_*` trong `Layout.tsx`. Đừng đọc mấy dòng này thành
// "fe-user thiếu route" rồi đi thêm route.
AwV2_KhkkN1: 'AwV2_KhkkN1',
AwV2_KhkkN2: 'AwV2_KhkkN2',
AwV2_KhkkN3: 'AwV2_KhkkN3',
AwV2_KhkkN4: 'AwV2_KhkkN4',
AwV2_KhkkN5: 'AwV2_KhkkN5',
AwV2_KhkkN6: 'AwV2_KhkkN6',
AwV2_KhkkN7: 'AwV2_KhkkN7',
AwV2_KhkkN8: 'AwV2_KhkkN8',
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
// "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)

View File

@ -59,6 +59,11 @@ interface WorkflowDto {
code: string
name: string
isUserSelectable: boolean
// [W2g S182] Cùng một mã `KHKK-N{n}` có NHIỀU phiên bản (admin bấm "Tạo phiên bản
// mới" thì bản cũ chuyển sang lưu trữ). `history` mà BE trả về là DANH SÁCH ĐẦY ĐỦ
// — cả bản đang áp dụng lẫn bản đã lưu trữ — nên thiếu cờ này thì màn tạo phiếu
// không phân biệt được, xem thêm chỗ dùng bên dưới.
isActive: boolean
}
// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
@ -110,9 +115,15 @@ export function KhkkCreatePage() {
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
// [W2g S182] 🔴 Phải lọc thêm `isActive`. Danh sách trả về gồm CẢ phiên bản đã lưu
// trữ, mà `find` lấy phần tử ĐẦU TIÊN khớp mã — hễ admin tạo phiên bản mới cho nhóm
// này là phiếu mới có thể bị gắn vào bản CŨ (thứ tự do BE trả, không do màn này
// quyết), đi theo đội hình duyệt đã bị thay. Gắn sai quy trình thì lúc phát hiện
// phiếu đã chạy được vài trạm rồi. Ràng buộc `isUserSelectable` ở query trên vẫn giữ
// (admin ghim bản nào thì user mới được dùng bản đó).
const wantWorkflowCode = `KHKK-N${approvalGroup}`
const autoWorkflow = workflows.data?.find(
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
(w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
) ?? null
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
@ -125,7 +136,11 @@ export function KhkkCreatePage() {
const create = useMutation({
mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
// [W2g S182] Câu này nói ĐIỀU KIỆN, không nói "không tồn tại": quy trình có thể
// đang nằm đó nhưng ở phiên bản đã lưu trữ hoặc chưa được ghim.
if (!autoWorkflow) {
throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode} đang áp dụng và được ghim cho user chọn`)
}
const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId: autoWorkflow.id,
@ -349,9 +364,10 @@ export function KhkkCreatePage() {
</div>
{!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa có quy trình <strong>{wantWorkflowCode}</strong> (hoặc chưa đưc ghim
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống
KHÔNG tự ng quy trình của nhóm khác.
Chưa dùng đưc quy trình <strong>{wantWorkflowCode}</strong>: phải
phiên bản <strong>đang áp dụng</strong> <strong>đã ghim</strong> cho user chọn.
Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống KHÔNG tự dùng
phiên bản đã lưu trữ, cũng KHÔNG tự dùng quy trình của nhóm khác.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">

View File

@ -4,7 +4,9 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans; // [S182 W1d] ContractSigningPlanWorkflowGuards.ParseGroup
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans; // [S182 W1c] changelog + phase KHKK
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
@ -330,21 +332,53 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
{
var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType;
// 🔴 [S182 MAJOR-2] CHUẨN HOÁ `Code` TẠI ĐƯỜNG GHI — nguồn duy nhất cho cả handler.
//
// Trước bản này BA thành phần hỏi cùng câu "code này thuộc nhóm nào" bằng BA luật khác nhau:
// • `ContractSigningPlanWorkflowGuards.GroupCodePattern` — regex .NET, case-SENSITIVE;
// • `w.Code == request.Code` — EF → SQL Server, theo collation `*_CI_AS` = case-INSENSITIVE;
// • FE `KhkkCreatePage` — `.trim().toUpperCase()` = case-INSENSITIVE.
// ⇒ admin gõ `khkk-n1` ở panel type-10 gộp (ô Mã KHÔNG khoá vì `lockedCode = null`):
// DB coi là trùng ⇒ hạ `IsActive` của `KHKK-N1` THẬT · FE vẫn auto-pin sang bản mới ·
// nhưng regex trả `null` ⇒ rào nhóm 3-site và D5 IM LẶNG TẮT cho toàn bộ phiếu nhóm 1.
// Không lỗi nào báo.
// 🔴 Test KHÔNG thể bắt: 14 ca chạy SQLite, nơi `=` trên TEXT là BINARY (case-SENSITIVE)
// ⇒ ngữ nghĩa NGƯỢC prod. Mù theo CẤU TRÚC, không assertion nào bịt được ⇒ phải chặn
// bằng chuẩn-hoá, đừng chặn bằng test.
var code = request.Code.Trim().ToUpperInvariant();
// Auto-increment version theo Code (cùng Code = cùng "logical" workflow)
var nextVersion = await db.ApprovalWorkflows
.Where(w => w.Code == request.Code)
.Where(w => w.Code == code)
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
// Deactivate active version cho ApplicableType này (only ONE active per type)
var actives = await db.ApprovalWorkflows
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct);
// Tắt bản đang active — PHẠM VI phụ thuộc loại (bản trước: LUÔN per-type).
//
// • type-10 `ContractSigningPlan` (KHKK): **per-CODE**. Loại này có 8 quy trình
// SỐNG SONG SONG cùng type — mỗi nhóm duyệt N1..N8 một `Code` riêng
// (`DbInitializer.SeedKhkkGroupWorkflowsAsync:609`, seed cả 8 với IsActive=1).
// Tắt theo type ⇒ tạo version mới cho MỘT nhóm sẽ hạ IsActive của CẢ 7 nhóm còn
// lại (mỗi nhóm mất quy trình active ⇒ FE hết auto-pin ⇒ không tạo được phiếu).
// • các loại khác: **per-TYPE** như cũ — 1 active/type là bất biến của chúng.
//
// 🔴 [S182 R-6] Vị ngữ type-10 KHOÁ đúng 3 vế `Code == request.Code &&
// ApplicableType == typeEnum && IsActive`. CẤM thay bằng biến-thể regex
// `^KHKK-N[1-8]$`: `Code` type-10 nằm ngoài họ đó (vd `QT-KHKK-V2-001`) sẽ rơi
// lại nhánh per-type và nuke đúng 8 nhóm mà rào này sinh ra để cứu.
// (`nextVersion` `:334-337` vốn đã tính per-Code ⇒ nhánh này làm 2 vế khớp nhau.)
var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan
? await db.ApprovalWorkflows
.Where(w => w.Code == code && w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct)
: await db.ApprovalWorkflows
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct);
foreach (var old in actives) old.IsActive = false;
var def = new ApprovalWorkflow
{
Code = request.Code,
Code = code, // [S182 MAJOR-2] bản đã chuẩn hoá, KHÔNG dùng request.Code thô
Version = nextVersion,
ApplicableType = typeEnum,
Name = request.Name,
@ -856,10 +890,17 @@ public class UpdateAwDefinitionCommandHandler(
// được "quy trình vừa bị sửa lúc nào, ai sửa, vì sao".
// ⚠️ KHAI GIỚI HẠN: repo CHƯA có bảng changelog cho quy trình duyệt, mà
// đợt 1 bị chốt "no new table" ⇒ vết cho workflow thuộc 5 module Văn
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) + KHKK (W1 S161 — reviewer F-6b:
// `ContractSigningPlanChangelogs` là changelog PHIẾU, không phải changelog QUY-TRÌNH;
// wire nếu cần ở W2/W3) hiện CHỈ có UpdatedAt/
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
// 🔄 [S182 W1c] KHKK (type-10) ĐÃ WIRE — xem nhánh `else if` bên dưới. Câu cũ ở
// đây xếp KHKK chung rọ "chỉ có UpdatedAt/UpdatedBy" và hẹn "wire nếu cần ở W2/W3";
// đợt này wire thật nên câu đó gỡ, không để lại neo thối.
var note = string.Join(" | ", changes);
if (!string.IsNullOrWhiteSpace(request.Reason))
note = $"Lý do: {request.Reason.Trim()} | {note}";
var summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)";
var actorName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống";
if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
or ApprovalWorkflowApplicableType.DuyetNccPhuongAn)
@ -871,10 +912,6 @@ public class UpdateAwDefinitionCommandHandler(
.Select(p => new { p.Id, p.Phase })
.ToListAsync(ct);
var note = string.Join(" | ", changes);
if (!string.IsNullOrWhiteSpace(request.Reason))
note = $"Lý do: {request.Reason.Trim()} | {note}";
foreach (var pe in affected)
{
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
@ -885,8 +922,48 @@ public class UpdateAwDefinitionCommandHandler(
EntityId = def.Id,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống",
Summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)",
UserName = actorName,
Summary = summary,
ContextNote = note,
});
}
}
// [S182 W1c] KHKK (type-10) — mirror khối PE ngay trên, cùng shape 8 field.
//
// VÌ SAO CẦN: dialog PUT hứa nguyên văn với admin "ghi vào lịch sử phiếu đang chạy"
// (`ApprovalWorkflowsV2Page.tsx:963-977`). Với type-10 lời hứa đó trước nay là RỖNG —
// sửa quy trình xong lịch sử phiếu KHKK không có dòng nào. Đây là fix cho lời hứa
// thành thật, không phải tính năng mới.
//
// 🔴 Vị ngữ phiếu "đang chịu ảnh hưởng" = CHƯA kết thúc: loại 2 phase TERMINAL
// (`DaDuyet` chốt giá + `TuChoi`). `TraLai` KHÔNG loại — phiếu trả lại sẽ được
// trình LẠI và chạy đúng quy trình vừa sửa ⇒ người soạn cần thấy vết.
// 🔴 CỐ Ý KHÔNG `IgnoreQueryFilters()`: `ContractSigningPlans` có global filter
// (`ContractSigningPlanConfiguration.cs:55`) ⇒ phiếu XOÁ MỀM tự rụng khỏi tập.
// Đúng ý muốn: ghi changelog cho phiếu đã xoá là đẻ rác không ai đọc. (Ngược với
// rào DELETE bên dưới — ở đó xoá mềm VẪN phải chặn vì FK còn thật.)
else if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.ContractSigningPlan)
{
var affectedPlans = await db.ContractSigningPlans.AsNoTracking()
.Where(p => p.ApprovalWorkflowId == def.Id
&& p.Phase != ContractSigningPlanPhase.DaDuyet
&& p.Phase != ContractSigningPlanPhase.TuChoi)
.Select(p => new { p.Id, p.Phase })
.ToListAsync(ct);
foreach (var plan in affectedPlans)
{
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
{
ContractSigningPlanId = plan.Id,
EntityType = ContractSigningPlanEntityType.Workflow,
Action = SolutionErp.Domain.Contracts.ChangelogAction.Update,
EntityId = def.Id,
PhaseAtChange = plan.Phase,
UserId = currentUser.UserId,
UserName = actorName,
Summary = summary,
ContextNote = note,
});
}
@ -1184,9 +1261,21 @@ public class SetAwUserSelectableCommandHandler(IApplicationDbContext db)
}
}
// ========== DELETE version (chỉ khi chưa có phiếu pin) ==========
// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT.
// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete.
// ========== DELETE version — CHẶN khi còn phiếu pin / còn chữ ký ==========
//
// 🔴 [S182 W1d] Câu cũ ở đây ("chưa có phiếu nào pin schema mới → unconditional delete OK cho
// UAT") đã HẾT ĐÚNG từ lâu: 8 module pin `ApprovalWorkflowId` thật trên prod. Xoá trần để
// lại 2 hậu quả khác hẳn nhau tuỳ module — và chính vì khác nhau nên rào này có HAI lý do,
// không phải một:
// • PE / Hợp đồng / KHKK có **FK vật lý** ⇒ xoá trần nổ SQL 547 = 500 thô. Rào = đổi
// 500 khó hiểu thành 409 đọc được. (UX)
// • Proposal / Leave / Ot / Travel / Vehicle pin **LOOSE, 0-FK** ⇒ DB không đỡ gì cả,
// xoá trần đi lọt ÊM và để lại con trỏ treo. Rào này là **hàng phòng thủ DUY NHẤT**.
// (TOÀN VẸN DỮ LIỆU — nặng hơn vế trên)
//
// Thứ tự kiểm: usage TRƯỚC (i+ii), D5 SAU (iv). Cả hai đều 409; usage đứng trước vì khi cả hai
// cùng đúng thì "5 phiếu đang gắn" là câu admin xử lý được, còn "nhóm phải còn ≥1 version" là
// luật chính sách — nói cái hành-động-được trước.
public record DeleteAwDefinitionCommand(Guid Id) : IRequest;
@ -1195,9 +1284,90 @@ public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db)
{
public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct)
{
// (iii) `NotFoundException` thay `KeyNotFoundException`: BCL exception KHÔNG được
// `GlobalExceptionMiddleware` map ⇒ id lạ trước nay ra 500 thô. Nay 404.
var def = await db.ApprovalWorkflows
.FirstOrDefaultAsync(d => d.Id == request.Id, ct)
?? throw new KeyNotFoundException($"ApprovalWorkflow {request.Id} không tồn tại.");
?? throw new NotFoundException("ApprovalWorkflow", request.Id);
// ---- (i) Đếm phiếu ĐANG PIN, quét đủ 8 module có cột `ApprovalWorkflowId`.
//
// 🔴 `IgnoreQueryFilters()` cho PE / Contract / KHKK — 3 entity DUY NHẤT có global
// filter `!IsDeleted` (đã grep `HasQueryFilter` trong Configurations để chốt danh
// sách, không suy từ trí nhớ). Phiếu XOÁ MỀM vẫn CÒN ROW ⇒ FK vẫn thật ⇒ bỏ filter
// thì ta đếm ra 0 rồi xoá và nổ 547 đúng lúc chạy. 5 module Văn phòng số không có
// filter nên đếm thẳng.
var pinned = 0;
pinned += await db.PurchaseEvaluations.IgnoreQueryFilters()
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.Contracts.IgnoreQueryFilters()
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.ContractSigningPlans.IgnoreQueryFilters()
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.Proposals.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.LeaveRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.OtRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.TravelRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.VehicleBookings.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
// ---- (ii) Đếm CHỮ KÝ còn trỏ vào các dòng Cấp của quy trình này — 8 bảng
// `*LevelOpinion`. Phiếu có thể đã bỏ pin mà chữ ký vẫn còn (FK Restrict về
// `ApprovalWorkflowLevels`) ⇒ đếm pin thôi là chưa đủ để dám xoá.
// `IgnoreQueryFilters()` cả 8 cho ĐỒNG HÌNH — bảng nào không có filter thì gọi
// vô hại, bảng có filter mà quên gọi thì đếm hụt.
var levelIds = await db.ApprovalWorkflowLevels.AsNoTracking().IgnoreQueryFilters()
.Where(l => l.Step!.ApprovalWorkflowId == def.Id)
.Select(l => l.Id)
.ToListAsync(ct);
var signatures = 0;
if (levelIds.Count > 0)
{
signatures += await db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.ContractLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.ContractSigningPlanLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.ProposalLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.LeaveRequestLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.OtRequestLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.TravelRequestLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.VehicleBookingLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
}
if (pinned > 0 || signatures > 0)
throw new ConflictException(
$"Quy trình '{def.Code}' v{def.Version} đang được dùng: {pinned} hồ sơ đang gắn " +
$"+ {signatures} chữ ký đã lưu — không xoá được. " +
"Muốn ngừng dùng thì bỏ kích hoạt (hoặc bỏ ghim) thay vì xoá.");
// ---- (iv) [D5] Nhóm duyệt KHKK phải LUÔN còn ít nhất 1 version.
//
// Seeder `SeedKhkkGroupWorkflowsAsync` idempotent PER-CODE (`DbInitializer.cs:610`:
// `if (Any(w.Code == code)) continue;`) ⇒ xoá SẠCH một mã `KHKK-N{n}` thì lần khởi
// động sau seeder đẻ LẠI bản v01 mặc định — roster + cờ mà admin đã chỉnh biến mất
// âm thầm, không có lỗi nào báo. Rào ở đây đóng đường UI dẫn tới ca đó.
// (Version 0-usage mà KHÔNG phải bản cuối của mã ⇒ vẫn xoá bình thường.)
if (ContractSigningPlanWorkflowGuards.ParseGroup(def.Code) is int group)
{
// [S182 MINOR-3] Lọc CẢ `ApplicableType`: UNIQUE là `(Code, Version)` chứ không kèm
// type ⇒ một row `KHKK-N{n}` ở type KHÁC (Version khác) sẽ bị tính là "anh em" và
// cho phép xoá bản type-10 CUỐI CÙNG — đúng ca mà D5 sinh ra để chặn.
var siblings = await db.ApprovalWorkflows
.CountAsync(w => w.Code == def.Code
&& w.ApplicableType == def.ApplicableType
&& w.Id != def.Id, ct);
if (siblings == 0)
throw new ConflictException(
$"Nhóm N{group} phải còn ≥1 version — đây là version cuối cùng của quy trình " +
$"'{def.Code}'. Tạo version mới trước, rồi mới xoá bản này.");
}
db.ApprovalWorkflows.Remove(def);
await db.SaveChangesAsync(ct);

View File

@ -317,6 +317,34 @@ internal static class ContractSigningPlanScope
throw new ConflictException("Quy trình duyệt không thuộc loại Kế hoạch ký kết HĐ.");
}
/// [S182 W1b2 — D6] Quy trình có còn được phép PIN MỚI không: `IsActive` ∧ `IsUserSelectable`.
/// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147`.
///
/// 🔴 CHỈ gọi ở đường ĐẶT PIN MỚI (tạo phiếu / sửa nháp có ĐỔI quy trình). CỐ Ý KHÔNG gọi
/// khi giữ nguyên pin cũ: phiếu lỡ pin bản đã archived vẫn phải sửa được GhiChu/HoSoLink,
/// nếu không thì admin ngừng một quy trình là khoá cứng mọi phiếu nháp đang trỏ vào nó.
/// Cũng KHÔNG gọi ở đường TRÌNH duyệt vì lý do đó (D6 chốt 2 site).
///
/// 2 cờ ném 2 message TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): "đã ngừng
/// hoạt động" và "chưa ghim" sửa ở 2 nút khác nhau trong Designer — gộp thì người đọc lỗi
/// không biết bấm nút nào.
internal static async Task EnsureWorkflowPinnableAsync(
IApplicationDbContext db, Guid approvalWorkflowId, CancellationToken ct)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == approvalWorkflowId)
.Select(w => new { w.Code, w.IsActive, w.IsUserSelectable })
.FirstOrDefaultAsync(ct);
if (wf is null)
throw new NotFoundException("ApprovalWorkflow", approvalWorkflowId);
if (!wf.IsActive)
throw new ConflictException(
$"Quy trình '{wf.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động.");
if (!wf.IsUserSelectable)
throw new ConflictException(
$"Quy trình '{wf.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác.");
}
/// Precompute ID phiếu mà `userId` là approver của Cấp ĐANG CHỜ — mirror
/// `PurchaseEvaluationFeatures.cs:817-852 ResolveV2InboxIdsAsync`.
/// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list
@ -472,18 +500,20 @@ 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 (iv-bis) [S182 W1b2 — D6] Quy trình phải CÒN DÙNG ĐƯỢC lúc pin.
// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147` (cùng lỗ, đã bịt cho
// type-3): FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì pin được bản đã
// archived ⇒ phiếu trình xong treo ở quy trình không ai bảo trì.
await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(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.");
// [S182 W1b] Thân rào DỜI sang `ContractSigningPlanWorkflowGuards` — cùng luật này
// nay chặn ở CẢ 3 cửa (tạo / sửa nháp re-pin / trình duyệt), trước chỉ có cửa này.
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
db, request.ApprovalWorkflowId, approvalGroup, ct);
// 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
// (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
@ -600,9 +630,18 @@ public class UpdateContractSigningPlanDraftCommandHandler(
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
// Re-pin quy trình: guard type LẠI (workflow có thể bị admin đổi loại giữa chừng).
//
// [S182 W1b/W1b2] Cửa này trước KHÔNG có rào nhóm ⇒ tạo phiếu đúng nhóm rồi sửa nháp
// pin sang quy trình nhóm khác là lọt. Nay đủ 3 rào như đường TẠO.
// 🔴 Cả 3 nằm TRONG nhánh `awId != plan.ApprovalWorkflowId` (đường ĐỔI pin) — CỐ Ý:
// client gửi lại đúng workflow cũ (form "Sửa" echo nguyên field) không được biến
// thành 409, nếu không thì sửa GhiChu của phiếu pin bản archived sẽ hỏng (D6).
if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId)
{
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, awId, ct);
await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, awId, ct);
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
db, awId, plan.ApprovalGroup, ct);
plan.ApprovalWorkflowId = awId;
}

View File

@ -0,0 +1,78 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Application.ContractSigningPlans;
// [S182 W1b — YC-013] Rào DÙNG CHUNG cho quan hệ "nhóm duyệt của phiếu" ⟷ "nhóm duyệt của
// quy trình đã pin". Trước đợt này logic chỉ nằm ở MỘT chỗ (đường TẠO phiếu,
// `ContractSigningPlanFeatures.cs` rào (v) — K3 S165) ⇒ hai cửa còn lại (sửa nháp re-pin,
// và TRÌNH duyệt) đi lọt: phiếu nhóm 3 vẫn trình được bằng quy trình nhóm 5.
//
// 🔴 VÌ SAO `public static class` ở tầng Application chứ không nhét vào
// `ContractSigningPlanScope` (internal, cùng file Features):
// site thứ 3 là `ContractSigningPlanWorkflowService.SubmitAsync` nằm ở **Infrastructure**
// ⇒ không thấy `internal` của Application. Ba lối đã cân nhắc và loại:
// • nới `Scope` thành public → mở TOÀN BỘ helper nội bộ ra ngoài, chỉ để dùng 1 method;
// • `InternalsVisibleTo(Infrastructure)` → mở cả assembly, cùng bệnh, còn khó soi hơn;
// • copy-inline sang Service → 2 bản luật ĐI LỆCH NHAU theo thời gian (đúng bug-class
// đang chữa: một chỗ có rào, chỗ kia không).
// ⇒ tách file riêng, đúng một method public, ba site cùng gọi.
public static class ContractSigningPlanWorkflowGuards
{
// Họ mã quy trình nhóm duyệt: `KHKK-N1` … `KHKK-N8`, seed ở
// `DbInitializer.SeedKhkkGroupWorkflowsAsync` (`DbInitializer.cs:609` — `$"KHKK-N{n}"`).
//
// 🔴 ĐÂY LÀ NHÀ DUY NHẤT của mẫu này phía Application. Trước đợt này mẫu được gõ tay
// trong `ContractSigningPlanFeatures.cs:482`; W1d cần lại đúng mẫu đó ở
// `ApprovalWorkflowV2AdminFeatures` (rào xoá version cuối) ⇒ nếu chép lần nữa thì có
// 3 bản chuỗi rời nhau, sửa 1 quên 2. Ai cần "code này thuộc nhóm mấy" thì gọi
// `ParseGroup`, đừng viết lại regex.
//
// ⚠️ ĐỪNG nhầm với rào W1a bên `ApprovalWorkflowV2AdminFeatures` (deactivate-per-Code):
// ở ĐÓ regex bị CẤM (R-6) vì phạm vi tắt-active phải phủ MỌI code type-10, kể cả code
// ngoài họ. Ở ĐÂY regex mới là đúng việc: mục đích của rào là NHẬN DIỆN họ `KHKK-N`,
// và code ngoài họ được cho qua CÓ CHỦ ĐÍCH (xem `EnsureWorkflowGroupMatchAsync`).
// 🔴 [S182 MAJOR-2] `IgnoreCase` = BELT cho dữ-liệu CŨ. Đường ghi nay đã chuẩn hoá
// `Code` về HOA (`CreateAwDefinitionCommandHandler` — `request.Code.Trim().ToUpperInvariant()`),
// nhưng row có TRƯỚC bản vá thì chưa qua cửa đó. Thiếu cờ này, một `khkk-n1` tồn dư
// sẽ trả `null` ⇒ rào nhóm 3-site + D5 im lặng TẮT cho cả nhóm 1, trong khi SQL Server
// (collation `*_CI_AS`) vẫn coi nó trùng `KHKK-N1` ⇒ hai tầng nói ngược nhau.
// Suspenders ⟂ belt: chuẩn-hoá chặn dữ-liệu MỚI, cờ này tha-thứ dữ-liệu CŨ.
private static readonly Regex GroupCodePattern =
new(@"^KHKK-N([1-8])$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
/// Số nhóm duyệt suy ra từ mã quy trình, hoặc `null` nếu mã KHÔNG thuộc họ `KHKK-N[1-8]`.
public static int? ParseGroup(string? workflowCode)
{
var m = GroupCodePattern.Match(workflowCode ?? string.Empty);
return m.Success ? int.Parse(m.Groups[1].Value) : null;
}
/// Quy trình thuộc họ nhóm `KHKK-N{k}` ⇒ `k` PHẢI khớp nhóm duyệt của phiếu, nếu không → 409.
///
/// 🔴 GIỮ NGUYÊN semantics "cho qua code NGOÀI họ" của bản gốc
/// (`ContractSigningPlanFeatures.cs:477-478` — chủ đích, không phải sót): ràng buộc này
/// chỉ nói về 8 workflow nhóm seed @K3. Quy trình type-10 đặt tay (vd `QT-KHKK-V2-001`)
/// không mang thông tin nhóm trong mã ⇒ không có gì để đối chiếu, chặn nó là bịa ràng
/// buộc. Rào loại (`EnsureWorkflowTypeAsync`) mới là chỗ chặn quy trình sai LOẠI.
///
/// Mã không tồn tại (`awId` rác) cũng rơi vào nhánh cho-qua: guard NotFound là việc của
/// `EnsureWorkflowTypeAsync` (Create/UpdateDraft) — chồng thêm ở đây sẽ đẻ 2 nguồn 404
/// cho cùng một nguyên nhân.
public static async Task EnsureWorkflowGroupMatchAsync(
IApplicationDbContext db, Guid awId, int approvalGroup, CancellationToken ct)
{
var code = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == awId)
.Select(w => w.Code)
.FirstOrDefaultAsync(ct);
var wfGroup = ParseGroup(code);
if (wfGroup is int k && k != approvalGroup)
throw new ConflictException(
$"Phiếu nhóm N{approvalGroup} đ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.");
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans; // ContractSigningPlanWorkflowGuards (rào nhóm dùng chung 3 site)
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.ApprovalWorkflowsV2;
@ -186,6 +187,15 @@ public class ContractSigningPlanWorkflowService(
throw new ConflictException(
$"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}.");
// ── [S182 W1b] Rào NHÓM ⟷ QUY TRÌNH, site 3/3 (2 site kia ở đường tạo/sửa nháp).
// Cửa này là chốt CUỐI và là cửa DUY NHẤT bắt được phiếu pin lệch có TỪ TRƯỚC: hai
// rào kia chỉ chặn lúc GHI, nên phiếu tạo trước khi có rào (hoặc trước khi admin đổi
// mã quy trình) vẫn nằm sẵn trong DB với nhóm lệch. Đứng ngay trên `LoadStepsAsync`
// để phiếu lệch không kịp bước vào ChoDuyet rồi mới lộ.
// Thân luật + lý do "cho qua code ngoài họ": `ContractSigningPlanWorkflowGuards`.
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
db, awId, plan.ApprovalGroup, ct);
var steps = await LoadStepsAsync(awId, ct);
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
if (firstLevelGroup.Count == 0)

View File

@ -131,7 +131,13 @@ public class ContractSigningPlanApprovalTests
string code,
Guid[][][] steps,
int stepOrderStart = 1,
int stepOrderStride = 1)
int stepOrderStride = 1,
// [S182 W1 T7] 3 tham số đuôi cho rào D6 (pin-check IsActive ∧ IsUserSelectable) và
// cho việc dựng 2 version CÙNG Code (UNIQUE là (Code, Version)). Optional trailing ⇒
// mọi call-site cũ giữ nguyên chữ.
int version = 1,
bool isActive = true,
bool isUserSelectable = true)
{
await using var db = f.NewDb(TestCurrentUser.System());
@ -139,13 +145,13 @@ public class ContractSigningPlanApprovalTests
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Version = version,
Name = "Quy trình duyệt Kế hoạch ký kết",
// Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
// của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
IsActive = true,
IsUserSelectable = true,
IsActive = isActive,
IsUserSelectable = isUserSelectable,
};
db.ApprovalWorkflows.Add(wf);
@ -193,21 +199,29 @@ public class ContractSigningPlanApprovalTests
ContractSigningPlanPhase phase,
int? stepIndex,
int? levelOrder,
LineSeed[] lines)
LineSeed[] lines,
// [S182 W1 T4-T7] Nhóm duyệt của phiếu. Hạng mục fixture ĐI THEO nhóm này (cùng giá trị)
// ⇒ rào hạng mục `…WorkflowService.cs:182-187` luôn cho qua, và mọi 409 quan sát được
// trong T5 chỉ có thể đến từ rào NHÓM-QUY-TRÌNH. Trailing optional ⇒ 12 call-site cũ giữ nguyên.
int approvalGroup = 1)
{
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
await using var db = f.NewDb(drafter);
// [K2 S164 — vá-5] SubmitAsync nay CHẶN dòng chưa gán hạng mục + hạng mục khác nhóm.
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu (entity default ApprovalGroup=1)
// — mirror phiếu "đã gán đủ" trước trình; Code random né unique-filtered giữa các plan.
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu — mirror phiếu "đã gán đủ"
// trước trình; Code random né unique-filtered giữa các plan.
// 🔴 [S182 W1] Hạng mục ĐI THEO `approvalGroup` của phiếu (không còn hằng 1): T5 seed
// phiếu nhóm 3, nếu hạng mục vẫn nhóm 1 thì `SubmitAsync` ném 409 của rào HẠNG MỤC
// (`ContractSigningPlanWorkflowService.cs:182-187`) và test sẽ "xanh vì nhầm cửa" —
// đo đúng chữ 409 nhưng sai hoàn toàn cơ chế.
var catalog = new ContractCatalogEntry
{
Id = Guid.NewGuid(),
Code = "T-" + Guid.NewGuid().ToString("N")[..8],
TenVi = "Hạng mục test W3",
GroupCode = "B1",
ApprovalGroup = 1,
ApprovalGroup = approvalGroup,
SignerRole = "TP.PRO",
IsActive = true,
SortOrder = 1,
@ -225,6 +239,7 @@ public class ContractSigningPlanApprovalTests
DepartmentId = Guid.NewGuid(),
DrafterUserId = drafter.UserId!.Value,
Phase = phase,
ApprovalGroup = approvalGroup,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = stepIndex,
CurrentApprovalLevelOrder = levelOrder,
@ -1138,4 +1153,299 @@ public class ContractSigningPlanApprovalTests
.Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
.ToListAsync(CancellationToken.None);
}
// ###################################################################
// [S182 W1 — YC-013, 2026-08-08] T4-T7: rào NHÓM-QUY-TRÌNH 3-site + pin-check D6.
// SPEC: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
// spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1 fix (b) + (b2).
//
// 🔴 VẤN ĐỀ: rào "phiếu nhóm N{g} chỉ pin được quy trình `KHKK-N{g}`" hôm nay chỉ đứng ở
// ĐÚNG MỘT CỬA — `CreateContractSigningPlanCommandHandler` (`Features.cs:479-486`).
// Hai cửa còn lại đi thẳng: UpdateDraft `:603-607` chỉ gọi `EnsureWorkflowTypeAsync`,
// và `SubmitAsync` (`ContractSigningPlanWorkflowService.cs:176-189`) không hỏi câu này
// bao giờ. Hệ quả không phải "409 muộn" mà là KÝ SAI DÂY CHUYỀN: phiếu nhóm 3 chạy trạm
// của nhóm 5 ⇒ hồ sơ tới tay người không có thẩm quyền ký, im lặng, đúng luồng UI.
// W1b gom logic vào `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync`
// và cắm đủ 3 cửa.
//
// ⛔ KHÔNG nhân bản cửa Create ở đây. Cửa đó ĐÃ có lưới đủ răng từ K3/S164 —
// `ContractSigningPlanGroupAndCatalogTests.Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa`
// (chặn sai-nhóm + cho-qua-ngoài-họ + không-đốt-mã), đang XANH ở baseline. Nó chính là
// chứng-nhân "phép dời logic sang class dùng chung KHÔNG đổi ngữ nghĩa cửa cũ"; chép lại
// 60 dòng seed PE vào file này chỉ đẻ bản sao đo cùng một thứ. T4-T7 chỉ viết phần nó
// KHÔNG đo: 2 cửa mới + rào D6.
//
// 🔎 Chữ trong thông điệp 409: spec đưa RA HAI bản (giữ nguyên câu Create `:483-486`, hoặc
// câu mới W1b "Phiếu nhóm N{g} đang gắn quy trình N{k}…"). Cả hai đều hợp lệ ⇒ test pin
// cái BẤT BIẾN giữa hai bản: thông điệp phải nêu CẢ nhóm-của-phiếu LẪN nhóm-của-quy-trình,
// vì thiếu một trong hai thì người dùng đọc xong vẫn không biết phải đổi cái gì.
// (Nếu BE chọn câu mới nguyên văn ⇒ nó BỎ mã quy trình và làm ĐỎ assert `Contain("KHKK-N5")`
// của test K3 nói trên — đã báo lead ở §8 artifact, KHÔNG tự nới test cũ.)
// ###################################################################
private static async Task UpdateDraftAsync(
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId,
Guid? awId = null, string? ghiChu = null)
{
await using var db = f.NewDb(actor);
var handler = new UpdateContractSigningPlanDraftCommandHandler(db, actor);
await handler.Handle(
new UpdateContractSigningPlanDraftCommand(planId, GhiChu: ghiChu, HoSoLink: null,
ApprovalWorkflowId: awId),
CancellationToken.None);
}
/// Đọc pin TỪ ĐĨA (context mới). Mọi vế "409 rồi thì không đổi gì" phải đo ở đây chứ không
/// đo trên entity trong bộ nhớ — handler gán `plan.ApprovalWorkflowId` TRƯỚC khi
/// `SaveChangesAsync` chạy, nên bản in-memory nói dối được.
private static async Task<Guid?> ReadPinAsync(KhkkWorkflowFixture f, Guid planId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlans.AsNoTracking()
.Where(p => p.Id == planId)
.Select(p => p.ApprovalWorkflowId)
.SingleAsync(CancellationToken.None);
}
private static async Task<int> ChangelogCountAsync(KhkkWorkflowFixture f, Guid planId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlanChangelogs.AsNoTracking()
.CountAsync(c => c.ContractSigningPlanId == planId, CancellationToken.None);
}
private static async Task ArchiveWorkflowAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
var wf = await db.ApprovalWorkflows.SingleAsync(w => w.Id == workflowId, CancellationToken.None);
wf.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
}
// ===================================================================
// (T4) Cửa UpdateDraft — re-pin sang quy trình LỆCH NHÓM ⇒ 409.
//
// Đây là cửa NGUY HIỂM NHẤT trong ba: Create bị chặn thì người dùng thấy ngay và chọn lại;
// còn ở đây phiếu ĐÃ hợp lệ rồi mới bị đổi ruột — lần sau nhìn vào chỉ thấy một phiếu Nháp
// bình thường, không có dấu vết nào nói nó vừa bị chuyển dây chuyền ký.
//
// Ba vế, mỗi vế bịt một kiểu xanh-giả:
// (1) chặn + thông điệp nêu ĐỦ hai nhóm;
// (2) ĐĨA bất động: pin cũ còn nguyên VÀ 0 dòng changelog — handler gán pin rồi mới
// `SaveChanges` ở cuối, nên chỉ assert "có ném" là không phân biệt được "chặn kịp"
// với "chặn sau khi đã ghi";
// (3) CONTROL DƯƠNG cùng lượt: re-pin sang bản v02 CÙNG NHÓM ⇒ qua, và changelog nhảy
// 0 → 1. Thiếu vế này thì một rào chặn-tuốt (hoặc lỗi seed làm mọi UpdateDraft ném)
// vẫn cho (1)+(2) xanh.
// ===================================================================
[Fact]
public async Task T4_UpdateDraft_RePinQuyTrinhLechNhom_ThiBiChan_VaGiuNguyenPinCu()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var n3v1 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
var n3v2 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]], version: 2);
var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, n3v1, "KHKK/2026/301",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 3);
// ---- (1) Re-pin sang quy trình của nhóm KHÁC.
var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n5);
var ex = await act.Should().ThrowAsync<ConflictException>(
"sửa nháp là cửa thứ hai vào cùng một chỗ — rào chỉ đứng ở Create thì nó vô hiệu "
+ "bằng đúng một request PUT hợp lệ về mọi mặt khác");
ex.Which.Message.Should().Contain("N5").And.Contain("N3",
"thông điệp phải nêu CẢ nhóm của quy trình lẫn nhóm của phiếu — thiếu một vế thì "
+ "người dùng biết mình sai nhưng không biết sai ở đâu");
// ---- (2) Đĩa bất động.
(await ReadPinAsync(f, planId)).Should().Be(n3v1,
"409 mà pin đã đổi = rào ném SAU khi ghi; phiếu vẫn chạy sai dây chuyền, chỉ thêm một lỗi đỏ");
(await ChangelogCountAsync(f, planId)).Should().Be(0,
"lệnh bị chặn không được để lại dòng 'Cập nhật thông tin kế hoạch ký kết' — vết ma "
+ "làm người đọc lịch sử tin rằng đã có thay đổi");
// ---- (3) CONTROL DƯƠNG: đổi đúng MỘT biến (nhóm của quy trình đích) ⇒ kết quả trái ngược.
await UpdateDraftAsync(f, drafter, planId, awId: n3v2, ghiChu: "Đổi sang bản v02 cùng nhóm");
(await ReadPinAsync(f, planId)).Should().Be(n3v2,
"cùng cửa, cùng người, cùng phiếu — chỉ khác nhóm của quy trình đích ⇒ rào SO KHỚP "
+ "chứ không phải chặn mọi lệnh re-pin");
(await ChangelogCountAsync(f, planId)).Should().Be(1,
"và phép đếm changelog ở (2) là phép đo SỐNG: nó biết tăng khi lệnh thật sự chạy");
}
// ===================================================================
// (T5) Cửa Submit — phiếu pin lệch nhóm TỪ TRƯỚC (data cũ / lách API) ⇒ 409 lúc trình.
//
// Vì sao cửa này bắt buộc phải có dù đã chặn ở Create + UpdateDraft: prod HÔM NAY có thể
// đang mang phiếu pin lệch (spec W0 §0.4 chạy hẳn một query audit để đếm trước khi vá).
// Rào ở hai cửa GHI không dọn được thứ đã nằm sẵn trong bảng — chỉ cửa TRÌNH mới chặn được
// phiếu cũ trước khi nó bước vào dây chuyền ký sai.
// Seed thẳng DbContext là CỐ Ý: đó là cách duy nhất dựng lại trạng thái "đã lệch từ trước"
// sau khi rào Create đóng.
//
// 🔴 Điều kiện để phép đo có nghĩa: `SubmitAsync` có SẴN 3 cửa 409 khác (phase, hạng mục
// chưa gán, hạng mục sai nhóm) ⇒ bắt được `ConflictException` là chưa nói lên gì.
// Fixture đã kéo hạng mục theo `approvalGroup` của phiếu, nên hai rào hạng mục im;
// và phiếu CHỨNG NHÂN pin đúng nhóm trình LỌT trong cùng một lượt chứng minh phần còn
// lại của đường trình vẫn thông.
// ===================================================================
[Fact]
public async Task T5_Submit_PhieuPinLechNhomTuTruoc_ThiBiChan_ChuKhongTrinhQua()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var n3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
// Phiếu nhóm 3 nhưng pin quy trình nhóm 5 — dựng thẳng, mô phỏng phiếu có từ trước rào.
var lech = await SeedPlanAsync(f, drafter, n5, "KHKK/2026/310",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 3);
// Chứng nhân: y hệt, khác đúng một biến — pin ĐÚNG nhóm.
var dung = await SeedPlanAsync(f, drafter, n3, "KHKK/2026/311",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 3);
var act = async () => await ActAsync(f, drafter, lech, "submit");
var ex = await act.Should().ThrowAsync<ConflictException>(
"phiếu lệch nhóm phải bị chặn Ở CỬA TRÌNH — hai rào kia không với tới dữ liệu đã nằm sẵn trong bảng");
ex.Which.Message.Should().Contain("N5").And.Contain("N3",
"thông điệp phải phân biệt được với 3 câu 409 khác của SubmitAsync (phase / hạng mục "
+ "chưa gán / hạng mục sai nhóm), nếu không người dùng đi sửa nhầm chỗ");
var blocked = await ReadPlanAsync(f, lech);
ShouldBeAt(blocked, ContractSigningPlanPhase.DangSoanThao, null, null,
"bị chặn thì phiếu phải Ở NGUYÊN nháp — sang ChoDuyet nửa vời là phiếu treo trong "
+ "hộp thư của người không có thẩm quyền ký");
// CHỨNG NHÂN cùng lượt: đường trình vẫn thông cho phiếu pin đúng nhóm ⇒ 409 ở trên là
// của rào NHÓM, không phải một rào khác của SubmitAsync vô tình nổ.
await ActAsync(f, drafter, dung, "submit");
var ok = await ReadPlanAsync(f, dung);
ShouldBeAt(ok, ContractSigningPlanPhase.ChoDuyet, 0, 1,
"cùng fixture, cùng hạng mục, cùng quy trình 1-Bước-1-Cấp — chỉ khác nhóm của quy "
+ "trình được pin ⇒ hai kết quả trái ngược");
}
// ===================================================================
// (T6) Vế ÂM — mã NGOÀI họ `KHKK-N[1-8]` (quy trình chung/legacy type-10) vẫn đi lọt ở
// CẢ HAI cửa mới. Ngữ nghĩa này là CHỦ ĐÍCH của rào cũ (`Features.cs:477-478`) và spec W1b
// ra lệnh giữ nguyên khi dời sang class dùng chung — siết thêm ở đây là khoá cứng mọi phiếu
// đang pin quy trình chung, mà đường sửa duy nhất lại chính là cửa vừa bị khoá.
//
// 🔴 Vế âm một mình là XANH-MÙ: hôm nay rào chưa tồn tại nên "đi lọt" đúng ở cả thế giới
// chưa-vá. Vì vậy chân (2) — CÙNG cửa, CÙNG phiếu, chỉ đổi mã đích sang trong-họ ⇒ 409 —
// là thứ chứng minh rào ĐANG CHẠY trong chính lượt này; thiếu nó thì T6 không đo gì.
// ===================================================================
[Fact]
public async Task T6_MaNgoaiHoNhom_UpdateDraftVaSubmit_VanDiLot()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var chung01 = await SeedWorkflowAsync(f, "QT-KHKK-V2-001", steps: [[[approver.Id]]]);
var chung02 = await SeedWorkflowAsync(f, "QT-KHKK-V2-002", steps: [[[approver.Id]]]);
var n7 = await SeedWorkflowAsync(f, "KHKK-N7", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, chung01, "KHKK/2026/320",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 4);
// ---- (1) Cửa UpdateDraft: mã ngoài họ ⇒ KHÔNG bị hỏi nhóm.
await UpdateDraftAsync(f, drafter, planId, awId: chung02);
(await ReadPinAsync(f, planId)).Should().Be(chung02,
"quy trình chung không mang số nhóm trong mã ⇒ không có gì để so khớp; chặn ở đây là "
+ "siết oan và phiếu mất luôn đường tự sửa");
// ---- (2) CHỨNG NHÂN rào đang sống: cùng cửa, cùng phiếu, chỉ đổi mã đích sang TRONG họ.
var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n7);
await act.Should().ThrowAsync<ConflictException>(
"phiếu nhóm 4 pin `KHKK-N7` phải bị chặn — nếu vế này cũng đi lọt thì (1) và (3) chỉ "
+ "đang mô tả một thế giới CHƯA CÓ rào, không chứng minh ngữ nghĩa cho-qua nào cả");
(await ReadPinAsync(f, planId)).Should().Be(chung02, "409 thì pin giữ nguyên");
// ---- (3) Cửa Submit: vẫn mã ngoài họ ⇒ trình lọt.
await ActAsync(f, drafter, planId, "submit");
var after = await ReadPlanAsync(f, planId);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 1,
"rào mới cắm vào SubmitAsync không được nuốt phiếu đang chạy quy trình chung");
}
// ===================================================================
// (T7) D6 — re-pin sang bản ĐÃ NGƯNG HOẠT ĐỘNG / CHƯA GHIM ⇒ 409 (mirror vá-3
// `CreateContractFromSigningPlanFeatures.cs:141-147`, CÙNG một lỗ ở bridge type-3).
//
// Vì sao đáng chặn: sau W1a, "tạo phiên bản mới" hạ `IsActive` bản cũ. Phiếu pin bản đã
// nghỉ vẫn chạy được (service resolve theo Id, không đọc cờ) nên sai KHÔNG lộ ra — nó lộ ở
// chỗ khác: người duyệt của bản cũ đã bị thay trong bản mới.
//
// Bốn chân trên CÙNG một bộ seed (cả 4 bản đều mã `KHKK-N2` ⇒ rào nhóm im, mọi 409 quan
// sát được chỉ có thể đến từ D6):
// (1) IsActive=false ⇒ 409;
// (2) IsUserSelectable=false ⇒ 409, và THÔNG ĐIỆP PHẢI KHÁC (1) — spec đòi 2 câu tách,
// vì hai nguyên nhân này sửa ở hai nút khác nhau trong Designer; gộp thành một câu
// `!IsActive || !IsUserSelectable` là admin đi bấm nhầm nút;
// (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ re-pin qua;
// (4) 🔴 GIỮ-NGUYÊN-PIN KHÔNG BỊ HỎI: phiếu cũ đang pin bản đã nghỉ vẫn phải sửa được
// GhiChu. Đây là vế phân biệt "check trong nhánh đổi-pin" với "check mọi lần PUT" —
// bản vá đặt guard ngoài nhánh sẽ khoá cứng đúng những phiếu cần cứu nhất.
// ===================================================================
[Fact]
public async Task T7_RePinBanDaNgungHoacChuaGhim_ThiBiChan_ConGiuNguyenPinThiVanSuaDuoc()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var dangDung = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]]);
var daNghi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]],
version: 2, isActive: false);
var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]],
version: 3, isUserSelectable: false);
var banMoi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]], version: 4);
var planId = await SeedPlanAsync(f, drafter, dangDung, "KHKK/2026/330",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)],
approvalGroup: 2);
// ---- (1) Bản đã ngưng hoạt động.
var actNghi = async () => await UpdateDraftAsync(f, drafter, planId, awId: daNghi);
var exNghi = await actNghi.Should().ThrowAsync<ConflictException>(
"pin bản đã nghỉ = phiếu chạy dàn người duyệt của một phiên bản không còn hiệu lực");
(await ReadPinAsync(f, planId)).Should().Be(dangDung);
// ---- (2) Bản chưa ghim cho người dùng chọn.
var actGhim = async () => await UpdateDraftAsync(f, drafter, planId, awId: chuaGhim);
var exGhim = await actGhim.Should().ThrowAsync<ConflictException>(
"cờ ghim là cách Admin quyết quy trình nào người dùng được chọn — bỏ qua nó ở đường "
+ "PUT thì nút Ghim/Bỏ ghim chỉ còn là trang trí");
(await ReadPinAsync(f, planId)).Should().Be(dangDung);
exGhim.Which.Message.Should().NotBe(exNghi.Which.Message,
"hai nguyên nhân sửa ở hai nút khác nhau ⇒ phải hai câu khác nhau; một câu gộp "
+ "`!IsActive || !IsUserSelectable` là đẩy admin đi mò");
// ---- (3) CONTROL DƯƠNG: bản active + đã ghim.
await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Chuyển sang bản v04");
(await ReadPinAsync(f, planId)).Should().Be(banMoi,
"khác đúng một biến (2 cờ trạng thái của quy trình đích) ⇒ hai kết quả trái ngược");
// ---- (4) Bản đang pin bị cho nghỉ SAU đó ⇒ phiếu vẫn phải sửa được nội dung.
await ArchiveWorkflowAsync(f, banMoi);
await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Ghi chú sau khi quy trình nghỉ");
var plan = await ReadPlanAsync(f, planId);
plan.GhiChu.Should().Be("Ghi chú sau khi quy trình nghỉ",
"GIỮ NGUYÊN pin thì không có gì để kiểm — đặt guard ngoài nhánh `awId != pin hiện tại` "
+ "là khoá cứng mọi phiếu đang pin bản đã nghỉ, đúng nhóm phiếu cần sửa nhất");
plan.ApprovalWorkflowId.Should().Be(banMoi, "và pin không được tự đổi trong lượt sửa GhiChu");
}
}

View File

@ -142,18 +142,25 @@ public class ContractSigningPlanGroupAndCatalogTests
KhkkGroupFixture f,
string code,
Guid[][][] steps,
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan)
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan,
// [S182 vá MAJOR-3] 3 tham số ĐUÔI cho rào D6 (`EnsureWorkflowPinnableAsync`): cần
// dựng nhiều version CÙNG một Code (UNIQUE là (Code, Version) —
// `ApprovalWorkflowConfiguration.cs:23`) rồi lật TỪNG cờ một. Optional TRAILING ⇒ mọi
// call-site cũ giữ nguyên chữ. Cùng khuôn `ContractSigningPlanApprovalTests.cs:135-140`.
int version = 1,
bool isActive = true,
bool isUserSelectable = true)
{
await using var db = f.NewDb(TestCurrentUser.System());
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Version = version,
Name = "Quy trình duyệt Kế hoạch ký kết",
ApplicableType = type,
IsActive = true,
IsUserSelectable = true,
IsActive = isActive,
IsUserSelectable = isUserSelectable,
};
db.ApprovalWorkflows.Add(wf);
@ -855,9 +862,21 @@ public class ContractSigningPlanGroupAndCatalogTests
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ì");
// [S182 W1b — spec change, KHÔNG phải nới bar] Thân rào dời sang
// `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync` dùng chung cho 3 cửa
// (tạo / sửa nháp / trình), và spec chốt MỘT câu 409 duy nhất cho cả ba:
// "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."
// Câu mới bỏ MÃ quy trình, giữ SỐ NHÓM. Không mất thông tin: rào chỉ nổ cho mã TRONG họ
// `KHKK-N[1-8]` nên "N5" xác định duy nhất mã "KHKK-N5" (mã ngoài họ đi thẳng, vế (3)).
// ⇒ pin cái BẤT BIẾN qua cả hai bản chữ: thông điệp phải nêu ĐỦ HAI nhóm.
// [S182 MINOR-1] Pin NGUYÊN CỤM có thứ tự ở ĐÚNG 1 ca này (các ca khác giữ Contain 2 vế):
// `Contain(N5) ∧ Contain(N3)` vẫn xanh nếu ai đó ĐẢO 2 đối số ở call-site
// (`EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)` — 2 tham số kề nhau,
// 3 call-site). Cụm đầy đủ dưới đây chết ngay khi đảo ⇒ bất-biến thứ-tự có chứng nhân.
ex.Which.Message.Should().Contain("Phiếu nhóm N3 đang gắn quy trình N5",
"thông báo phải nêu ĐỦ và ĐÚNG CHIỀU: nhóm của phiếu (N3) trước, nhóm của quy trình "
+ "đang pin (N5) sau — đảo đối số ở bất kỳ call-site nào là câu này đổi chiều");
// 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");
@ -901,6 +920,90 @@ public class ContractSigningPlanGroupAndCatalogTests
.ToListAsync(CancellationToken.None);
}
// ===================================================================
// [S182 — vá MAJOR-3 reviewer pre-commit] Rào D6 Ở ĐƯỜNG TẠO —
// `ContractSigningPlanFeatures.cs:507` gọi `ContractSigningPlanScope
// .EnsureWorkflowPinnableAsync` (thân `:331-346`).
//
// Vì sao ca này phải tồn tại RIÊNG: D6 cắm ở HAI site (Create `:507` + UpdateDraft `:642`),
// nhưng ca duy nhất phủ D6 — T7 `ContractSigningPlanApprovalTests.cs:1402` — gọi
// `UpdateDraftAsync` ở CẢ BỐN chân. Tức là write-path mà MỌI phiếu KHKK đi qua đang có 0
// răng đo: ai dời/đảo/gỡ guard khỏi Create thì cả suite vẫn xanh.
//
// Ba chân trên CÙNG một bộ seed. Ba quy trình khác nhau ĐÚNG ở Version + MỘT cờ; Code đều
// là `KHKK-N1` và phiếu cũng nhóm 1 ⇒ rào (v) `:515` khớp nên IM LẶNG, mọi 409 quan sát
// được chỉ có thể đến từ D6:
// (1) IsActive=false ⇒ 409 "… đã ngừng hoạt động";
// (2) IsUserSelectable=false ⇒ 409 "… chưa được ghim", và THÔNG ĐIỆP PHẢI KHÁC (1) — đó
// đúng là lý do prod tách 2 câu (`:328-330`: hai nguyên nhân sửa ở hai nút khác nhau
// trong Designer). 🔴 Bản này BUỘC giữ IsActive=true, nếu không vế `!IsActive` nổ
// trước và chân (2) đang đo lại chân (1) — hai cờ cùng false = phép đo rỗng.
// (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ tạo được, VÀ mã ra `001`. Hai lần 409 ở trên
// đứng TRƯỚC `codeGen.GenerateAsync` (`:524`), mà codegen TỰ COMMIT trong transaction
// riêng (`ContractSigningPlanCodeGenerator.cs:23-43`) ⇒ đảo thứ tự hai bước này là số
// thứ tự bốc hơi vĩnh viễn (mã sẽ ra `003`) và dải mã KHKK thủng lỗ.
// ===================================================================
[Fact]
public async Task Create_PinQuyTrinhDaNgungHoacChuaGhim_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 dangDung = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var daNghi = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
version: 2, isActive: false);
// IsActive VẪN true — xem ghi chú chân (2) ở khối trên.
var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
version: 3, isUserSelectable: false);
// ---- (1) Pin bản ĐÃ NGƯNG HOẠT ĐỘNG.
var actNghi = async () => await CreateAsync(f, bch, pe.PeId, daNghi, approvalGroup: 1);
var exNghi = await actNghi.Should().ThrowAsync<ConflictException>(
"'tạo phiên bản mới' hạ IsActive bản cũ; pin được bản đã nghỉ thì phiếu chạy dàn "
+ "người duyệt của một phiên bản không còn hiệu lực — sai IM LẶNG, chỉ lộ khi hồ sơ "
+ "tới tay người đã bị thay trong bản mới");
exNghi.Which.Message.Should().Contain("ngừng hoạt động");
// ---- (2) Pin bản CHƯA GHIM cho người dùng chọn.
var actGhim = async () => await CreateAsync(f, bch, pe.PeId, chuaGhim, approvalGroup: 1);
var exGhim = await actGhim.Should().ThrowAsync<ConflictException>(
"FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì bỏ qua FE ⇒ cờ ghim phải "
+ "có răng ở BE, không thì nút Ghim/Bỏ ghim của Admin chỉ là trang trí");
exGhim.Which.Message.Should().Contain("ghim");
exGhim.Which.Message.Should().NotBe(exNghi.Which.Message,
"hai nguyên nhân sửa ở HAI NÚT khác nhau trong Designer ⇒ phải hai câu khác nhau; "
+ "gộp thành một câu `!IsActive || !IsUserSelectable` là đẩy admin đi mò");
// ---- Bị chặn thì KHÔNG để lại gì: không phiếu nửa vời, và mã CHƯA TỪNG được cấp.
(await ReadPlanScopesAsync(f)).Should().BeEmpty(
"D6 ném trước `db.ContractSigningPlans.Add` (`:577`) ⇒ không phiếu nào ra đời");
await using (var probe = f.NewDb(bch))
{
probe.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026").Should().BeFalse(
"hai lần 409 xảy ra TRƯỚC codegen ⇒ hàng sequence còn chưa được tạo lần nào");
}
// ---- (3) CONTROL DƯƠNG: cùng bộ seed, chỉ đổi trạng thái 2 cờ của quy trình đích.
var ok = await CreateAsync(f, bch, pe.PeId, dangDung, approvalGroup: 1);
ok.MaKeHoach.Should().Be("KHKK/2026/001",
"ra 002/003 tức là hai lần bị chặn đã TIÊU số thứ tự cho phiếu chưa từng tồn tại — "
+ "codegen tự commit nên số đó mất vĩnh viễn, không rollback theo request");
ok.LineCount.Should().Be(1, "PE seed có đúng 1 NCC trúng thầu ⇒ 1 dòng");
(await ReadPlanScopesAsync(f)).Should().BeEquivalentTo(new[]
{
new PlanScope("KHKK/2026/001", 1, dangDung),
}, "đúng MỘT phiếu ra đời và pin đúng bản đang dùng — nếu D6 chặn-tuốt thì chân này đỏ, "
+ "còn nếu D6 bị gỡ khỏi Create thì hai chân trên xanh giả; cần cả ba mới phân biệt được");
await using var db = f.NewDb(bch);
db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq.Should().Be(1,
"đúng một lần cấp mã cho đúng một phiếu");
}
// ===================================================================
// [S168 K8-blocker] Lines-editor `UpdateContractSigningPlanLineCommand` — mảnh comment
// auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn

View File

@ -4,9 +4,10 @@ using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.ApprovalWorkflowsV2;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans; // [S182 W1] KHKK plan + changelog (T1-T3, T8-T14)
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius)
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius) + LeaveRequest (T12)
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
@ -67,12 +68,15 @@ public class UpdateAwDefinitionTests
ApprovalWorkflowApplicableType type,
(int LevelOrder, Guid User)[][] stepSpecs,
decimal? ceoThreshold = null,
string code = "QT-UPD-V2")
string code = "QT-UPD-V2",
// [S182 W1] T14 cần 2 version CÙNG Code (UNIQUE là (Code, Version) —
// `ApprovalWorkflowConfiguration.cs:26`). Optional trailing ⇒ 14 call-site cũ không đổi.
int version = 1)
{
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
Version = version,
ApplicableType = type,
Name = "QT test sửa tại chỗ",
IsActive = true,
@ -969,4 +973,561 @@ public class UpdateAwDefinitionTests
"vết phải đọc được bằng TÊN, không phải GUID");
}
}
// ###################################################################
// [S182 W1 — YC-013, 2026-08-08] 8 panel "Duyệt KHKK — Nhóm 1..8" mirror trang Duyệt NCC.
// SPEC FINAL: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
// spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1.
//
// 10 lưới dưới đây (T1-T3 + T8-T14) đo 3 fix của W1 trên `ApprovalWorkflowV2AdminFeatures.cs`.
// Viết TRƯỚC khi BE land (test-before cho a/b, test-after cho c/d) ⇒ ca nào ĐỎ hôm nay là
// ĐỎ HỢP LỆ; hình dạng "đỏ vì chưa làm" đã ghi trong `sub-test-specialist-2.md §7`.
//
// 🔴 MỆNH ĐỀ TRUNG TÂM (R-6, spec `:14`): với `ApplicableType = ContractSigningPlan` (10),
// tập bị tắt khi tạo version mới là **THEO Code**, không theo Type:
// Where(w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive)
// Hôm nay `:340-343` lọc THEO TYPE ⇒ POST một nhóm = tắt IsActive của CẢ 8 nhóm prod.
// T1/T3 là hai nửa của cùng một mệnh đề; T2 giữ cho bản vá KHÔNG nới sang type khác.
// ⚠️ CẤM biến-thể regex `^KHKK-N[1-8]$` (R-6): mã type-10 ngoài họ vẫn phải per-Code —
// vế đó chính là chân thứ hai của T3.
// ###################################################################
private const ApprovalWorkflowApplicableType Khkk = ApprovalWorkflowApplicableType.ContractSigningPlan;
/// 8 quy trình nhóm `KHKK-N1..N8` (type-10, v01, IsActive) — hình dạng prod SAU seeder K3.
/// Một người duyệt dùng chung cho cả 8: rào trùng chỉ áp trong CÙNG một Cấp của CÙNG một Bước.
private static async Task<Dictionary<string, Guid>> SeedEightKhkkGroupsAsync(
TestApplicationDbContext db, Guid approverId)
{
var map = new Dictionary<string, Guid>();
for (var n = 1; n <= 8; n++)
{
var wf = await SeedWorkflowAsync(db, Khkk, [[(1, approverId)]], code: $"KHKK-N{n}");
map[$"KHKK-N{n}"] = wf.Id;
}
return map;
}
/// Lệnh POST "Tạo phiên bản mới" tối thiểu-nhưng-hợp-lệ (1 Bước × 1 Cấp).
private static CreateAwDefinitionCommand NewVersionCommand(
ApprovalWorkflowApplicableType type, string code, Guid approverId)
=> new(
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", approverId)])]);
private sealed record WfRow(string Code, int Version, bool IsActive);
private static Task<List<WfRow>> ReadWorkflowsAsync(
TestApplicationDbContext db, ApprovalWorkflowApplicableType type)
=> db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.ApplicableType == type)
.Select(w => new WfRow(w.Code, w.Version, w.IsActive))
.ToListAsync(CancellationToken.None);
/// 🔴 ĐƠN VỊ CỦA SỰ CỐ THẬT: "bao nhiêu NHÓM còn cửa tạo phiếu". FE auto-pin quy trình cho
/// phiếu mới bằng `find(w => w.isActive && code-match)` (spec W2g) ⇒ nhóm mất bản active là
/// nhóm KHÔNG lập được phiếu nữa. Đếm bằng đơn vị này thì thông điệp lỗi nói đúng thứ vỡ.
private static async Task<int> KhkkGroupsWithActiveVersionAsync(TestApplicationDbContext db)
{
var rows = await ReadWorkflowsAsync(db, Khkk);
return rows.Where(r => r.IsActive)
.Select(r => r.Code)
.Where(c => Enumerable.Range(1, 8).Any(n => c == $"KHKK-N{n}"))
.Distinct().Count();
}
private static async Task<ContractSigningPlan> SeedKhkkPlanAsync(
TestApplicationDbContext db,
Guid workflowId,
Guid drafterId,
string maKeHoach,
ContractSigningPlanPhase phase = ContractSigningPlanPhase.ChoDuyet,
int approvalGroup = 1,
bool isDeleted = false)
{
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
MaKeHoach = maKeHoach,
// 4 cột loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`).
PurchaseEvaluationId = Guid.NewGuid(),
ProjectId = Guid.NewGuid(),
DepartmentId = Guid.NewGuid(),
DrafterUserId = drafterId,
Phase = phase,
ApprovalGroup = approvalGroup,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = phase == ContractSigningPlanPhase.ChoDuyet ? 0 : null,
CurrentApprovalLevelOrder = phase == ContractSigningPlanPhase.ChoDuyet ? 1 : null,
IsDeleted = isDeleted,
DeletedAt = isDeleted ? new DateTime(2026, 8, 7, 0, 0, 0, DateTimeKind.Utc) : null,
};
db.ContractSigningPlans.Add(plan);
await db.SaveChangesAsync(CancellationToken.None);
return plan;
}
/// Đơn nghỉ phép — module Văn phòng số pin **LOOSE, KHÔNG FK** (`WorkflowAppsConfiguration.cs:12-26`
/// không khai `HasOne<ApprovalWorkflow>`; `LeaveTypeId` cũng chỉ là Guid trần).
private static async Task<LeaveRequest> SeedLeaveRequestAsync(
TestApplicationDbContext db, Guid workflowId, Guid requesterId, string maDonTu)
{
var leave = new LeaveRequest
{
Id = Guid.NewGuid(),
MaDonTu = maDonTu,
RequesterUserId = requesterId,
RequesterFullName = "Người xin nghỉ",
LeaveTypeId = Guid.NewGuid(),
StartDate = new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc),
EndDate = new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc),
NumDays = 2m,
Reason = "Việc gia đình",
Status = WorkflowAppStatus.DaGuiDuyet,
ApprovalWorkflowId = workflowId,
CurrentApprovalLevelOrder = 1,
};
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
return leave;
}
private static Task DeleteWorkflowAsync(Harness h, Guid id)
=> new DeleteAwDefinitionCommandHandler(h.Db).Handle(
new DeleteAwDefinitionCommand(id), CancellationToken.None);
private static Task<int> WorkflowRowCountAsync(Harness h, Guid id)
=> h.Db.ApprovalWorkflows.AsNoTracking().CountAsync(w => w.Id == id, CancellationToken.None);
// ===================================================================
// (T1) 🔴 BOM ĐANG ARMED — tạo v02 cho MỘT nhóm KHÔNG được tắt 7 nhóm còn lại.
//
// Đo bằng đơn vị của sự cố: số NHÓM còn bản active. Hôm nay `:340-343` tắt theo TYPE ⇒
// sau một cú bấm "Tạo phiên bản mới" trên panel N1, con số đó tụt 8 → 1 và 7 folder KHKK
// mất cửa lập phiếu — im lặng, không lỗi, không có endpoint re-activate (spec R-2).
// ===================================================================
[Fact]
public async Task T1_CreateNewVersionForOneKhkkGroup_DeactivatesOnlyThatCode_OtherSevenGroupsStayActive()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedEightKhkkGroupsAsync(h.Db, alice.Id);
// Tiền đề đo: 8 nhóm, TẤT CẢ đang có bản active. Thiếu chốt này thì một seed hỏng
// (vd 1 nhóm đã tắt sẵn) vẫn cho assert phía dưới xanh ở thế giới sai.
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, "tiền đề: seeder K3 để cả 8 nhóm active");
await new CreateAwDefinitionCommandHandler(h.Db).Handle(
NewVersionCommand(Khkk, "KHKK-N1", alice.Id), CancellationToken.None);
var rows = await ReadWorkflowsAsync(h.Db, Khkk);
rows.Should().HaveCount(9, "POST đẻ ĐÚNG 1 dòng mới (v02 của N1) — không xoá/không thêm dòng nào khác");
rows.Single(r => r is { Code: "KHKK-N1", Version: 1 }).IsActive.Should().BeFalse(
"cùng Code thì bản cũ PHẢI nhường chỗ — đây là vế 'ratchet một chiều' của chính nhóm N1");
rows.Single(r => r is { Code: "KHKK-N1", Version: 2 }).IsActive.Should().BeTrue(
"version vừa tạo là bản đang dùng của nhóm N1");
rows.Where(r => r.Code != "KHKK-N1").Should().OnlyContain(r => r.IsActive,
"🔴 R-6: tập bị tắt lọc theo CODE, không theo TYPE. Lọc theo type thì một cú bấm trên "
+ "panel N1 tắt luôn N2..N8 — 7 nhóm mất bản active, và KHÔNG có endpoint bật lại");
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8,
"số NHÓM còn cửa lập phiếu phải BẤT BIẾN qua thao tác của một nhóm khác; tụt xuống 1 "
+ "là đúng hình vụ nổ mà spec R-1 gọi là 'bom đã armed'");
}
}
// ===================================================================
// (T2) HỒI QUY cho các loại KHÁC: bản vá W1a chỉ được đổi hành vi của type-10.
// Type-1 (Duyệt NCC) tạo quy trình mã MỚI vẫn phải tắt mã CŨ cùng type (1-active-per-type).
// Kèm chứng nhân xuyên-type: 8 nhóm KHKK không được suy suyển vì một lệnh của type-1.
// ===================================================================
[Fact]
public async Task T2_CreateNewCodeForType1_StillDeactivatesOldCodeOfSameType_AndLeavesKhkkGroupsAlone()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], code: "QT-DN-V2-001");
await SeedEightKhkkGroupsAsync(h.Db, alice.Id);
// Mã KHÁC, cùng type-1 — đúng ca mà per-Code (nếu áp cho mọi type) sẽ để LỌT 2 bản active.
await new CreateAwDefinitionCommandHandler(h.Db).Handle(
NewVersionCommand(ApprovalWorkflowApplicableType.DuyetNcc, "QT-DN-V2-002", alice.Id),
CancellationToken.None);
var type1 = await ReadWorkflowsAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc);
type1.Single(r => r.Code == "QT-DN-V2-001").IsActive.Should().BeFalse(
"type-1 GIỮ ngữ nghĩa 1-active-per-type: mã cũ phải tắt khi có quy trình mới của cùng loại");
type1.Single(r => r.Code == "QT-DN-V2-002").IsActive.Should().BeTrue();
type1.Count(r => r.IsActive).Should().Be(1,
"vá W1a mà nới per-Code cho MỌI type thì trang Duyệt NCC có 2 quy trình cùng active — "
+ "phiếu mới không biết pin cái nào");
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8,
"chứng nhân xuyên-type: lệnh của type-1 không được chạm 8 nhóm type-10");
}
}
// ===================================================================
// (T3) F4 — mã type-10 NGOÀI họ `KHKK-N[1-8]`. Hai chân, mỗi chân giết một bản vá sai:
// (a) tạo `QT-KHKK-V2-001` ⇒ 8 nhóm BẤT BIẾN → giết bản vá "giữ per-type cho code lạ"
// (đúng cái mà công thức regex FE-invest sẽ làm — R-6 CẤM);
// (b) tạo LẠI CHÍNH mã đó ⇒ v01 phải tắt → giết bản vá lười "type-10 thì đừng tắt gì cả"
// (chân (a) một mình sẽ XANH với bản vá đó).
// ===================================================================
[Fact]
public async Task T3_CreateType10_WithOutOfFamilyCode_LeavesEightGroupsActive_AndStillRatchetsItsOwnCode()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
await SeedEightKhkkGroupsAsync(h.Db, alice.Id);
var handler = new CreateAwDefinitionCommandHandler(h.Db);
// ---- (a) mã type-10 chưa từng có ⇒ KHÔNG có bản cũ nào để tắt.
await handler.Handle(NewVersionCommand(Khkk, "QT-KHKK-V2-001", alice.Id), CancellationToken.None);
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8,
"🔴 F4: quy trình type-10 mã ngoài họ (legacy/chung) tạo mới KHÔNG được đụng 8 nhóm. "
+ "Công thức regex `^KHKK-N[1-8]$` cho mã này rơi về nhánh per-type ⇒ NUKE cả 8 (R-6 cấm)");
var afterA = await ReadWorkflowsAsync(h.Db, Khkk);
afterA.Single(r => r.Code == "QT-KHKK-V2-001").IsActive.Should().BeTrue();
afterA.Count(r => r.IsActive).Should().Be(9, "8 nhóm + 1 quy trình chung, cùng sống song song");
// ---- (b) CHÍNH mã đó, lần thứ hai ⇒ ratchet per-Code vẫn phải chạy.
await handler.Handle(NewVersionCommand(Khkk, "QT-KHKK-V2-001", alice.Id), CancellationToken.None);
var afterB = await ReadWorkflowsAsync(h.Db, Khkk);
afterB.Single(r => r is { Code: "QT-KHKK-V2-001", Version: 1 }).IsActive.Should().BeFalse(
"per-Code áp cho MỌI mã type-10, kể cả ngoài họ — 'type-10 thì không tắt gì' là bản vá "
+ "làm 2 version cùng mã cùng active, FE auto-pin bốc ngẫu nhiên");
afterB.Single(r => r is { Code: "QT-KHKK-V2-001", Version: 2 }).IsActive.Should().BeTrue();
(await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, "8 nhóm vẫn bất biến sau lượt 2");
}
}
// ===================================================================
// (T8) Fix (c) — PUT sửa-tại-chỗ quy trình type-10 phải ghi VẾT vào phiếu KHKK đang treo.
// Dialog PUT đang HỨA nguyên văn "ghi vào lịch sử phiếu đang chạy" (`ApprovalWorkflowsV2Page.tsx:963-977`)
// trong khi cổng `:863-865` chỉ mở cho DuyetNcc/DuyetNccPhuongAn ⇒ lời hứa rỗng cho KHKK.
//
// Đo SHAPE chứ không chỉ đếm: một dòng vết thiếu `EntityId`/`PhaseAtChange` thì màn lịch sử
// phiếu hiện một dòng "ai đó sửa gì đó" — vô dụng đúng lúc cần nhất.
// ===================================================================
[Fact]
public async Task T8_UpdateType10Workflow_WithPendingKhkkPlan_WritesExactlyOneChangelogRowWithFullShape()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N3");
var plan = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/101",
ContractSigningPlanPhase.ChoDuyet, approvalGroup: 3);
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Anh Chương nghỉ phép — bổ sung người ký thay"), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
// Sanity chống rỗng-nghĩa: lệnh THẬT SỰ đổi cấu hình (⇒ `changes` khác rỗng).
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2, "không có thay đổi thì không có vết để đo");
// Đọc bằng IgnoreQueryFilters: đọc THIẾU trông y hệt "chưa ghi" (bài S155).
var logs = await h.Db.ContractSigningPlanChangelogs.IgnoreQueryFilters().AsNoTracking()
.ToListAsync(CancellationToken.None);
logs.Should().ContainSingle("đúng 1 phiếu đang treo ⇒ đúng 1 dòng vết");
var log = logs.Single();
log.ContractSigningPlanId.Should().Be(plan.Id);
log.EntityType.Should().Be(ContractSigningPlanEntityType.Workflow, "enum slot 4 đã có sẵn — không đẻ slot mới");
log.Action.Should().Be(ChangelogAction.Update);
log.EntityId.Should().Be(wf.Id, "trỏ về quy trình bị sửa — thiếu thì không lần ngược được");
log.PhaseAtChange.Should().Be(ContractSigningPlanPhase.ChoDuyet);
log.UserId.Should().Be(admin.Id);
log.UserName.Should().Be("Quản trị hệ thống", "người đọc lịch sử cần TÊN, không phải GUID");
log.Summary.Should().Contain("KHKK-N3").And.Contain("giữ nguyên phiên bản",
"phân biệt 'sửa tại chỗ' với 'tạo version mới' — hai việc khác hẳn nhau về hệ quả");
log.ContextNote.Should().Contain("Lý do: Anh Chương nghỉ phép");
log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình",
"nội dung đổi phải đọc được bằng tên người, mirror khối PE `:874-891`");
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync(CancellationToken.None))
.Should().Be(0, "quy trình type-10 KHÔNG được rải vết sang changelog của module PE");
}
}
// ===================================================================
// (T9) Vế ÂM của cùng cơ chế — và có CONTROL DƯƠNG TRONG CÙNG MỘT LƯỢT.
// Nếu chỉ seed phiếu đã kết thúc rồi assert "0 dòng" thì ca này XANH cả trong thế giới
// fix (c) chưa từng được viết. Vì vậy 4 phiếu cùng một quy trình:
// • ChoDuyet SỐNG → PHẢI có vết (chứng cơ chế đang chạy)
// • DaDuyet / TuChoi → KHÔNG (đã đóng, sửa quy trình không còn ảnh hưởng)
// • ChoDuyet XOÁ MỀM → KHÔNG (spec `:34`: query-filter tự loại — vết cho phiếu đã xoá là rác)
// ===================================================================
[Fact]
public async Task T9_UpdateType10Workflow_ChangelogGoesOnlyToLivePendingPlan_NotFinishedNorSoftDeleted()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N5");
var pending = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/110",
ContractSigningPlanPhase.ChoDuyet, approvalGroup: 5);
var approved = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/111",
ContractSigningPlanPhase.DaDuyet, approvalGroup: 5);
var rejected = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/112",
ContractSigningPlanPhase.TuChoi, approvalGroup: 5);
var softDeleted = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/113",
ContractSigningPlanPhase.ChoDuyet, approvalGroup: 5, isDeleted: true);
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người ký"), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var logs = await h.Db.ContractSigningPlanChangelogs.IgnoreQueryFilters().AsNoTracking()
.ToListAsync(CancellationToken.None);
logs.Select(l => l.ContractSigningPlanId).Should().BeEquivalentTo(new[] { pending.Id },
$"chỉ phiếu CHƯA kết thúc và CHƯA xoá mới chịu ảnh hưởng — DaDuyet {approved.MaKeHoach}, "
+ $"TuChoi {rejected.MaKeHoach} và phiếu xoá mềm {softDeleted.MaKeHoach} đều phải bị loại; "
+ "riêng dòng của phiếu ChoDuyet sống là CONTROL DƯƠNG chứng cơ chế đang chạy thật");
}
}
// ===================================================================
// (T10) Fix (d)(i) — XOÁ quy trình còn phiếu KHKK pin ⇒ 409 LỊCH SỰ, không phải 500 thô.
// Mã dùng ở đây là NGOÀI họ `KHKK-N{n}` để rào D5 (T14) không xen vào: 409 ở đây chỉ có thể
// do usage-check. Kèm 2 vế bắt buộc:
// • CONTROL DƯƠNG cùng lượt: quy trình 0-usage vẫn xoá được ⇒ guard không phải "chặn tuốt";
// • CHỨNG-NHÂN: FK Restrict là thật — không có guard thì người dùng nhận DbUpdateException (547).
// ===================================================================
[Fact]
public async Task T10_DeleteWorkflow_WithPinnedKhkkPlan_Blocked409_WhileUnusedOneStillDeletes()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var used = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
var free = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-002");
await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/120");
var act = async () => await DeleteWorkflowAsync(h, used.Id);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"còn phiếu pin thì phải chặn LỊCH SỰ — để rơi xuống FK là người dùng nhận 500 không đọc được")).Which;
ex.Message.Should().Contain("QT-KHKK-V2-001",
"phải gọi ĐÍCH DANH quy trình: admin bấm xoá từ một danh sách nhiều dòng, câu 409 "
+ "không nêu tên thì không biết dòng nào vừa bị chặn");
(await WorkflowRowCountAsync(h, used.Id)).Should().Be(1, "409 rồi thì quy trình phải còn nguyên");
// Thông điệp phải MANG SỐ ĐẾM THẬT (rào quét 8 module + 8 bảng chữ ký), không phải
// một câu hằng. Đo bằng VI-SAI để không pin chữ: thêm đúng 1 hồ sơ pin nữa rồi gọi
// LẠI CÙNG một lệnh — câu trả về phải KHÁC. Cách này miễn nhiễm với việc BE đổi
// "phiếu"/"hồ sơ"/"bản ghi" (rào phủ cả đơn nghỉ phép lẫn hợp đồng, "phiếu" là chữ
// của riêng KHKK), nhưng vẫn giết được bản vá trả câu cố định.
await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/121");
var ex2 = (await act.Should().ThrowAsync<ConflictException>()).Which;
ex2.Message.Should().NotBe(ex.Message,
"1 hồ sơ đang gắn và 2 hồ sơ đang gắn mà ra CÙNG một câu ⇒ con số trong câu là "
+ "chữ chết; admin không biết còn bao nhiêu thứ phải gỡ trước khi xoá được");
// CONTROL DƯƠNG cùng lượt — cùng loại, cùng hình dạng, khác đúng một biến: có/không phiếu pin.
await DeleteWorkflowAsync(h, free.Id);
(await WorkflowRowCountAsync(h, free.Id)).Should().Be(0,
"quy trình 0-usage vẫn phải xoá được — nếu ca này cũng 409 thì guard đang chặn tuốt "
+ "và ca trên không chứng minh gì");
// CHỨNG-NHÂN: đây là thứ người dùng nhận nếu KHÔNG có guard (FK Restrict
// `ContractSigningPlanConfiguration.cs:50-53`). Dọn tracker trước — bài F-14.
h.Db.ChangeTracker.Clear();
var stillTracked = await h.Db.ApprovalWorkflows.FirstAsync(w => w.Id == used.Id, CancellationToken.None);
h.Db.ApprovalWorkflows.Remove(stillTracked);
var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None);
await raw.Should().ThrowAsync<DbUpdateException>(
"guard 409 đang đỡ đúng quả nổ này — không có nó thì UI hiện 500 và admin đoán mò");
}
}
// ===================================================================
// (T11) F2 — phiếu KHKK ĐÃ XOÁ MỀM vẫn phải chặn xoá quy trình.
// Xoá mềm = row VẪN NẰM TRONG BẢNG ⇒ FK Restrict vẫn nổ; nhưng guard đọc qua global filter
// (`ContractSigningPlanConfiguration.cs:55`) sẽ ĐẾM ĐƯỢC 0 và cho đi tiếp — "vắng mặt đọc
// thành sạch". Vậy nên guard BẮT BUỘC `IgnoreQueryFilters()`.
// 🔴 Nếu ca này ĐỎ với `DbUpdateException` thay vì `ConflictException` thì đó ĐÚNG là lỗ
// F2 — BÁO guard thiếu, KHÔNG nới test.
// ===================================================================
[Fact]
public async Task T11_DeleteWorkflow_PinnedOnlyBySoftDeletedKhkkPlan_StillBlocked409_NotFkCrash()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]);
var used = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
var free = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-002");
await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/130", isDeleted: true);
// Tiền đề: phiếu KHÔNG còn nhìn thấy qua đường thường, nhưng VẪN nằm trong bảng.
(await h.Db.ContractSigningPlans.AsNoTracking()
.CountAsync(p => p.ApprovalWorkflowId == used.Id, CancellationToken.None))
.Should().Be(0, "global filter che phiếu xoá mềm — đây chính là cái bẫy");
(await h.Db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
.CountAsync(p => p.ApprovalWorkflowId == used.Id, CancellationToken.None))
.Should().Be(1, "row vẫn còn ⇒ FK Restrict vẫn sẽ nổ nếu guard bỏ qua nó");
var act = async () => await DeleteWorkflowAsync(h, used.Id);
await act.Should().ThrowAsync<ConflictException>(
"F2: phiếu xoá mềm là phiếu KHÔI PHỤC ĐƯỢC (màn 'Đã xoá') — guard phải đọc bằng "
+ "IgnoreQueryFilters. Nhận DbUpdateException ở đây = guard đang mù trước soft-delete");
(await WorkflowRowCountAsync(h, used.Id)).Should().Be(1);
await DeleteWorkflowAsync(h, free.Id);
(await WorkflowRowCountAsync(h, free.Id)).Should().Be(0,
"control dương: khác đúng một biến (có/không phiếu xoá-mềm pin) ⇒ hai kết quả trái ngược");
}
}
// ===================================================================
// (T12) F3 — phiếu Văn phòng số (nghỉ phép) pin quy trình ⇒ cũng phải 409.
// 🔴 Đây là ca DATA-INTEGRITY, không phải UX: 5 module Office pin **LOOSE, 0 FK** ⇒ hôm nay
// DELETE trả 200 và đơn nghỉ phép trỏ vào một quy trình KHÔNG CÒN TỒN TẠI (dangling, im lặng).
// Guard usage-check là RÀO DUY NHẤT — chứng minh bằng chính DB ở cuối test.
// ===================================================================
[Fact]
public async Task T12_DeleteWorkflow_PinnedByLeaveRequest_Blocked409_BecauseGuardIsTheOnlyFence()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var staff = await h.Fix.CreateUserAsync("nv@t.vn", "Nhân viên xin nghỉ", null, []);
var used = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.LeaveRequest,
[[(1, alice.Id)]], code: "QT-NP-V2-001");
var witness = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.LeaveRequest,
[[(1, alice.Id)]], code: "QT-NP-V2-002");
var leave = await SeedLeaveRequestAsync(h.Db, used.Id, staff.Id, "DT/LR/2026/001");
var leaveWitness = await SeedLeaveRequestAsync(h.Db, witness.Id, staff.Id, "DT/LR/2026/002");
var act = async () => await DeleteWorkflowAsync(h, used.Id);
await act.Should().ThrowAsync<ConflictException>(
"đơn nghỉ phép đang pin quy trình này — xoá là đơn treo trỏ vào hư không");
(await WorkflowRowCountAsync(h, used.Id)).Should().Be(1);
(await h.Db.LeaveRequests.AsNoTracking().FirstAsync(x => x.Id == leave.Id, CancellationToken.None))
.ApprovalWorkflowId.Should().Be(used.Id, "đơn vẫn pin nguyên — 409 không được đụng dữ liệu");
// 🔴 CHỨNG-NHÂN "rào duy nhất": xoá THẲNG quy trình thứ hai qua DbContext.
// Với PE/HĐ/KHKK thì câu này nổ FK; với module Office nó ĐI LỌT — và đơn nghỉ phép
// thành mồ côi. Đó là lý do guard này là DATA-INTEGRITY chứ không phải làm đẹp UX.
h.Db.ChangeTracker.Clear();
var tracked = await h.Db.ApprovalWorkflows.FirstAsync(w => w.Id == witness.Id, CancellationToken.None);
h.Db.ApprovalWorkflows.Remove(tracked);
await h.Db.SaveChangesAsync(CancellationToken.None); // KHÔNG ném — 0 FK bảo vệ
(await WorkflowRowCountAsync(h, witness.Id)).Should().Be(0);
var orphan = await h.Db.LeaveRequests.AsNoTracking()
.FirstAsync(x => x.Id == leaveWitness.Id, CancellationToken.None);
orphan.ApprovalWorkflowId.Should().Be(witness.Id,
"đơn vẫn giữ Id của quy trình đã biến mất = dangling pointer THẬT trong DB — "
+ "đúng thứ mà guard 409 ở trên tồn tại để chặn");
}
}
// ===================================================================
// (T13) Fix (d)(iii) — id không tồn tại phải là 404, không phải 500 thô.
// `KeyNotFoundException` là BCL, `GlobalExceptionMiddleware` KHÔNG map ⇒ rơi 500.
// Control dương cùng lượt: id THẬT (0 usage, ngoài họ) xoá trơn ⇒ 404 là do id, không phải
// do handler hỏng.
// ===================================================================
[Fact]
public async Task T13_DeleteWorkflow_UnknownId_ThrowsNotFound_NotRawBclKeyNotFound()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var real = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
var act = async () => await DeleteWorkflowAsync(h, Guid.NewGuid());
await act.Should().ThrowAsync<NotFoundException>(
"middleware chỉ map NotFoundException→404; KeyNotFoundException (BCL) rơi ra 500 thô");
await DeleteWorkflowAsync(h, real.Id);
(await WorkflowRowCountAsync(h, real.Id)).Should().Be(0,
"control dương: đường xoá vẫn chạy bình thường ⇒ ngoại lệ trên là VỀ ID, không phải handler chết");
}
}
// ===================================================================
// (T14) D5 — nhóm `KHKK-N{n}` phải luôn còn ≥1 version.
// Xoá bản CUỐI của một nhóm = đóng cửa lập phiếu nhóm đó, và đường mở lại duy nhất là
// seeder chạy khi boot (M-1) — tức người dùng không tự cứu được từ UI.
// Ba chân trên CÙNG một bộ seed, mỗi chân bịt một kiểu vá sai:
// (1) xoá v01 khi còn v02 ⇒ OK (vá "cấm xoá mọi bản của nhóm" sẽ ĐỎ ở đây)
// (2) xoá v02 khi nó là bản cuối ⇒ 409
// (3) xoá quy trình NGOÀI họ chỉ có 1 version ⇒ OK (vá "cấm xoá bản-duy-nhất" sẽ ĐỎ ở đây)
// ===================================================================
[Fact]
public async Task T14_DeleteLastVersionOfKhkkGroup_Blocked409_ButNonLastAndOutOfFamilyDeleteFine()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var n2v1 = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N2", version: 1);
var n2v2 = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N2", version: 2);
var legacy = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001");
// Tiền đề: KHÔNG phiếu nào pin ⇒ 409 (nếu có) chỉ có thể đến từ D5, không phải usage-check.
(await h.Db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
.CountAsync(CancellationToken.None)).Should().Be(0, "0 usage ⇒ tách bạch D5 với rào (i)");
// ---- (1) còn v02 đứng sau ⇒ xoá v01 hợp lệ.
await DeleteWorkflowAsync(h, n2v1.Id);
(await WorkflowRowCountAsync(h, n2v1.Id)).Should().Be(0,
"version cũ 0-usage KHÔNG phải bản cuối thì vẫn dọn được — rào D5 không được nuốt luôn ca này");
(await WorkflowRowCountAsync(h, n2v2.Id)).Should().Be(1);
// ---- (2) nay v02 là bản CUỐI của nhóm N2 ⇒ chặn.
var act = async () => await DeleteWorkflowAsync(h, n2v2.Id);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"xoá bản cuối = nhóm N2 mất quy trình, mà đường tái sinh duy nhất là seeder lúc boot")).Which;
ex.Message.Should().Contain("N2", "thông điệp phải chỉ đích danh nhóm đang bị đóng cửa");
(await WorkflowRowCountAsync(h, n2v2.Id)).Should().Be(1, "409 thì bản cuối phải còn nguyên");
// ---- (3) ngoài họ, chỉ 1 version ⇒ KHÔNG dính D5.
await DeleteWorkflowAsync(h, legacy.Id);
(await WorkflowRowCountAsync(h, legacy.Id)).Should().Be(0,
"D5 chỉ áp cho họ KHKK-N[1-8]; chặn cả 'bản duy nhất' của quy trình chung là siết oan");
}
}
}