wal: flush 20260731T1304

This commit is contained in:
pqhuy1987
2026-07-31 13:04:48 +07:00
parent e695f8aeaa
commit 0fd9558839
6 changed files with 377 additions and 15 deletions

View File

@ -31,9 +31,14 @@ next: 5/6 OG CHỐT (1 schema-GỘP+UI-1-1 · 2 đội-hình-cũ · 7 N-phiếu+
thêm răng #81-EXT site :342) · catalog authz mirror CatalogsController · MÂU THUẪN OG-3 THẬT (BE chưa
port finalize — KHKK_EARLY_FINALIZE_NOTE system/ApprovalWorkflowsV2Page:151) → spec §④ OG-3 ĐÃ SỬA
(K3 3 việc a/b/c). Roster ô ⚠️ "Trưởng" 3 ứng viên — verify LIVE trước seed.
wf: B2-r cụm-1 wf_2a967a21-79e ĐANG CHẠY (3 lane reviewer opus: c1-evi · c1-cov · c1-seq).
CHỜ ANH: câu (I) bản-15-trang quy-trình-chữ khác bản-6-trang? · (II) B2-14-dòng + "/"=hoặc · (III) ai bấm
"Đưa vào HĐ"+chọn form. Pattern sổ: fable-lane StructuredOutput-fail 3/4 (att-2 này SẠCH — n=4).
B2-r cụm-1 ✅ 3/3 lane (L1 2/9-claim-đúng GỠ-(i)-:342 [invest đọc-ngược-comment] · L2 FAIL 3H · L3 PWB
5H) → spec-cum1-chi-tiet-31-07-2026.md FINAL (nền 33KB + 12 VÁ thắng-xung-đột; blocker: key-VÀO-All +
sweep-ép-false-AllowApproverFinalize ≥4-site + KhkkCreatePage-×2-app + submit-guard-CatalogEntryId).
🆕 OG-9 (L2-M3): K3 bật duyệt-sớm KHKK = ĐẢO phán quyết "PHÁ VỠ" @S155 (5 nơi ghi) — cần anh xác nhận
TƯỜNG MINH (câu IV). Chặn K3 (a)-(e); K1/K2 KHÔNG chặn.
wf: B2 cụm-2 wf_15a42015-aea ĐANG CHẠY (1 lane fable đan-xen: K4 sidebar 49-row · K5 cây groups · K6 admin).
CHỜ ANH: (I) bản-15-trang · (II) B2-14-dòng+"/"=hoặc · (III) ai bấm Đưa-vào-HĐ · (IV=OG-9) đảo PHÁ-VỠ-S155.
Sổ fable-lane: StructuredOutput-fail 3/5 (2 att gần nhất SẠCH — đan-xen tương quan?).
verify:
grep -c 'END spec v2' .claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/spec-4gd-khkk-tong-quat-31-07-2026.md

View File

@ -12,7 +12,9 @@
| 1b | Review spec tổng quát — 3 lane `WF reviewer lens{1,2,3} s164` | /fable-clone review (tier:opus ×3) | ✅ 3/3 sạch 0-empty · 41 finding thô → synthesis 14 vá + 2 OG mới → **spec v2** |
| 2.c1 | Cụm-1 K1+K2+K3 chi tiết — `WF invest-fable b2c1-att2 s164` | /fable-real invest | ✅ att-2 33.048B 5/5 section 17 test (att-1 skeleton-472B chết; luật ĐAN-XEN cứu) — 3 QUYẾT + 1 mâu-thuẫn OG-3 (K3 phải port finalize) + roster 1 ô ⚠️ Trưởng |
| 2.c2/c3 | Cụm-2 K4+K5+K6 · cụm-3 K7+K8 chi tiết | /fable-real invest | ☐ |
| 2.x-r | Review spec chi tiết per cụm | /fable-clone review | ☐ |
| 2.c1-r | Review cụm-1 — 3 lane `WF reviewer c1-{evi,cov,seq} s164` | /fable-clone (opus ×3) | ✅ 3/3 sạch · L1 2-blocker+GỠ-(i)-:342 · L2 FAIL 3H (menu-ngoài-All chết · ép-false ≥3 site sót · KhkkCreatePage mâu thuẫn) · L3 PWB 5H → **spec-cum1-final 12 vá + OG-9** |
| 2.c2 | Cụm-2 K4+K5+K6 — `WF invest-fable b2c2 s164` wf_15a42015-aea | /fable-real invest | ⏳ |
| 2.c2-r/c3 | Review cụm-2 · invest+review cụm-3 | | ☐ |
| 3 | Chốt spec cuối | /fable-real review | ☐ |
| 4 | Fan-out build bám spec+checklist | Opus 5 MAX (hmw) | ☐ |
| 5 | Dry-run E2E toàn trình | theo spec wave cuối | ☐ |

View File

