# c1-l3 đo-được + thứ-tự

> 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

**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` có **đú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

**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ữ.