@ -0,0 +1,27 @@
# SPEC CỤM-1 FINAL — K1 + K2 + K3 (lead kết tinh sau B2-r, @S164)
## ① Đề bài
K1 master-data danh mục (86 row) · K2 phiếu mang hạng-mục + nhóm + freeze-răng · K3 8 workflow nhóm + port-finalize + designer. Nguồn NỀN = `sub-invest-fable-b2-cum1.md` (33KB, 17 test) — **12 VÁ dưới đây THẮNG khi xung đột** (từ B2-r 3 lane: `sub-reviewer-c1-l{1,2,3}.md` — 41 finding, hội tụ cross-lane).
## ② 12 vá (build ĐỌC CẢ nền LẪN vá)
1. 🔴 **[3-lane hội tụ] Menu key PHẢI VÀO `MenuKeys.All`** — nền khai `ContractCatalog` + 8 `AwV2_KhkkN*` "ngoài All" ⇒ **0 permission row ⇒ sidebar chết** (loop grant `DbInitializer.cs:2058` nguồn = All; khuôn THẬT: 5 leaf catalog hiện hành `MenuKeys.cs:168` + 3 leaf AwV2 `:182` đều TRONG All). Fix: cả 9 key **VÀO All** + KHAI drift canonical (Menu keys 55→64 · Policies 220→256 — khai 2 row STATUS cùng lúc, đừng Goodhart giữ-số-đứng-yên). Acceptance += "login Admin THẤY leaf trong sidebar ×2 app" + 1 test seed-grant.
2. 🔴 **[L1-F5] GỠ phương án (i) tại site `:342`** — nền đọc NGƯỢC comment (`WorkflowService.cs:36-43` mô tả NGUỒN COPY và khẳng định KHKK CỐ Ý không có site gán-qua-BIẾN; `:342` nằm trong `ReturnOrRejectAsync` chỉ nhận TraLai/TuChoi literal — DaDuyet **unreachable** qua đó). GIỮ phương án (ii): test khóa khẳng định điều đó. Cấm "sửa" :342.
3. 🔴 **[L2-H2] K3 thêm việc (d): sweep cụm ép-false** — `grep -rn "AllowApproverFinalize|AllowApproverSkipToFinal"` toàn src + fe-*: gỡ đủ **CREATE ép-false server-side** `ApprovalWorkflowV2AdminFeatures.cs:380-381` + **PUT Conflict** `:591-594`/`:605-610` + **FE `levelFlagsPayload`** `ApprovalWorkflowsV2Page.tsx:247-258` (`earlyFinalizeLocked :638`) — không chỉ note `:151-152`. Test mới: "tạo version qua CreateCommand type-10 GIỮ được cờ".
4. 🔴 **[L2-H3] `KhkkCreatePage` ×2 app PHẢI SỬA** (mâu thuẫn nền: command +`ApprovalGroup` required nhưng page ghi "KHÔNG-ĐỔI") — body POST thêm `approvalGroup` + picker nhóm. Acceptance += "tạo phiếu nhóm 2 **TỪ UI** ×2 app" (curl-only không đủ).
5. 🔴 **[L2-M6/L1-F6] Chốt nhất quán `CatalogEntryId`**: auto-gen line lúc tạo phiếu được mồi null NHƯNG **submit-guard CHẶN null** (mọi line phải gán hạng mục trước trình) + validator line-mới-qua-API bắt buộc có. DoD "line khác nhóm → 409" phải chạy trên phiếu CÓ line thật (chống PASS-bằng-tập-rỗng).
6. **[L2-M4] Kéo `phase-guard line-edit sau DaDuyet` từ K4b VỀ K2** — Σ `Features.cs:709-711` tính LIVE, soft-delete line làm số phiếu-đã-duyệt trôi; freeze chưa kín nếu guard nằm cụm-2.
7. **[L2-M5/L1-F13] Mig 71 BỔ SUNG item 6 = cột `EndedByLevelFinalize`** (K2.a hiện chỉ 1-5) + tên mig phản ánh (vd `AddKhkkApprovalGroupCatalogAndFinalizeRuntime`) + khai ghost-window K2→K3 (cột nằm im tới khi K3 land).
8. **[L1-F8] Bảng consumer += `Features.cs:712-713` Σ ProposedAmount** (nền sót — list có 2 Σ) + quyết: list thêm Σ Approved sau freeze để list/detail không lệch số.
9. **[L1-F7] Chứng NULL-unique vá lại**: unique cũ bị DROP trong chính Mig 71 — mắt xích thật chặn 2-line-NULL-cùng-(Plan,Supplier) phải nêu ĐÚNG (submit-guard vá-5 + backfill line cũ gán từ header-group; đừng dựa index đã chết).
10. **[L1-F9/L2-M2] Sửa nhãn IgnoreQueryFilters**: số đúng = "0 hit trong `DbInitializer.cs`" (toàn src/Backend = 29 hit/5 file — claim "duy nhất"/"0-hit" là sai lây từ comment code); đơn thuốc seed `:2970` thiếu IgnoreQueryFilters vẫn ĐÚNG.
11. **[L1-e] Roster: acceptance đo DANH TÍNH** — K3.e += mục "mở workflow nhóm N, danh sách người duyệt KHỚP bảng đội hình từng người" + xử 2 cặp gần-trùng (`truong.le` vs `tring.le` vs `truong.nguyen` · `thanh.lethanh` "Lê Thanh Bình" vs `binh.le` "Lê Văn Bình") bằng đối chiếu **dept + chức danh trên bản LIVE**, không match tên trần; dept-lookup `:185` thêm null-guard.
12. **[L1-F3/F4] Trích dẫn `:44-46` ĐỦ + khai supersede**: nguyên văn có "chủ dự án phán 'PHÁ VỠ' @S155" — spec PHẢI khai K3 = **ĐẢO phán quyết cũ theo lời owner @S164**, kèm việc (e): sửa comment `:36-43`/`:44-46` + 2 lệnh tự-kiểm `:292-293` dán PR + cập nhật `KhkkWorkflowPanel.tsx:8` ×2 app + HANDOFF:77. 🔴 **[OG-9 — L2-M3] chờ anh xác nhận TƯỜNG MINH** (câu IV đã gửi): bật lại duyệt-sớm cho KHKK = đảo "PHÁ VỠ" @S155. **Chặn K3 việc (a)-(e); K1/K2 KHÔNG chặn.**
## ③ Checklist final cụm-1
- [ ] K1: 7 mục nền + **"Admin thấy leaf sidebar ×2 app"** + test seed-grant + drift 2 row STATUS khai cùng commit.
- [ ] K2: 8 mục nền + **submit-guard null-CatalogEntryId (test ÂM trên phiếu có line)** + phase-guard line-edit + Σ×3 nhất quán + Mig-71 6 item + 3-file + backfill=1 + **UI tạo-phiếu-nhóm-2 ×2 app** + bảng 18 consumer (17 nền + :712-713).
- [ ] K3 (SAU OG-9): 6 mục nền + sweep ép-false ≥4 site + test giữ-cờ-qua-CreateCommand + đo danh tính roster + comment/panel/HANDOFF cập nhật + verify LIVE trước seed.
- [ ] Mọi wave: test ≥590+k khai k · cicd PASS · BE-only smoke API (#77 chỉ khi FE-ship).
END spec-cum1-final — nền 17 test + vá: ~5 test mới · 1 OG mới (OG-9) · 2 blocker menu/ép-false đã đóng đường.

View File

@ -1,12 +1,161 @@
# c1-l1 QUYẾT-vs-code
Lane 1/3 review cụm-1 — lens: QUYẾT (spec chốt) đối chiếu CODE trên đĩa.
Đối tượng: `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-invest-fable-b2-cum1.md`
Đối tượng: `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-invest-fable-b2-cum1.md` (33.048B, 258 dòng).
Mọi dòng dưới đây đo từ đĩa @2026-07-31, HEAD `e5f53d0`.
## Điểm số
- **Citation hygiene: 23/25 chuẩn** — file:line trỏ đúng chỗ gần như tuyệt đối.
- **Claim chịu lực: 2/9 ĐÚNG · 4 LỆCH · 3 SAI** — lỗi KHÔNG nằm ở "trỏ sai dòng", mà ở **suy ra cái gì từ dòng đã trỏ đúng** và ở **cái không được trỏ**.
- **Blocker: 2** (F-1, F-2) · **Phải sửa trước khi implement: 4** (F-3, F-4, F-6, F-13).
## Findings
(ĐANG LÀM)
### F-1 [HIGH · BLOCKER] K3 8 menu-con khai "NGOÀI `MenuKeys.All`" ⇒ 0 permission row ⇒ sidebar không bao giờ hiện
- Spec dòng 214 (K3.c): "MenuKeys.cs +8 const `AwV2_KhkkN1..AwV2_KhkkN8`**NGOÀI `All`** (0 policy mới, tiền lệ `HopDongCung`/leaf `Pe_*`)". K3.c liệt: const + MenuItem row + `Layout.tsx` resolvePath + mirror `menuKeys.ts`. **Không có bước cấp quyền.**
- Đĩa: `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:2058``foreach (var menuKey in MenuKeys.All)` là nguồn DUY NHẤT của grant admin generic. Key ngoài `All` KHÔNG sinh row `Permissions`.
- `src/Backend/SolutionErp.Application/Permissions/Queries/GetMyMenuTree/GetMyMenuTreeQuery.cs` — grep `Admin|IsAdmin` = **0 hit** ⇒ cây menu KHÔNG có nhánh admin-bypass, không có đường vớt.
- Tiền lệ mà spec viện dẫn nói NGƯỢC lại: mọi key ngoài `All` trong repo đều đi kèm danh sách grant TƯỜNG MINH — `Pe_*``peKeys` `DbInitializer.cs:2196-2208`; `HopDongCung``.Concat(new[] { MenuKeys.KeHoachKyKet, MenuKeys.HopDongCung })` `:2214`; `Khkk_*``KhkkKeys()` `:2185-2189` + `SeedKeHoachKyKetAccessAsync` `:2415`.
- Và 3 leaf `AwV2_*` đang sống thì **NẰM TRONG `All`**: `src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs:182``ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2`. Tức 8 leaf mới phá chính khuôn anh em ruột của nó.
- Hệ quả đo được: acceptance K3.e mục 3 ("sidebar đếm được **8 menu-con**") sẽ FAIL tại UAT.
- **Đề nghị:** K3.c phải khai thêm 1 việc — hoặc đưa 8 key vào danh sách grant explicit (mẫu `KhkkKeys()` `:2185-2189`), hoặc đưa vào `All` và chấp nhận +32 policy kèm sửa 2 số canonical `docs/STATUS.md` (Policies 220 / Menu keys 55).
### F-2 [HIGH · BLOCKER] K1.d mắc đúng lỗi F-1, nhưng K1.f không có phép đo nào bắt được
- Spec dòng 57 (K1.d): "+const `MenuKeys.ContractCatalog` (**NGOÀI `All`** …) + 1 MenuItem row con group `Catalogs` … + **grant admin (SeedAdminPermissionsAsync loop)**". Hai vế này loại trừ nhau: nguồn của loop chính là `MenuKeys.All` (`DbInitializer.cs:2058`).
- Acceptance K1.f (dòng 69-75) có 7 mục: 3-file mig · seed 86 · restart ×2 · curl 200/403 · test +5 · DoD 86 dòng · số STATUS. **Không mục nào chạm menu.**
- Tức: bản cài đặt trong đó trang danh mục **không vào được từ sidebar** vẫn PASS 7/7. Đây là câu trả lời cho phép thử "impl SAI nào vẫn PASS?".
- Ghi chú thêm: 4 leaf catalog hiện có (`Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems`) đều nằm trong `All` (`MenuKeys.cs:169`, dòng thứ 29 của khối `All`) và nằm trong Master-set review-scope `DbInitializer.cs:2181-2183` — leaf mới ngoài `All` cũng sẽ vắng mặt ở review-scope, tức non-admin không thấy.
- **Đề nghị:** +1 acceptance đo được ("đăng nhập admin → sidebar `Danh mục` có leaf mới → click mở đúng trang `:kind=contract-catalog`") và khai đúng đường grant.
### F-3 [HIGH] K3.⚠️ đảo ngược một quyết định owner @S155 mà không khai — trích dẫn `:44` bị cắt đúng chỗ nói lý do
- Spec dòng 180 viết: "…(KHÔNG có nhánh AllowApproverFinalize; MEMORY-verified **`:44` comment không port CeoApprovalThreshold**)".
- Nguyên văn `src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs:44-46`:
> `(b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (AllowApproverFinalize, CeoApprovalThreshold) và cũng KHÔNG port AllowApproverSkipToFinal (:318-352 nguồn copy) — chủ dự án phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.`
- Spec trích 1 trong 3 tên nhánh, và tên bị bỏ đúng là `AllowApproverFinalize` — thứ K3.⚠️(a) định port. Mệnh đề owner-veto cũng bị bỏ.
- **Công bằng với lane:** `spec-4gd-khkk-tong-quat-31-07-2026.md:46` (hàng OG-3) CÓ ghi lời owner @S164 ("*a Chương sẽ quyết định là cái đó cần CEO ko hay là anh ấy đc duyệt*") ⇒ có cơ sở hợp lệ để SUPERSEDE quyết định @S155. Vấn đề là B2-cụm-1 không hề nói đây là đảo quyết cũ, nên người đọc sau sẽ gặp comment `:44-46` và tưởng code đang bị làm trái ý owner.
- **Đề nghị:** K3.⚠️ trích `:44-46` đầy đủ + thêm đúng 1 câu "OG-3 @S164 SUPERSEDE veto @S155", và đưa việc **sửa lại comment `:44-46`** vào danh sách việc của K3.
### F-4 [MED-HIGH] K3 thiếu việc code thứ 4: khối comment `:36-43` sẽ thành lời khai sai ngay khi (a) land
- K3.⚠️ liệt 3 việc (a)(b)(c); (c) chỉ gỡ khoá + note FE `ApprovalWorkflowsV2Page.tsx:151-152`.
- Nhưng `ContractSigningPlanWorkflowService.cs:37-43` đang khai: "🔴 KHAI RÕ: service này có **ĐÚNG MỘT** nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy". Và `:292-293` cài sẵn 2 lệnh tự-kiểm:
```
grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs
grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs
```
- Sau khi port nhánh duyệt-sớm, mệnh đề "ĐÚNG MỘT" hoặc vẫn đúng (nếu nhánh mới nhảy vào cùng choke-point) hoặc thành sai (nếu sinh site thứ 2) — **phải chứng, không phải đoán**.
- Đây đúng class lỗi mà chính spec dùng để bắt FE ở K3.⚠️(c) ("không gỡ = config-lie NGƯỢC"), chỉ khác chiều: lần này là comment BE nói "không có" trong khi code đã có.
- **Đề nghị:** thêm acceptance "chạy 2 lệnh `:292-293`, dán output vào PR, hai lệnh phải khớp nhau" + sửa `:36-43` cùng commit K3.
### F-5 [MED] K2.b đọc sai comment `:39`; site `:342` không phải bẫy — và phương án (i) sẽ TẠO RA bẫy đó
- Spec dòng 105 xếp `WorkflowService.cs:342` là "`= targetPhase` (gán qua BIẾN — đúng class bẫy `:39` comment)" rồi gắn "🔴 PHẢI XỬ", đề 2 phương án: (i) gọi `ApplyApprovedAmounts` ngay trước `:342` khi `targetPhase == DaDuyet`, (ii) assert + test khoá hành vi.
- Đọc `:39` trong ngữ cảnh `:36-43`: câu đó đang mô tả **NGUỒN COPY** (`ContractWorkflowService.cs:190-193`), và cả khối khẳng định KHKK **cố ý không có** site đó, lý do nêu ở `:40-42`: "hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số `targetPhase`".
- Đo lại từ đĩa, đúng như code khai:
- `:342` nằm trong `private async Task ReturnOrRejectAsync(...)` khai báo tại `:310`.
- Toàn repo có **đúng 2 call-site**: `:128-129` truyền literal `ContractSigningPlanPhase.TraLai`, `:133-134` truyền literal `ContractSigningPlanPhase.TuChoi`.
- Cửa vào là `TransitionAsync(Guid planId, string action, …)` `:86-90` — chữ ký **không có** `targetPhase`; `AllowedActions` `:73` chặn trước mọi truy vấn `:95-96`; switch đóng kín với `default: throw` `:137-139`.
- Thân hàm còn tự khoá thêm: `:318` `isReturn = targetPhase == TraLai`, `:320-323` chỉ chấp nhận phiếu đang `ChoDuyet`, `:346-347` xoá con-trỏ, `:349-355` ghi log "Trả lại/Từ chối".
- ⇒ `targetPhase == DaDuyet` **không reachable hôm nay**. Phương án (i) sẽ mở đường thứ 2 tới `DaDuyet` chạy xuyên thân hàm return/reject (xoá con-trỏ + log sai nhãn) — tức tự tay dựng đúng cái bẫy `:37-43` nói là đã tránh được.
- **Đề nghị:** bỏ (i), giữ (ii); đổi nhãn ô bảng từ "🔴 PHẢI XỬ" thành "an toàn do thiết kế — khoá bằng test hành vi". Test K2.g #6 giữ nguyên, nhưng phải phát biểu là "không action string nào đưa phiếu tới DaDuyet mà bỏ qua freeze".
### F-6 [MED-HIGH] K2 tự mâu thuẫn về `CatalogEntryId` bắt buộc; hệ quả là rào "phiếu phải có hạng mục" không tồn tại ở đâu
Ba câu trong cùng một cụm, đọc liền nhau thì triệt tiêu nhau:
- dòng 87 (K2.a-4): "Chặn phát sinh mới bằng validator: **line MỚI/SỬA bắt buộc có `CatalogEntryId`** (chỉ row legacy được giữ null)".
- dòng 113 (K2.c): auto-gen `Features.cs:389` "mồi `CatalogEntryId = null, TenHangMuc = null`" — tức **mọi** line của **mọi** phiếu mới đều null.
- dòng 126 (K2.d): submit-guard "Line `CatalogEntryId == null` … → **CHO QUA**".
- Đối chiếu code: `ContractSigningPlanFeatures.cs:383-400` auto-sinh line trong chính handler create, `:389` `plan.Lines.Add(new ContractSigningPlanLine { … })`.
- ⇒ Phiếu vừa tạo (100% line null) submit và duyệt trọn được mà không mang hạng mục nào. Mục tiêu K2 ("1 phiếu = 1 nhóm, N hạng mục") không được rào tại bất kỳ tầng nào, còn DoD "line khác nhóm → 409" thì PASS trivially vì tập line-có-catalog rỗng.
- **Đề nghị:** khai rõ luật NOT-NULL chỉ áp cho lines-editor K4b; và hoặc thêm rào submit "≥1 line có `CatalogEntryId`" với acceptance đo được, hoặc defer TƯỜNG MINH sang K4b kèm ghi nợ.
### F-7 [MED] Chứng "2 line NULL cùng (Plan,Supplier) không lọt" hở mắt xích — mắt xích thật nằm ở module khác
- Spec dòng 87 lập luận: "Data cũ AN TOÀN (unique cũ bảo đảm ≤1 row/cặp ⇒ tối đa 1 NULL-row/cặp)". Vế **data cũ** này ĐÚNG: `ContractSigningPlanLineConfiguration.cs:22-24` là `HasIndex(new { ContractSigningPlanId, SupplierId }).IsUnique().HasFilter("[IsDeleted] = 0")`, và index mới cũng filter `[IsDeleted]=0` nên row xoá mềm không tham gia. Migrate sẽ không vỡ.
- Nhưng unique cũ **bị DROP trong chính Mig 71** (K2.a-4) ⇒ sau migrate nó không còn che auto-gen nữa. Thứ thật sự chặn auto-gen sinh 2 line cùng supplier là index ở **module khác**, spec không hề nhắc: `PurchaseEvaluationConfiguration.cs:105` — `b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique();` (không filtered). Nhờ nó mà vòng lặp `Features.cs:383` (`foreach w in winners`) với `:392 SupplierId = w.SupplierId` không thể ra 2 line trùng.
- Kết luận của spec đúng, nhưng chuỗi chứng đứt: bảo đảm đang được chuyển từ một index **sắp bị xoá** sang một index **chưa từng được nêu tên**.
- **Đề nghị:** cite `PurchaseEvaluationConfiguration.cs:105` như bất-biến chịu lực và khoá bằng 1 test; nếu K4b sau này cho thêm line tay thì bất-biến này hết hiệu lực, phải khai luôn.
### F-8 [MED] Tiền: list DTO có HAI tổng, spec chỉ xử một — sau freeze, list và detail sẽ nói 2 con số khác nhau
- `ContractSigningPlanFeatures.cs:709-713` (projection list): `Count(lines)` `:709` · `Sum(PeReferenceAmount)` `:710-711` · **`Sum(ProposedAmount)` `:712-713`**.
- Bảng 17-consumer K2.f dòng 148 chỉ có `:711`. `:712-713` **không xuất hiện** trong bảng.
- K2.e (dòng 132) định nghĩa lại semantics cho row tách: `PeReferenceAmount = 0`, nhưng không nói `ProposedAmount` của row tách bằng bao nhiêu ⇒ tổng đề-xuất trên list hoặc tụt hoặc nhân đôi, không dấu vết.
- List cũng không có tổng `ApprovedAmount`. Sau `DaDuyet`, detail hiện `ApprovedAmount` (display-gate `budgetFrozen`, K2.b dòng 107) còn list vẫn hiện `ProposedAmount` ⇒ hai màn hai con số cho cùng một phiếu đã chốt.
- **Đề nghị:** thêm `:712-713` vào bảng consumer; quy định `ProposedAmount` của row tách; và cho list đọc `ApprovedAmount ?? ProposedAmount` khi phiếu đã đóng băng (hoặc khai tường minh là chấp nhận lệch).
### F-9 [MED] Claim "hit `IgnoreQueryFilters` đầu tiên trong src/Backend — S155 đo 0-hit" là SAI
- Spec dòng 38 (K1.b): "(Đây là hit `IgnoreQueryFilters` **đầu tiên** trong src/Backend — S155 đo 0-hit — cố ý, khai trong PR.)"
- Đo lại: **29 occurrence / 5 file** trong `src/Backend`:
`SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs` 1 · `SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` 18 · `SolutionErp.Application/Contracts/ContractFeatures.cs` 3 · **`SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` 3** (đúng file cụm-1 sẽ sửa) · `SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs` 4.
- Kèm theo: `PurchaseEvaluationsController.cs:186` cũng đang khai "DUY NHẤT dùng `IgnoreQueryFilters()` trong toàn `src/Backend` (D3)" — hai claim độc quyền cùng sai, claim của spec nhiều khả năng thừa kế từ claim này.
- Nội dung kỹ thuật vẫn ĐÚNG và đáng giữ: seeder phải `IgnoreQueryFilters()` mới né được #75/#76, còn khuôn `SeedRealMasterDataAsync` thì đúng là không có (`DbInitializer.cs:2970` `var existingProjectCodes = await db.Projects.Select(p => p.Code).ToListAsync();` — không IgnoreQueryFilters, đúng như spec mô tả). Chỉ cái nhãn "đầu tiên" là sai.
- **Đề nghị:** bỏ nhãn "đầu tiên / S155 đo 0-hit"; nếu tiện thì ghi errata cho `PurchaseEvaluationsController.cs:186`.
### F-10 [LOW-MED] Ba citation trỏ vào USE-SITE trong khi việc phải sửa nằm ở DECLARATION
| Spec nói | Thứ ở đó | Declaration thật |
|---|---|---|
| `LineDto :570-574` +2 field (dòng 131) | projection `.Select(l => new ContractSigningPlanLineDto(…))` | `public record ContractSigningPlanLineDto(` — `Features.cs:59` |
| `DetailDto :608-616` +2 field (dòng 131) | lời gọi ctor `return new ContractSigningPlanDetailDto(` `:608` | `public record ContractSigningPlanDetailDto(` — `Features.cs:119` |
| `types/khkk.ts:203` `KhkkLineDto` +2 field (dòng 149) | field `lines: KhkkLineDto[]` của interface Detail | interface `KhkkLineDto` (chỗ khác trong file) |
Ngược lại 2 citation cùng loại thì chuẩn: `ContractSigningPlanListItemDto` `:34` (đúng decl) và `ListContractSigningPlansQuery` `:622-626` (đúng decl). **Đề nghị:** với mỗi DTO ghi CẶP (decl + mọi construction site), vì sửa 1 nơi thì không biên dịch được.
### F-11 [LOW] Đường entity K1.a không nằm "cạnh WorkItem"
- Spec dòng 11: `src/Backend/SolutionErp.Domain/Master/ContractCatalogEntry.cs` "(cạnh WorkItem — folder Domain/Master hiện hữu)".
- Đĩa: `Domain/Master/` = `Catalogs/`, `Department.cs`, `Project.cs`, `Supplier.cs`, `SupplierStatus.cs`, `SupplierType.cs`. `WorkItem.cs` nằm ở `Domain/Master/Catalogs/` (cùng `MaterialItem.cs`, `ServiceItem.cs`, `UnitOfMeasure.cs`).
- Ảnh hưởng thật: namespace `SolutionErp.Domain.Master` vs `SolutionErp.Domain.Master.Catalogs` ⇒ `using` ở Configuration, Features, `ApplicationDbContext`, `IApplicationDbContext`. **Đề nghị:** chốt `Domain/Master/Catalogs/ContractCatalogEntry.cs` (Application path `Application/Master/Catalogs/` mà spec chọn thì đã đúng khuôn).
### F-12 [LOW] Index-swap cite lệch 2 dòng
- Spec dòng 86: "DropIndex unique cũ … (tên EF-sinh — lấy đúng từ `ContractSigningPlanLineConfiguration.cs` vùng **:24-26**)".
- Đĩa: unique cũ ở **`:22-24`**; `:25` là `HasIndex(x => x.SupplierId)`, `:26` là `HasIndex(x => x.ContractId)` — hai index thường, không liên quan. Đi theo `:24-26` là đụng nhầm.
- Cùng mục: "`:29 WithMany(p => p.Lines)` giữ nguyên" ✅ chính xác.
### F-13 [MED] "Item 6" của Mig 71 là tham chiếu treo — và trả lời câu hỏi (c) "hợp lý hay trộn concern"
- K3.⚠️(b) dòng 181: "gắn vào **Mig 71 item 6 (K2.a)**". §Thứ tự commit dòng 242: "Mig 71 gồm cả **item 6** `EndedByLevelFinalize`".
- K2.a (dòng 83-88) liệt **1..5**. Không có item 6. Người làm K2 mà chỉ đọc K2.a sẽ không thêm cột ⇒ K3 mất tiền đề "0-mig" và test K3.d #5 (`EndedByLevelFinalize=1`) fail.
- **Trả lời (c): là TRỘN CONCERN**, ba lý do đo được: (1) tên mig `AddContractSigningPlanGroupAndLineCatalog` không phủ cột này, mà tên mig là nhãn duy nhất người sau đọc trong `__EFMigrationsHistory`; (2) giữa commit K2 và commit K3 cột là ghost — defined-but-unread, đúng class ghost-wire; (3) chính spec phải viết thêm 2 chỗ cross-ref để bù, và đã hụt 1 chỗ (đây).
- Vẫn chấp nhận được NẾU làm đủ 3 việc: liệt item 6 tường minh vào K2.a · đổi tên mig cho phủ · khai ghost-window trong PR. Tách Mig 72 thì sạch concern hơn nhưng tốn 1 mig — trade-off để lead chốt, không phải lỗi.
### F-14 [MED] Roster K3: acceptance chỉ đo BAO NHIÊU, không đo AI ⇒ chọn sai người vẫn PASS trọn bộ
- 11/11 mapping email trong bảng K3.a **đối chiếu đúng** `DbInitializer.cs:1613-1632`, kể cả cảnh báo bẫy `long.chau@` vs `long.nguyen@` (`:1616` vs `:1629`) — phần này lane làm tốt.
- Nhưng cơ chế resolve cho ô ⚠️ "Trưởng" **không đủ an toàn**:
- Ứng viên thật trong DB: `truong.le@` "Lê Trần Đăng Trường" `:1626` · `tring.le@` "Lê Tự Đăng Trình" `:1625` · và còn `anh.nguyen@` "Nguyễn Thị Kim Ánh" `:1624` mà spec không nhắc (CCM có 6 NV `:1621-1626`, roster chọn 4).
- Fail-soft theo khuôn `:178-183` chỉ bắt email **không tồn tại**. Chọn nhầm một email **có tồn tại** thì seed 8/8 thành công ⇒ K3.e mục 2 (đếm 8) PASS, mục 4/5 PASS ⇒ sai người duyệt lọt trọn vẹn. Không mục acceptance nào của K3.e hỏi "ai".
- Test K3.d #2 `SeedKhkk_Shape_PerWorkflow` chỉ đếm hình dạng (Order1×4 + Order2×1), không assert danh tính 11 email.
- Cặp nhập nhằng thứ 2 không được gắn ⚠️: PRO "Thanh" → `thanh.lethanh@` "Lê **Thanh Bình**" `:1615`, trong khi CCM "Binh" → `binh.le@` "Lê Văn **Bình**" `:1621`. Nếu roster gốc ghi "Bình" thì cùng lớp nhập nhằng như "Trưởng".
- Thêm một lỗ của khuôn được sao chép: `:185` `var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");` **không có null-guard**. K3 cần cả `PRO`/`CCM`/`BOD`, mà fail-soft spec mô tả chỉ phủ user.
- **Đề nghị:** (1) test #2 assert đủ 11 email theo bộ ba (Code, StepOrder, LevelOrder), không chỉ đếm; (2) chốt ô ⚠️ TRƯỚC khi viết seed, không để thành hậu-kiểm; (3) fail-soft nên log ĐỦ email thiếu thay vì `return` ở email hỏng đầu tiên (một email hỏng hiện giết cả 8 workflow); (4) phủ dept vào fail-soft.
### F-15 [LOW] Picker đổi nghĩa nhưng giữ tên cũ; `existingGroups` là collection-projection trong ctor record
- `Features.cs:1199-1202` hiện loại PE đã có plan (`:1201-1202` — spec cite chuẩn). K2.c bỏ vế loại-trừ ⇒ DTO `ApprovedPeAwaitingPlanDto` (`:150`) không còn "AwaitingPlan" nữa; tên thành sai nghĩa cho cả BE lẫn FE.
- `existingGroups: int[]` là subquery collection tương quan đặt trong `select new ApprovedPeAwaitingPlanDto(…)` — cần kiểm dịch EF Core trước khi cam kết (rủi ro không translate / client-eval), hoặc lấy bằng 1 query phụ rồi ghép.
## Những claim CHỊU ĐƯỢC soi (khai để lead khỏi soi lại)
1. **QUYẾT-1 catalog authz — ĐÚNG 4/4 vế.** `CatalogsController.cs:13` `[Route("api/catalogs")]` · `:14` `[Authorize]` class-level · **12** action ghi đều `[Authorize(Roles = "Admin")]` tại `:23,31,40,53,61,70,83,91,100,113,121,130` (đúng dải `:23-:130` spec nói) · grep `Authorize(Policy = "(WorkItems|MaterialItems|UnitsOfMeasure|ServiceItems)` = **0 hit**. Comment `:9` của chính controller cũng khai cùng ý ("Authorize = đăng nhập đủ … ghi cần Admin"). Lý do (ii) của spec (picker cần READ rộng, khoá `KeHoachKyKet.Read` sẽ 403) khớp gotcha #85.
2. **Chứng cứ máy-freeze — ĐÚNG từng dòng.** `ContractSigningPlanLine.cs:17-19` comment snapshot + `:20 PeReferenceAmount` · `:23 ApprovedAmount` kèm chú "CHỈ ghi tại choke-point finalize (W3)" · `WorkflowService.cs:277 ApplyApprovedValuesOnFinalize(plan);` đứng **trước** `:278 plan.Phase = DaDuyet` · `:301-305` helper với `??=` · `:288-298` khối LUẬT, trong đó `:290` đúng nguyên văn spec trích.
3. **Liệt kê site gán Phase — ĐỦ, không sót.** Grep `Phase\s*=[^=]` toàn repo cho KHKK ra đúng 4 write-site vào `plan.Phase`: `Features.cs:373` (initializer, không có dấu chấm — spec cảnh báo đúng), `WorkflowService.cs:174`, `:278`, `:342`. Phần còn lại là đọc (`:173/:245/:341`), là `Changelog.FromPhase/ToPhase` (`:493-494`), là comment (`:39/:292`), hoặc nằm trong tests. DbInitializer 0 site — đúng như spec khai.
4. **Citation khác đều chuẩn:** `Features.cs:350` + `:460` (2 call-site `EnsureWorkflowTypeAsync`, spec ghi `:458-462` — nằm trong dải) · `:500` Include Lines · `:524` supplierIds · `:568-574` projection line · `:622-626` query record · `:711` Σ PeReference · `:1201-1202` picker · `DbInitializer.cs:174-176` idempotent per-type · `:178-183` fail-soft · `:1780-1787` Khkk_G1 + 6 leaf · `:1801` node ApprovalWorkflowsV2 · `:2970` khuôn thiếu IgnoreQueryFilters · `Layout.tsx:166-172` resolvePath 3-code + `:174 return null` (rớt im lặng #50 — đúng) · `ApprovalWorkflowsV2Page.tsx:147` slot 10, `:151-152` note khoá, `:164-171` DEFAULT_CODE_BY_TYPE · `KhkkCreatePage.tsx:8` "FE KHÔNG gửi lines" + `:56-66` lọc `isUserSelectable` · `KhkkDetailPage.tsx:425-441` bảng lines (lưu ý: thêm cột phải sửa cả `<td>` ở `:443+`, ngoài dải cite) · `types/khkk.ts:203` · `ApprovalWorkflow.cs:158 AllowApproverFinalize` có sẵn (đúng: 0 mig cho cột này).
5. **Số canonical khớp `docs/STATUS.md:6`** (Mig **69** · **96 bảng** · **590 test** · Policies 220 · Menu keys 55) ⇒ "96→97", "69→70→71", "baseline +17" đều nhất quán.
## Verdict
(ĐANG LÀM)
**PASS_WITH_BLOCKERS** — spec cụm-1 có nền chắc (citation 23/25 chuẩn, phần chứng-cứ freeze và authz mirror miễn nhiễm soi), nhưng **không được implement nguyên trạng**.
- **2 blocker phải sửa trước khi viết dòng code nào:** F-1 (8 menu-con K3 ngoài `All` ⇒ sidebar chết, chính acceptance K3.e sẽ FAIL) và F-2 (K1.d cùng lỗi, K1.f mù không bắt được).
- **4 phải sửa trong spec trước khi giao:** F-3 (khai việc đảo quyết owner @S155 + trích đủ `:44-46`), F-4 (việc code thứ 4: comment `:36-43` + 2 lệnh tự-kiểm `:292-293`), F-6 (mâu thuẫn `CatalogEntryId` bắt buộc ⇄ auto-gen null ⇒ rào hạng mục không tồn tại), F-13 (item 6 treo + tên Mig 71 không phủ).
- **1 phải gỡ khỏi spec:** F-5 phương án (i) — nó dựng đúng cái bẫy mà `:37-43` nói đã tránh; giữ (ii).
- Còn lại F-7…F-12, F-14, F-15 là siết chặt + sửa nhãn.
- Câu trả lời gọn cho 6 câu hỏi giao: (a) **ĐÚNG** · (b) chứng-cứ **ĐÚNG**, disposition site `:342` **SAI** · (c) **TRỘN CONCERN**, chấp nhận được kèm 3 điều kiện · (d) kết luận **ĐÚNG** nhưng chuỗi chứng **HỞ**, và "line mới bắt buộc có CatalogEntryId" **mâu thuẫn** K2.c · (e) mapping **ĐÚNG 11/11**, cơ chế resolve **KHÔNG đủ an toàn** · (f) 17/17 dòng cite **chuẩn**, nhưng **thiếu** `:712-713` Σ ProposedAmount.

View File

@ -1,12 +1,96 @@
# c1-l2 đủ-vs-spec
Lane 2/3 — review cụm-1 (`sub-invest-fable-b2-cum1.md`) đối chiếu spec-v2 (`spec-4gd-khkk-tong-quat-31-07-2026.md`).
Lens: **ĐỦ-vs-SPEC** (nghĩa vụ nào của spec-v2 KHÔNG được cụm-1 gánh).
Lens: **ĐỦ-vs-SPEC** (nghĩa vụ nào của spec-v2 KHÔNG được cụm-1 gánh) — mọi claim đo lại trên đĩa.
**VERDICT: FAIL (cổng tiền-implement) — 17 finding / 3 HIGH.** Nền vững (anchor ~95% chính xác, 0 trôi số canonical); thủng ở **acceptance mù**: cả 3 HIGH đều thuộc lớp *"impl SAI vẫn PASS 100% checkbox"*. Vá bằng chỉnh acceptance + bổ sweep, KHÔNG cần làm lại invest.
---
## A. Bảng phủ 9 quyết định §② (Q1)
| QĐ | Nghĩa vụ | Cụm-1 gánh ở | Kết |
|---|---|---|---|
| ②.1 OG-1 gộp | schema GỘP-capable | PRE `:79` ghi tên; việc THẬT = swap UNIQUE `:86` nhưng không dán nhãn OG-1; 0 acceptance riêng | ⚠️ 0-bit gate |
| ②.2 per-nhóm + line hạng mục | cột + UNIQUE + rào + Σ + 16-consumer | `:84-87` · `:111-112` · `:132` · `:136-153` (17 dòng) | ✅ (trừ M1, M6) |
| ②.3 8 Code + K2-chủ-cột | seed 8 + validator + "K3 chỉ đọc" | `:185-192` · `:210` · `:130` | ✅ |
| ②.4 vai 2 tầng | **"khai limitation trong doc K1"** | chỉ 2 chú-thích cột `:21`,`:40`**0 deliverable doc** trong commit K1 `:240`, **0 acceptance** `:69-75` | ❌ **RƠI** |
| ②.5 NĐUQ tham khảo | cột text, seed null | `:23` | ✅ |
| ②.6 backfill=1 (+42-leaf→K4a) | default 1 + COUNT==Σ8 | `:83` + acceptance `:168` | ✅ |
| ②.7 admin ẩn | cụm-1 chỉ cần CHỪA subtree AwV2 | `:214` | ✅ (đúng phạm vi) |
| ②.8 nguồn + OG-6 | chặn seed-land | `:7` · `:241` · `:256` = **3 chỗ** | ✅ |
| ②.9 freeze | freeze tại finalize + né #81-EXT | K2.b `:90-107` | ⚠️ H2 + M4 |
**PRE-gate (2 câu hỏi đích danh):** OG-6 chặn seed-land K1 = **CÓ**, 3 chỗ (`:7`/`:241`/`:256`). OG-2 verify-LIVE trước seed K3 = **CÓ**, 3 chỗ (`:176`/`:229`/`:243`) — và **khả thi thật**: `ApprovalWorkflowsV2Controller.cs:17-21` `GET api/approval-workflows-v2` `[Authorize]` trần, DTO có `IsActive` (`ApprovalWorkflowV2AdminFeatures.cs:176`) ⇒ phân biệt được LIVE/archived.
**14 vá b1b thuộc cụm-1 (8 mục):** #2 ✅ · #3 ✅ · #4 ✅ · #5 ✅BE/❌UI (M1) · #6 ✅ · #7 ✅ · #11 ✅ · #13 ✅.
---
## Findings
(ĐANG LÀM)
### HIGH
**H1 — leaf `ContractCatalog` NGOÀI `All` + "grant qua SeedAdminPermissionsAsync loop" = tự mâu thuẫn ⇒ menu KHÔNG BAO GIỜ hiện.**
Cụm-1 `:57`. Đo: `DbInitializer.cs:2058` `foreach (var menuKey in MenuKeys.All)` ⇒ key ngoài `All` không sinh Permission row. `GetMyMenuTreeQuery.cs` build cây THUẦN từ `db.Permissions` + filter `HasAccess = CanRead || child`**không có admin-bypass**; inherit-root chỉ **4** (Contracts/Workflows/PurchaseEvaluations/PeWorkflows), **`Catalogs` KHÔNG có**. ⇒ 0 row ⇒ leaf bị filter, kể cả Admin.
Tiền lệ dẫn SAI: `Pe_*` sống được CHÍNH VÌ `PurchaseEvaluations` là inherit-root; `HopDongCung` thì 0 menu-row. Khuôn THẬT của leaf catalog: `MenuKeys.cs:168` `Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems`**cả 5 đều TRONG `All`**.
Lý do (iv) "0 policy/menu-key mới = 0 drift STATUS row Policies" (`:47`) = **Goodhart**: giữ số doc đứng yên bằng cách làm menu chết. Acceptance K1.f `:69-75` **không có mục nào đo "leaf hiện trong sidebar"** ⇒ lỗi ship với 7/7 xanh.
Đề nghị: chọn 1 trong 2 — (a) vào `All` và KHAI drift `Menu keys` 55→56 + `Policies` 220→224 (2 row cùng lúc); (b) grant tường minh kiểu `SeedKeHoachKyKetAccessAsync`. Thêm acceptance "login Admin thấy leaf ở nhóm Danh mục" + 1 test seed-grant.
**H2 — K3.⚠️ liệt 3 việc code, SÓT ≥3 site "ép false" ⇒ tính năng Chương-tick chết ở lần admin lưu lại quy trình đầu tiên, mà mọi acceptance K3 vẫn PASS.**
Cụm-1 `:181` liệt (a) nhánh service (b) cột runtime (c) gỡ khoá FE `:151-152`. Sót:
- `ApprovalWorkflowV2AdminFeatures.cs:380-381` — CREATE **ép false server-side**: `AllowApproverSkipToFinal/Finalize = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.…` (comment `:377-379` "ép false server-side (FE đã ép ở levelFlagsPayload — đây chặn curl bypass)").
- `:591-594` PUT sửa-tại-chỗ: echo lệch → `ConflictException` "không được bật/tắt cờ …".
- `:605-610` dòng MỚI mang cờ → `ConflictException`.
- FE `ApprovalWorkflowsV2Page.tsx:247-258` `levelFlagsPayload(e, lockEarlyFinalize)``allowApproverFinalize: lockEarlyFinalize ? false : …`, bật bởi `:638 earlyFinalizeLocked = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN`. Gỡ note `:151-152` mà không gỡ 2 chỗ này ⇒ UI mở nhưng payload vẫn false.
Vì sao acceptance mù: K3.d test 2 & 5 đo trên workflow **SEED** (ghi thẳng DbContext, không qua command) ⇒ xanh. Đây đúng lớp **#81-EXT** mà chính doc viện dẫn — doc quét kỹ `Phase = ` nhưng **không quét `AllowApproverFinalize`**.
Đề nghị: bổ sweep `grep -rn "AllowApproverFinalize\|AllowApproverSkipToFinal"` vào K3, liệt đủ site vào PR + 1 test "tạo version mới qua CreateCommand cho type-10 GIỮ được cờ".
**H3 — `CreateContractSigningPlanCommand` +`ApprovalGroup` required, nhưng `KhkkCreatePage` bị dispose "KHÔNG-ĐỔI" ⇒ tạo phiếu vỡ (hoặc feature vô hình); acceptance curl-only không bắt.**
Cụm-1 `:111` (+`ApprovalGroup int`, validator `InclusiveBetween(1,8)`) ⟂ `:151` K2.f row 16-17 `KhkkCreatePage.tsx:8` = "KHÔNG-ĐỔI đợt này" ⟂ `:112` lại đòi sửa `KhkkCreatePage` ×2 app.
Đo: `fe-admin/src/pages/khkk/KhkkCreatePage.tsx:72-77` body = `{ peId: purchaseEvaluationId, approvalWorkflowId, … }``api.post('/contract-signing-plans', body)`**không có `approvalGroup`**. Header `:3` khai "File MIRROR SHA256 identical với fe-admin" ⇒ phải sửa cả 2 app. Không sửa: `approvalGroup=0` → 400 mọi lần tạo. Nếu impl né bằng default=1: mọi phiếu FE tạo ra đều nhóm 1 ⇒ feature vô hình.
K2.h `:169` đo bằng **curl** ⇒ bỏ qua FE hoàn toàn. Đề nghị: 1 dòng acceptance "tạo phiếu nhóm 2 **từ UI** ×2 app".
### MEDIUM
**M1 — nửa-UI của OG-7 có trong văn, 0 acceptance / 0 test / 0 dòng commit.** `:112` mô-tả nút "Tạo thêm cho nhóm khác" + `existingGroups: int[]`; K2.g `:157-163` không có test; K2.h `:167-172` không có mục; commit-K2 `:242` chỉ ghi "DTO + FE types". Impl SAI vẫn PASS: BE mở rào, UI giữ 409 cũ ⇒ đúng thứ owner nhìn thấy thì vắng.
**M2 — nhãn đo-lường SAI: "hit `IgnoreQueryFilters` đầu tiên trong src/Backend — S155 đo 0-hit"** (`:38`). Đo lại: **29 hit** trong `src/Backend` (trừ Migrations) — 18 `ApprovalWorkflowV2AdminFeatures.cs` · 4 `PeSoftDeleteFeatures.cs` · **3 `ContractSigningPlanFeatures.cs`** (chính module đang spec) · 3 `ContractFeatures.cs` · 1 `PurchaseEvaluationsController.cs`. Số ĐÚNG & chịu lực = **0 hit trong `DbInitializer.cs`** (đo: 0). Đơn thuốc vẫn ĐÚNG (chứng `DbInitializer.cs:2970` `db.Projects.Select(p => p.Code)` thiếu `IgnoreQueryFilters` — verified). Sửa nhãn hoặc bỏ nhãn.
**M3 — K3.⚠️(a) mở lại quyết định owner đã ĐÓNG bằng chữ "PHÁ VỠ" @S155, doc không nhắc một chữ.** 5 nơi ghi: `ContractSigningPlanWorkflowService.cs:44-46` · `ApprovalWorkflowV2AdminFeatures.cs:377` · `fe-{admin,user}/src/pages/khkk/KhkkWorkflowPanel.tsx:8` · `docs/HANDOFF.md:77` (vẫn nằm ở mục "🔸 Không chặn W1-W2" = CHƯA chốt). Spec v2 §④ OG-3 có câu owner @S164 nghe như tái-cho-phép và lead đã dán "🔄 SỬA @B2c1", nhưng câu đó nói về **NĐUQ/ngưỡng CEO**; đảo một phán quyết mang chữ "PHÁ VỠ" phải hỏi owner TƯỜNG MINH. → trả lời Q4.
**M4 — freeze tuyên "xong" ở cụm-1 nhưng lỗ duy nhất bịt bằng việc cụm-2.** `:107` tự khai: "xoá mềm line sau DaDuyet làm Σ `:711` trôi ÂM THẦM — chặn bằng phase-guard", mà phase-guard nằm ở **K4b (cụm-2)**. Đo: `Features.cs:709-711` Σ tính LIVE từ `ContractSigningPlanLines` ⇒ soft-delete 1 line đổi tổng phiếu ĐÃ DUYỆT. K2.g test 5 chỉ đo `ApprovedAmount != null`.
**M5 — neo chéo gãy: "Mig 71 item 6" không tồn tại.** `:181` ("gắn vào Mig 71 **item 6 (K2.a)**") + `:242` ("Mig 71 gồm cả item 6") ⟂ K2.a `:83-88` chỉ có **item 1-5**. Impl đọc K2.a rời → Mig 71 thiếu `EndedByLevelFinalize` → K3 kẹt hoặc phải đẻ Mig 72 trái QUYẾT `:242`.
**M6 — `CatalogEntryId` bắt-buộc hay không: 3 câu không thể cùng đúng.** `:87` "line MỚI/SỬA **bắt buộc**`CatalogEntryId`" ⟂ `:113` auto-gen create "mồi `CatalogEntryId = null`" (mọi phiếu tạo sau Mig 71 đều sinh line null) ⟂ `:126` "null → CHO QUA". Cách đoán quyết định bẫy NULL-unique (`:87`) có nổ hay không.
### LOW
- **L1** — `:105` gắn cờ 🔴 "PHẢI XỬ" cho `WorkflowService.cs:342` do đọc nhầm chú thích `:39`. Đo: `ContractSigningPlanWorkflowService.cs:36-42` đã KHAI RÕ service có **ĐÚNG MỘT** nhánh set DaDuyet, và `:39` là trích **nguồn-copy** `ContractWorkflowService.cs:190-193` như thứ CỐ Ý không chép. `:342` nằm trong `ReturnOrRejectAsync` (`:310-318`, `targetPhase ∈ {TraLai,TuChoi}`; guard `:320-323` đòi `Phase == ChoDuyet`). Phương án (i) "gọi `ApplyApprovedAmounts` ngay trước `:342`" = thuốc SAI. Phương án (ii) test-khoá-hành-vi hợp lệ, bổ cho `ContractSigningPlanApprovalTests.cs:523-533` (T3 hiện chỉ khoá write-path Admin-approve).
- **L2** — `:11` đặt file `Domain/Master/ContractCatalogEntry.cs` "(cạnh WorkItem)"; `WorkItem.cs` thật ở `src/Backend/SolutionErp.Domain/Master/Catalogs/WorkItem.cs`. 2 vế không cùng đúng.
- **L3** — GroupCode dòng CEO: `:19` liệt giá trị `CEO``:40` bảo lấy heading "(A1..C3, **5.2**)" ⇒ ra `"5.2"`. Heading nguồn: `danh-muc-sp002-transcribe.md:131` "## 5.2 — CEO".
- **L4** — `ContractSigningPlanFeatures.cs:32` tự khai luật "positional record, field append-CUỐI (đừng chèn giữa — vỡ FE mapping)"; K2.e `:131` liệt 3 DTO cần +field nhưng không nói vị trí.
- **L5** — `AddColumn … defaultValue: 1` (`:83`) để lại DEFAULT constraint vĩnh viễn ⇒ insert quên set `ApprovalGroup` IM LẶNG thành nhóm 1 thay vì fail (cùng gốc M6/H3).
- **L6** — đếm QUYẾT: lead hỏi "3 QUYẾT của lane", đo được **5** (`:44`+`:47` policy · `:84` loose-Guid · `:90`+`:96` freeze-reuse · `:210` convention-parse · `:242` gộp-Mig-71).
- **L7** — `:86` trỏ "vùng `:24-26`" để lấy tên index EF-sinh; index thật ở `ContractSigningPlanLineConfiguration.cs:22-24`.
- **L8** — acceptance `:167` (`SUM(PeReferenceAmount)` trước == sau migrate) gần 0-bit: Mig 71 không chạm cột tiền ⇒ không impl-sai nào làm nó TRƯỢT. Răng thật ở test `:163` (mồi 0 khi tách dòng) — nên nêu vế đó.
---
## Kháng được soi (đo lại, ĐÚNG)
- **P1** — bảng #81-EXT `:100-105`: `grep -rnE "Phase\s*=[^=]"` → đúng 4 site (`Features.cs:373` · `WorkflowService.cs:174/:278/:342`); "DbInitializer 0 site KHKK" đo được **0**. Cảnh báo "đừng grep `\.Phase`" xác nhận đúng.
- **P2** — 13/13 email+tên K3.a khớp `DbInitializer.cs:1613-1632` từng dòng; bẫy `long.nguyen@` (HRA `:1629`) có thật; ô ⚠️ Trưởng/Trình có thật (`:1625` Lê Tự Đăng Trình ⟂ `:1626` Lê Trần Đăng Trường).
- **P3** — "KHKK không đọc bảng NS live": đo lại chỉ **1 hit source** (`Features.cs:162` nhãn DTO) ⇒ lý do bỏ 11 cột PE Mig 67 đứng vững.
- **P4** — khuôn authz catalog: `CatalogsController.cs:13-14` + đúng **12** `[Authorize(Roles = "Admin")]` + policy-per-action **0 hit**. Số khớp tuyệt đối.
- **P5** — 86 & phân bố 5/10/5/23/14/15/13/1 tự-kiểm từ transcribe: A1=5 · A2 1+A3 9=10 · A4=5 · B1=23 · B2=14 · B3=15 · C1 6+C2 3+C3 4=13 · CEO=1 ⇒ Σ=86 ✓; EN có mặt **86/86**; 8 cột map đủ (NduqNote seed null, khớp ⚠3).
- **P6** — 0 trôi số canonical: STATUS `Tests 590` (`:472`) · `SQL tables 96` (`:466`) · `Migrations 69` (`:465`) ⇒ "Mig 70/71" · "96→97" · "baseline ~590 +17" đều đúng.
- **P7** — OG-2 verify-LIVE khả thi đúng như mô tả (route + `IsActive` đều có).
- **P8** — anchor `Features.cs` :34 · :342-347 · :350 · :389 · :458-462 · :570-574 · :622-625 · :711 · :1201-1202 và `Line.cs:15/:17-20/:23/:25` · `LineConfig:22-26/:29` · `Layout.tsx:166-172`**đo lại khớp hết**.
## Verdict
(ĐANG LÀM)
**FAIL** (cổng tiền-implement) — 17 finding: **3 HIGH · 6 MEDIUM · 8 LOW**, 8 điểm kháng-soi.
Chặn: H1 (menu chết), H2 (feature owner-yêu-cầu chết im lặng), H3 (tạo phiếu vỡ). Cả 3 cùng một lớp: **acceptance không có phép đo nào TRƯỢT khi impl sai**.
Cần owner gật: **M3** (đảo phán quyết "PHÁ VỠ" @S155).

View File

@ -1,13 +1,108 @@
# c1-l3 đo-được + thứ-tự
> LANE 3/3 review cụm-1 (spec B2-r). Lens: **ĐO-ĐƯỢC** (test-case có assert? checklist có lệnh+số?) + **THỨ-TỰ** (commit/mig/restart/deploy/seed/phụ-thuộc-chéo).
> Target chính: `sub-invest-fable-b2-cum1.md` (33.048B) · liếc `spec-4gd-khkk-tong-quat-31-07-2026.md` §③.
> ⚠️ Path lệch trong spec: TASK yêu cầu `sub-reviewer-c1-l3.md`, footer RUN-TRACE ghi `sub-reviewer-2.md` → theo TASK (fix-mới thắng footer-cũ, S139); khai trong report.
> LANE 3/3 review cụm-1 (spec B2-r). Lens: **ĐO-ĐƯỢC** (test có assert? checklist có lệnh+số?) + **THỨ-TỰ** (commit/mig/restart/deploy/seed/phụ-thuộc-chéo).
> Target: `sub-invest-fable-b2-cum1.md` (33.048B) · `spec-4gd-khkk-tong-quat-31-07-2026.md` §③/§④.
> ⚠️ Path lệch trong spec giao việc: TASK yêu cầu `sub-reviewer-c1-l3.md`, footer RUN-TRACE ghi `sub-reviewer-2.md` → theo TASK (fix-mới thắng footer-cũ, S139), khai ở đây.
> **Đo bằng máy trong lượt này:** `sqlcmd (localdb)\MSSQLLocalDB SolutionErp_Dev` (plans=0 · lines=0 · sys.tables=96) · `sha256sum` 3 cặp FE · 9 grep code-tree · 2 đếm trên transcribe.
## Findings
(ĐANG LÀM)
**Tổng: 15 (5 HIGH · 6 MED · 4 LOW) + 5 điểm chịu được soi.**
### HIGH
**H1 · [Q3 thứ-tự] Mig 71 "item 6" (`EndedByLevelFinalize`) KHÔNG có trong mục schema K2.a, và K2 không có phép kiểm nào phát hiện được nó vắng mặt.**
- Evidence: `sub-invest-fable-b2-cum1.md:83-88` (K2.a liệt **5** item cho Mig 71, không có item 6) ⟂ `:181` (K3 khai "gắn vào **Mig 71 item 6** (K2.a)") ⟂ `:242` (thứ-tự commit khai "Mig 71 gồm cả item 6"). K2.h `:167-172` = 0 dòng về cột này; K2 test k=7 `:157-163` = 0 test.
- Hệ quả đo được: implementer dựng Mig 71 **theo đúng K2.a** → thiếu cột → **PASS 6/6 dòng K2.h + 7/7 test K2** → K3 (tự khai "0-mig") kẹt tại commit-time.
- Cột mồ côi (câu hỏi Q3): `:242` chỉ nói "nếu lead tách thì K3 tự mang Mig 72", KHÔNG nói gì nếu K3 **đổi/bỏ** thiết kế duyệt-sớm ⇒ còn lại 1 cột `NOT NULL DEFAULT 0` **0 người đọc** = đúng lớp ghost-wire "defined-but-unread".
- Đề nghị: chuyển item 6 vào chính K2.a (đánh số 6, có Down), thêm 1 dòng K2.h `sqlcmd … SELECT COUNT(*) FROM sys.columns WHERE object_id=OBJECT_ID('ContractSigningPlans') AND name='EndedByLevelFinalize'`**1**, và 1 câu rollback: "K3 bỏ duyệt-sớm ⇒ Mig 72 DropColumn cùng đợt".
**H2 · [Q3 seed/deploy] Khuôn seeder mà K3.a chỉ định là khuôn ĐANG BỊ CỔNG DEMO — bê nguyên thì prod 0 workflow, mà mọi dòng acceptance vẫn xanh trên Dev.**
- Evidence: `sub-invest-fable-b2-cum1.md:183` chọn khuôn `DbInitializer.cs:171-217` = `SeedSampleApprovalWorkflowsV2Async`; hàm đó được gọi tại `DbInitializer.cs:139` **bên trong `if (!demoSeedDisabled)`** (`:135-140`). Prod chạy `DemoSeed:Disabled=true` (comment `:111-118`, bài S91).
- Spec cân nhắc gate **chỉ cho K1** ("Gate: UNGATED", `:35`) và **im lặng cho K3**; ngược lại `:244` khẳng định "prod IIS restart → … seed tự chạy" — sai với seeder demo-gated.
- Acceptance không bắt được: `:230` `sqlcmd … → đúng 8` không nêu DB nào (K1.f `:70` nêu rõ localdb Dev ⇒ mặc định đọc là Dev, nơi `demoSeedDisabled=false`). Test K3-1 gọi seeder **trực tiếp** ⇒ cũng không đi qua cổng.
- Đề nghị: K3.a ghi thẳng "UNGATED, gọi NGOÀI khối `if (!demoSeedDisabled)` (cạnh `SeedRealMasterDataAsync`)" + 1 dòng acceptance đo **trên prod** (`curl /api/approval-workflows-v2?applicableType=10` đếm 8) chứ không chỉ localdb.
**H3 · [Q4 trái khuôn + Q2 checklist mù] Leaf menu đặt NGOÀI `MenuKeys.All` thì `SeedAdminPermissionsAsync` KHÔNG cấp được — leaf tàng hình với MỌI user kể cả Admin; K1 không có dòng nào bắt.**
- Evidence mâu thuẫn nội tại: `:57` vừa khai "`MenuKeys.ContractCatalog` (**NGOÀI `All`**)" vừa khai "grant admin (**SeedAdminPermissionsAsync loop**)". Loop đó là `DbInitializer.cs:2058` `foreach (var menuKey in MenuKeys.All)` ⇒ key ngoài `All` **không bao giờ** được cấp.
- Không có đường cứu: `GetMyMenuTreeQuery.cs:96-99` lọc `CanRead` từ row Permission thật, **không có Admin-bypass trong file**; `Catalogs` KHÔNG phải inherit-root (`:70-73` chỉ Contracts/Workflows/PurchaseEvaluations/PeWorkflows — gotcha #35).
- Tiền lệ bị bê nhầm: `Pe_*` ngoài `All` vì là leaf **sinh động qua factory** (`MenuKeys.cs:142-158`, comment `:150`); còn 4 leaf anh em CÙNG group `Catalogs` thì **đều nằm trong `All`** (`MenuKeys.cs:168`) ⇒ đây là rời khuôn chứ không phải theo khuôn.
- Đối chứng cho thấy đây là lỗi CHECKLIST chứ không phải mơ hồ thiết kế: K3.c `:214` mắc **y hệt** (8 `AwV2_Khkk*` ngoài `All`, trong khi 2 leaf anh em `ApprovalWorkflowDuyetNccV2`/`…PhuongAnV2` nằm trong `All``MenuKeys.cs:182`) **nhưng K3.e `:231` có dòng "sidebar đếm được 8 menu-con"** nên bắt được; K1.f không có dòng tương ứng nên lọt.
- Đề nghị: hoặc đưa key vào `All` (đúng khuôn 4 leaf anh em, chấp nhận +4 policy ⇒ sync 2 row STATUS), hoặc giữ ngoài `All` **kèm** grant tường minh (site riêng như `SeedProcurementMasterAccessAsync`); và thêm dòng K1.f: "đăng nhập admin → sidebar Danh mục **đếm được 5 leaf**".
**H4 · [Q2 acceptance mù] 3 lớp bảo vệ cho "backfill=1 + Σ tiền bất biến" cùng mù trên đúng cái DB mà checklist nêu tên.**
- Đo tươi (lượt này): `SolutionErp_Dev``ContractSigningPlans` = **0 row**, `ContractSigningPlanLines` = **0 row** (`sys.tables`=96).
- (a) `:167` `SUM(PeReferenceAmount)` trước == sau → `NULL == NULL`, đúng với **mọi** migration kể cả migration làm mất số.
- (b) `:168` `COUNT == Σ 8 nhóm``0 == 0`, PASS kể cả khi quên `defaultValue: 1` (EF sinh `defaultValue: 0` ⇒ phiếu thật rơi ra ngoài 8 folder).
- (c) test `Mig71_BackfillsApprovalGroup1_…` (`:157`) **không chạy được như tên gọi**: harness là `SqliteDbFixture` + `EnsureCreated()` dựng từ model (`tests/…/Common/SqliteDbFixture.cs:36-45`), và `grep -rl "Migrat" tests/` chỉ ra **DLL**, 0 file .cs ⇒ **không có migration nào từng chạy trong test suite**. Còn lại nó chỉ assert được default CLR = trùng ngôn (tautology).
- Chuẩn chống-mù đã là chuẩn NHÀ: `ProcurementMasterAccessSeedTests.cs:29-30` ghi rõ "Kèm CONTRAST assert **chống vacuous-pass**".
- Đề nghị: mỗi so-sánh-bằng phải có **control-dương** đứng trước (`SELECT COUNT(*) … > 0` mới có nghĩa), nêu rõ ĐO TRÊN DB NÀO (prod là nơi duy nhất có phiếu thật), và đổi tên/nội dung test (1) + (7) thành model-level hoặc dời sang bước tay có ghi số.
**H5 · [Q1 test không assert + Q3 đo thiếu] Yêu cầu "🔴 PHẢI XỬ site `:342`" chưa chạy phép đo; theo số đo thì phương án (i) là code chết.**
- Đo: `ReturnOrRejectAsync`**đúng 2 call-site**, cả hai truyền **hằng literal**`ContractSigningPlanWorkflowService.cs:128` (`TraLai`) và `:133` (`TuChoi`) — trong `switch` đóng kín `:117` với `default` ném lỗi `:138-139``targetPhase` **không thể** mang giá trị `DaDuyet`.
- Chính file đã khai sẵn: `:36-42`*"service này có **ĐÚNG MỘT** nhánh set DaDuyet … Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`"*. Spec `:105` trích `:39` như bằng chứng NGUY HIỂM nhưng bỏ ngỏ *"nếu nhánh này reachable"* — phép đo tốn đúng 1 lệnh grep.
- Hệ quả: K2 test 6 (`:162`) khai assert *"theo phương án (i)/(ii) đã chọn"***1/17 test không có assert cố định** (chọn lúc code = không phải tiêu chí nghiệm thu). Nếu implementer chọn (i) thì thêm 1 nhánh gọi `ApplyApprovedAmounts` **không bao giờ chạy tới** — vừa là code chết vừa tạo thêm một site trông như đường hợp lệ tới `DaDuyet`.
- Đề nghị: chốt (ii); assert = "gọi transition với mọi action hợp lệ, không action nào đưa Phase về `DaDuyet` ngoài `approve`"; và thay lệnh sweep bằng đúng 2 lệnh mà code tự kê ở `:292-293`.
### MED
**M1 · [Q2 thước hỏng] Lệnh sweep `grep 'Phase\s*=[^=]'` bị giới hạn `--include="ContractSigningPlan*"` — không thể chứng minh chính cái miễn-trừ mà K2.b viện dẫn.**
- Evidence: `:171` (K2.h) "Grep `Phase\s*=[^=]` **toàn `ContractSigningPlan*`**" ⟂ `:106` (K2.b) khẳng định "**seeder/DbInitializer** hiện 0 site gán phase KHKK". File `DbInitializer.cs` không bao giờ khớp glob `ContractSigningPlan*` ⇒ lệnh này mù với đúng lớp file được miễn trừ.
- (Tôi đã đo hộ: `grep -rn "ContractSigningPlanPhase.DaDuyet" src/Backend` ngoài service = **0 hit**; `grep -rn "ContractSigningPlan" DbInitializer.cs` = **0 hit***nội dung* claim ĐÚNG, chỉ *lệnh* không chứng được.)
- Thêm nhiễu: lệnh trả 12 dòng, trong đó 4 dòng không phải assignment (`Features.cs:623` param default · `:173/:245/:341` `var fromPhase = plan.Phase` · `:493-494` DTO changelog); checklist không nêu **số kỳ vọng** ⇒ "liệt đủ site" nghiệm thu được bằng bất kỳ danh sách nào.
- Đề nghị: dùng 2 lệnh repo-wide ở `:292-293` + ghi **số kỳ vọng** (hiện tại: 1 site `DaDuyet`, 1 site helper).
**M2 · [Q4/Q1] Convention `^KHKK-N([1-8])$` bị thoát bằng chính giá trị mặc định UI đang mời.**
- Evidence: `:210` chốt parse suffix Code; nhưng `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:167-170` `DEFAULT_CODE_BY_TYPE[TYPE_INT_CONTRACT_SIGNING_PLAN] = 'QT-KHKK-V2-001'` ⇒ quy trình admin tự tạo mặc định **không khớp pattern** → validator "pass + LogWarning".
- K3 test 4 (`:224`) assert chính lối thoát đó là CỐ Ý (đúng), nhưng DoD `spec §③-K3` (`spec…:26`) hứa "phiếu nhóm 3 pin workflow nhóm 5 → 409" như một bất-biến ⇒ **hứa rộng hơn thứ enforce được**.
- Đề nghị: viết vào DoD "chỉ enforce cho Code seeded `KHKK-N*`; quy trình admin tự đặt tên = ngoài rào (có warn)", hoặc để designer type-10 prefill/ép `KHKK-N{n}` khi đến từ deep-link nhóm.
**M3 · [Q3 seed một-chiều] Seed idempotent kiểu "có Code thì bỏ qua" ⇒ hình dạng sai lần đầu bị ĐÓNG BĂNG, mà acceptance chỉ đo SỐ LƯỢNG.**
- Evidence: `:185` `if (Any(w => w.Code == code)) continue;` (không có đường cập nhật) ⟂ ô ⚠️ chưa chốt "Trưởng/Trường/Trình" `:203` — đúng cái field dễ sai nhất. Acceptance `:230` đo `COUNT = 8`; test shape (`:222`) chỉ chạy trên DB test sạch, không soi prod.
- Đề nghị: giữ PRE-gate verify-LIVE (đang có, tốt) + ghi **đường sửa** (script SQL 1 lần hoặc seeder-corrective có điều kiện) + 1 dòng acceptance đối chiếu **email từng cấp trên prod** sau seed, không chỉ đếm 8.
**M4 · [Q2 thiếu trục] K1 và K2 đều ship FE nhưng 0 dòng acceptance FE; bất-biến mirror đang SỐNG bị bỏ trống.**
- Evidence: K1.d `:56-57` (KIND_CONFIG ×2 app + menuKeys ×2) · K2.f `:149-150` (6 file FE ×2 app) ⟂ K1.f `:69-75` và K2.h `:167-172` = **0 dòng** `npm run build`, 0 dòng so mirror. Dòng chung `:255` còn miễn byte-verify cho "wave BE-only" — K1/K2 **không** BE-only.
- Đo tươi: 3 cặp file KHKK hiện **SHA256 giống hệt** giữa 2 app (`KhkkCreatePage.tsx` ea51e7626eb1 · `KhkkDetailPage.tsx` c8ab2eadd3fd · `types/khkk.ts` 8b757f5b1422) và file tự khai bất-biến này (`fe-user/src/pages/khkk/KhkkCreatePage.tsx:3` "File MIRROR SHA256 identical với fe-admin") ⇒ K2 có thể phá âm thầm.
- Đề nghị: mỗi commit chạm FE thêm 2 dòng: `npm run build` ×2 PASS + `sha256` 3 cặp KHKK **bằng nhau** (khuôn K5 `spec…:30` đã có sẵn tiêu chí này).
**M5 · [Q3 phụ-thuộc chéo NGƯỢC] Cái chặn va-chạm NULL của UNIQUE mới nằm ở cụm-2, và không tái hiện được trên harness.**
- Evidence: `:87` nêu bẫy "NULL == NULL trong UNIQUE của SQL Server" và giao việc chặn cho "validator: line MỚI/SỬA bắt buộc có `CatalogEntryId`" — nhưng validator duy nhất K2 đặc tả (`:118-126`) chỉ kiểm **nhóm**; editor dòng là **K4b (cụm-2)** theo chính `:113`. 0 test + 0 dòng checklist K2 cho "line mới thiếu CatalogEntryId → 400".
- Thêm: SQLite coi các NULL là **phân biệt** trong unique index ⇒ kể cả có test cũng xanh-trên-harness/đỏ-trên-prod.
- Mục "Phụ thuộc chéo" `:246` liệt chiều K2→K1, K3→K2 nhưng **thiếu chiều cụm-2 nợ cụm-1** này.
- Đề nghị: đưa rule "CatalogEntryId bắt buộc cho line mới/sửa" vào K2 (validator + 1 test), hoặc khai limitation kèm đo tay trên SQL Server.
**M6 · [Q1 đếm không ra phủ] K2 test 5 trùng test đã có ⇒ "+17" đạt được mà hành vi mới vẫn không ai đo.**
- Evidence: `:161` `Finalize_FreezesAllLines_NoNullApprovedAmount` ⟂ test đang chạy `tests/…/Application/ContractSigningPlanApprovalTests.cs:475` `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` — bản cũ **mạnh hơn**: assert 0-null (`:505-506`), cách ly liên-phiếu (`:518`, `:528`), và giữ số sửa tay `??=` (`:513` 333→300).
- Đề nghị: bỏ test 5 khỏi k (k=6) hoặc đổi nội dung sang hành vi MỚI (freeze + `EndedByLevelFinalize` + line có `CatalogEntryId`), kẻo con số "+17" mua được bằng trùng lặp.
### LOW
**L1** `:70` acceptance `COUNT(*) WHERE IsDeleted=0` = **86** mâu thuẫn với kịch bản test 2 trên DB đã dùng: chỉ cần 1 lần xoá mềm hợp lệ là số đọc ra 85 và checklist báo FAIL giả. → nêu tiền đề "DB chưa xoá dòng nào", hoặc đo `COUNT(*)` gồm cả xoá mềm.
**L2** `:74` "tên VN+EN đủ **86/86**" không kèm lệnh → thêm `SELECT COUNT(*) … WHERE TenEn IS NULL OR LTRIM(RTRIM(TenEn))=''`**0**.
**L3** Cửa sổ "trang rỗng" giữa commit-1 (`:240`, ship FE kind + leaf menu) và commit-2 (`:241`, seed chờ OG-6): user prod thấy trang danh mục trống trong khoảng thời gian không xác định. Spec chỉ nói "restart local verify bảng rỗng OK". → hoặc ẩn leaf tới khi seed land, hoặc khai chấp nhận.
**L4** 3-file rule: `:69` "git status show 3" yếu hơn bài S161-W1 (2/3 file mig **untracked** trong khi snapshot tracked) và `:250` `git show --stat` chỉ soi được **sau** commit. → thêm cổng ngay trước commit: `git status --porcelain -- src tests | grep '^??'` phải **RỖNG**.
### Chịu được soi (đo rồi, giữ nguyên)
- **P1** Phân bố 86 dòng `5/10/5/23/14/15/13/1` (`:41`, `:70`, `:251`) **khớp transcribe**: đếm được 85 mã `[A-C][1-4]-\d\d` + 1 `CEO-01`; heading khai A1=5 · A2=1 + A3=9 → N2=10 · A4=5 · B1=23 · B2=14 · B3=15 · C1=6+C2=3+C3=4 → N7=13 · CEO=1. Cộng = **86**, khớp cả 2 chiều.
- **P2** `96 → 97 bảng` (`:31`, `:250`) đúng: `sys.tables` Dev đo được **96**; baseline test **590** khớp `docs/STATUS.md:6` ⇒ "+17 = 607" là con số có gốc.
- **P3** Bước verify-LIVE đội hình (`:229`) **đo được thật**: `GET /api/approval-workflows-v2` trả `AwLevelDto` gồm `ApproverUserId` + FullName + `ApproverEmail` (`ApprovalWorkflowV2AdminFeatures.cs:25-31`, projection `:189-195`) ⇒ đối chiếu 11 email là khả thi bằng curl.
- **P4** `AllowApproverFinalize` có thật ở cấp Level (`ApprovalWorkflow.cs:158` + `ApprovalWorkflowConfiguration.cs:100` default false) ⇒ khẳng định "0 mig cho cờ này" (`:180`) đứng vững.
- **P5** 5 test có assert 2 chiều/biên rõ ràng, xoá đi là mất phủ thật: K1-2 (`:62` no-resurrect), K1-4 (`:64` biên 0/9 fail + 1/8 pass), K3-2 (`:222` shape từng cấp), K3-4 (`:224` 3 chiều), K3-5 (`:225` multi-assert đường duyệt-sớm).
### Trả lời gọn 4 câu hỏi
- **Q1 (17 test):** 12/17 có assert cụ thể và sẽ ĐỎ nếu impl sai. Hỏng: K2-1 (không chạy được — H4c), K2-6 (không có assert cố định — H5), K2-7 nửa vế "AcrossMig" (H4c), K2-5 trùng test cũ (M6), K3-1 không đi qua cổng demo nên không bắt được H2. Thiếu hẳn test cho: cột `EndedByLevelFinalize` (H1), "line mới thiếu CatalogEntryId" (M5), "leaf menu render được" (H3).
- **Q2 (checklist):** phần lớn dòng có {lệnh · số}, nhưng **3 dòng PASS được bằng impl SAI**: K2.h Σ-tiền + COUNT-nhóm (H4a/b, đo 0-row), K1.f không có dòng nào bắt leaf tàng hình (H3), K2.h grep sai phạm vi (M1). Thiếu trục FE hoàn toàn ở K1/K2 (M4).
- **Q3 (thứ tự):** khung K1→K2→K3 hợp lý và cửa sổ config-lie #78 **KHÔNG mở ở mức commit** (seed + nhánh (a) + gỡ khoá FE cùng commit 4 — đúng lệnh `spec…§④ OG-3`). Lệch nằm ở: item-6 lọt khỏi K2.a (H1), khuôn seeder demo-gated (H2), backfill=1 đi bằng `defaultValue` trong Mig (đúng khuôn, nhưng không có phép kiểm còn răng — H4), 3-file rule đo sau commit (L4), và phụ thuộc chéo thiếu chiều cụm-2→cụm-1 (M5) + thiếu điều khoản cột-mồ-côi (H1).
- **Q4 (chế thêm trái khuôn):** 1 chỗ rời khuôn thật = leaf ngoài `MenuKeys.All` trong khi 4 leaf anh em cùng group đều ở trong (H3, lặp lại ở K3.c). 1 chỗ tạo quy ước mới = parse Code lấy nghĩa nghiệp vụ (M2) — chấp nhận được nếu khai giới hạn. Các quyết còn lại (mirror `CatalogsController` `[Authorize]` lớp + `[Authorize(Roles="Admin")]` per-write — đo khớp `CatalogsController.cs:13-14` + 12 action `:23…:130`; loose-Guid không FK; tái dùng `ApprovedAmount` thay chép 11 cột PE Mig 67) đều **bám khuôn**, không chế thêm.
## Verdict
(ĐANG LÀM)
**PASS-WITH-BLOCKERS** — nền spec chắc (khuôn đúng, 3 con số gốc kiểm được, 12/17 test có răng), nhưng **KHÔNG land như hiện trạng**: H1 + H2 + H3 mỗi cái đều để một impl SAI đi trọn checklist mà vẫn xanh, H4 làm 3 lớp bảo vệ tiền/backfill cùng mù, H5 mời implementer viết code chết. Chặn tối thiểu: vá **H1, H2, H3** trước commit K1; vá **H4, H5** trước commit K2/K3.
END sub-reviewer-c1-l3 — TOTAL: 15 finding (5 HIGH · 6 MED · 4 LOW) + 5 điểm giữ.