# LENS-1 CORRECTNESS — review đối-kháng spec SOLTUON 12-08-2026 (YC-029)

Reviewer lane-1/3 (ensemble /fable-clone) · lăng-kính **ĐÚNG-SAI KỸ THUẬT** của spec W1+W2 (+W3/W5 khi
chạm trục correctness). Mọi claim kèm `file:line` đo tại HEAD `1c677fb7`, cây làm việc 2026-08-12.

## Verdict

**FAIL — 11 findings (2 BLOCKER · 4 MAJOR · 3 MINOR · 2 xác-nhận-dương).**

Không phải FAIL về **hướng**: 5 điều chỉnh đúng ý owner, và chẩn-đoán root-cause A + B của invest
**tôi đo lại thì ĐỨNG** (F-10, F-11). FAIL là về **tính thực-thi-được của chữ trong spec**: hai mệnh
đề nền của W1 và W3 là **SAI SỰ THẬT về code hiện tại**, và nếu implementer làm đúng y chữ thì:

- **W1** đẻ trang "Luồng duyệt" **trắng / hiện nhầm dữ liệu Duyệt NCC** (F-1);
- **W3** đẻ danh sách "Đang duyệt" **hiện TOÀN BỘ HĐ của loại đó** mà vẫn xanh mọi acceptance (F-2).

Cả hai đều là hạng **im lặng** — build xanh, grep xanh, chỉ người dùng mới thấy. Sửa 2 mệnh đề +
nới W2 scope (F-3) + siết 3 ô acceptance (F-4, F-6, F-8) là spec chạy được.

---

## Findings

### 🔴 F-1 (BLOCKER, W1) — "trang tự suy applicableType=3 theo path, khuôn alias-path khkk sẵn" là **SAI**. Bỏ `type=3` khỏi URL ⇒ trang rơi về fallback `type=1` (Duyệt NCC).

Spec `②` dòng 37-38 và `③ W1` dựa trọn trên mệnh đề này. Đo thực tế:

- `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx:56-61`:
  ```tsx
  const { pathname } = useLocation()
  const rawType = Number(searchParams.get('type'))
  const typeInt = rawType === 1 || rawType === 2 || rawType === 3 || rawType === 10 ? rawType : 1
  ```
  `pathname` **KHÔNG** tham gia suy `typeInt` — comment `:54-55` tự khai công dụng của nó: *"link 'xem
  tất cả' phải bám đường dẫn ĐANG đứng"*. Đó là thứ duy nhất `pathname` làm.
- "Khuôn alias-path khkk" **không phải path-inference**: alias KHKK vẫn mang type trên QUERY —
  `Layout.tsx:86` `Khkk_WfView: '/khkk/workflow-matrix?type=10&group=1'` và `:216`
  `/khkk/workflow-matrix?type=10&group=${group}`; route alias `App.tsx:83`. Tức khuôn thật là
  **"path alias + type vẫn ở query"**, đúng ngược với thứ spec viện dẫn.
- Hệ quả số học: bỏ `type` ⇒ `searchParams.get('type')` = `null` ⇒ `Number(null)` = **0** ⇒ 0 ∉ {1,2,3,10}
  ⇒ `typeInt = 1` ⇒ `queryKey ['workflow-matrix', 1]` + `params { applicableType: 1 }` (`:63-71`) ⇒ trang
  tải **quy trình Duyệt NCC**. Nhãn header cũng đổi theo: `typeLabel = summary?.applicableTypeLabel`
  (`:76`) ⇒ "Luồng duyệt — Duyệt NCC".
- Cộng thêm nhánh lọc `ct` mà chính W1 thêm (`code === 'HD-<token>'`): bộ dữ liệu ApplicableType=1
  không có mã nào bắt đầu `HD-` ⇒ **0 dòng ⇒ trang trắng**.

**Không đủ để diệt bug, và còn mở đường mới:** kể cả khi trang đọc `ct` đúng, việc bỏ `type` làm
`useEffect` accordion `Layout.tsx:473` `if (!typeParam) return` **thoát sớm** ⇒ deep-link/refresh thẳng
vào `/contracts/workflow-matrix?ct=5` **không mở nhóm 05** ⇒ leaf sáng nhưng **nằm trong nhóm đang
đóng** — đúng triệu chứng "menu không ăn khớp" mà Đ3 muốn diệt.

**Đề nghị:** chọn MỘT trong hai, viết thẳng vào spec (đừng để implementer đoán):
(a) **Giữ `type=3` trên URL** + chỉ sửa accordion (đọc `ct` trước, xem F-5) — rẻ nhất, 0 đụng trang matrix;
(b) **Thật sự thêm path-inference** vào `WorkflowMatrixViewPage`: `pathname.startsWith('/contracts/') → 3`,
`'/khkk/' → 10`, `'/purchase-evaluations/' → query`, và khai rõ đây là **code MỚI**, kèm ô acceptance
riêng cho KHKK (`/khkk/workflow-matrix?type=10&group=1` **không được** đổi hành vi).

---

### 🔴 F-2 (BLOCKER, W3) — "BE param `pendingMe` sẵn" là **SAI** cho phía Hợp đồng. Param lạ bị ASP.NET nuốt im lặng ⇒ "Đang duyệt" hiện TOÀN BỘ HĐ của loại.

Spec `②` dòng 45-46: *"`/my-contracts?type=N&pendingMe=1` (khuôn PE `:176`; **BE param `pendingMe` sẵn**)"*.
Invest cũng khẳng định vậy (`sub-invest-b1-fable.md:84`). Đo:

- `grep -rn "pendingMe\|PendingMe" src/Backend/SolutionErp.Application/Contracts/ src/Backend/SolutionErp.Api/Controllers/ContractsController.cs` → **0 hit**.
- **Control-dương (chứng thước không hỏng):** cùng lệnh trên `src/Backend` trả 4 hit ở họ KHKK —
  `ContractSigningPlansController.cs:42` `[FromQuery] bool pendingMe = false`,
  `ContractSigningPlanFeatures.cs:1219` `bool PendingMe = false`, `:1255` `if (request.PendingMe)`.
  ⇒ grep chạy đúng; phía Contract **thật sự không có**.
- Chữ ký thật của endpoint list (`ContractsController.cs:20-27`): `phase · supplierId · projectId ·
  type · page · pageSize · search · sortDesc` — hết.
- **Cơ chế hỏng im lặng:** model-binding ASP.NET Core **bỏ qua** query param không khai ⇒
  `GET /contracts?type=1&pendingMe=1` = `GET /contracts?type=1` ⇒ trang "Đang duyệt" liệt **mọi** HĐ
  loại 1 (kể cả Nháp, Đã phát hành), 200 OK, 0 lỗi, 0 warning.
- **Khuôn PE mà spec viện dẫn cũng không phải BE-param:** PE là **FE đổi ENDPOINT** —
  `PurchaseEvaluationsListPage.tsx:39` `const pendingMe = sp.get('pendingMe') === '1'` → `:91` `if (pendingMe) { … }`
  (nhánh gọi endpoint inbox riêng). Phía BE PE cũng 0 hit `pendingMe`.

**Đường đi đúng cho HĐ (và ranh của nó):** `GET /contracts/inbox` (`ContractsController.cs:41-43`) trả
`List<ContractListItemDto>` **không phân trang** và **không nhận `type`** ⇒ FE phải lọc `type` client-side.
Ở đây lọc client **chấp nhận được** (khác ca W7-S187: nhánh `/contracts` bị cắt 100 dòng nên lọc client
làm loại hiếm biến mất; inbox trả trọn, không cắt) — **nhưng phải viết ra trong spec**, vì nó là ngoại-lệ
của một luật đã có, không phải mặc định.

**Đề nghị:** đổi W3 thành *"MyContractsPage: `pendingMe=1` ⇒ gọi `/contracts/inbox` rồi lọc `type`
client-side (inbox không phân trang — nêu rõ lý do được lọc client)"*, HOẶC nhận W3 là **wave BE** (thêm
`pendingMe` vào `ListContractsQuery` + tái dụng logic match approver V2/V1 của `GetMyInboxQuery`). Không
được để câu "BE đã sẵn".

---

### 🟠 F-3 (MAJOR, W2) — Dời cây sang trái **làm mất danh-tính per-loại** của 7 leaf "Danh sách" — mâu thuẫn thẳng với Đ2+Đ3. Khuôn KHKK mà spec viện dẫn **có** cơ chế giữ danh-tính, spec **bỏ sót** đúng cơ chế đó.

- Hôm nay panel-1 = danh-sách **lọc theo `type` ở SERVER** (`MyContractsPage.tsx:34,50-57`; W7-S187 cố ý
  gỡ lọc client vì >100 HĐ thì loại hiếm biến mất im lặng).
- `PipelineTreePanel` **không có trục loại HĐ**: props ở `:94-128` = `currentStage · stage2Options ·
  hideHeader · hideSearch …`; cây dựng theo Năm → Dự án → Hạng mục (`:24-32`). ⇒ thay panel-1 bằng cây
  thì **cả 7 leaf "Danh sách" render y hệt nhau** — đúng thứ mà chính repo gọi là *"mục menu nói dối tên nó"*
  (`WorkflowMatrixViewPage.tsx:80`).
- **Khuôn KHKK KHÔNG bị bệnh này vì nó có lát cắt:** `KhkkListPanel.tsx:200,216` nhận prop `group`, đẩy
  xuống server (`:253` `group: group ?? undefined`) **và** cắt nội dung cây (`:281`
  `src.filter(k => k.approvalGroup === group)`, `:295`, `:323`) — bơm vào cây qua `stage2Content`
  (`PipelineTreePanel.tsx:111` `Pick<BuildStagesOptions,'stage2Content'|'stage2Count'|'onOpenKhkk'|'onOpenPe'>`).
- Bên GĐ3 **chưa có gì tương ứng**: `grep "opts\." fe-user/src/hooks/usePipelineStages.ts` chỉ ra
  `stage1Content/stage1Count` (`:212-213`), `onOpenPe` (`:228-229`), `onOpenKhkk` (`:244`),
  `stage2Content/stage2Count` (`:303-306`) — **`stage3Content` 0-hit**.

⇒ W2 thật sự cần **4 món** chứ không phải 2 như spec liệt: `BuildStagesOptions` + `stage3Content` +
`stage3Count` + `onOpenContract`, **và** prop `stage3Options` trên panel (`Pick<…>` phải kể tên từng key —
quên tên nào thì opt-in **chết câm**, đúng lớp "guard dựng mà không ai gọi"), **cộng** quyết định
`?type=N` cắt cây thế nào. Đây là 2 file **MIRROR SHA-identical ×2 app** nên mọi sai shape nhân đôi.

**Đề nghị:** spec phải trả lời dứt khoát: *"panel-1 ở `/my-contracts?type=N` hiển thị cây **đã cắt theo
ContractType N** (leaf GĐ3 lọc `c.type === N`), hay cây toàn-cảnh không cắt?"* — và thêm ô acceptance
đối-chứng: đứng `?type=1` và `?type=5`, hai cây **phải khác nhau** (nếu chọn cắt), hoặc khai thẳng
"cố ý giống nhau" như một quyết-định owner (khuôn QĐ-2 KHKK).

---

### 🟠 F-4 (MAJOR, W1 acceptance) — Ô ✅(iii) **đo hình-dạng, không đo giá-trị**: trang TRẮNG vẫn qua.

Nguyên văn: *"WfView `ct=1` chỉ hiện họ `HD-TP` (0 dòng QT-HD-V2-001/loại khác)"*. Ca hỏng F-1 sinh ra
**0 dòng tất cả** ⇒ mệnh đề "0 dòng của loại khác" **ĐÚNG** ⇒ gate xanh trên đúng ca hỏng nặng nhất.
Ô (iv)/(v) là grep + build, cũng xanh. ⇒ **cả W1 có thể tick trọn trong khi tính năng chết.**

**Đề nghị** thay bằng 3 vế đo được: (1) **≥1 dòng** và mọi dòng có `code` bắt đầu `HD-TP`;
(2) header đọc **"Luồng duyệt — Hợp đồng"** (không phải "Duyệt NCC") — bắt đúng ca fallback `typeInt=1`;
(3) control-âm: `?ct=5` phải ra tập **KHÁC** `?ct=1`.

---

### 🟠 F-5 (MAJOR, W1) — "Loại trừ pathname `/contracts/workflow-matrix`" là **danh-sách-đen**; case anh em `Hdc_*` đang dính đúng bug đó và vẫn sống sau khi vá.

- Máy hiện tại (`Layout.tsx:470-485`): mọi pathname **không** bắt đầu `/purchase-evaluations` đều bị đọc
  `?type=` **như ContractType** (`INT_TO_TYPE_CODE`, `:16-29`) rồi set accordion (1 slot ⇒ mutex, `:467`).
- **Case anh em còn sống:** `Hdc_*` GĐ4 — `Layout.tsx:70-76` `'/hard-copies?type=1'`…`'?type=7'`. Bấm
  "Hợp đồng cứng → Thầu phụ" ⇒ nhánh else ⇒ **mở nhóm "01. HĐ Thầu phụ"** của cây menu khác. Cùng
  lớp bug A, **không** nằm trong tập vá của spec.
- `Khkk_WfView` (`:86,:216`, `type=10`) thoát **do may**: `INT_TO_TYPE_CODE[10]` = `undefined` ⇒ guard
  `if (code)` `:483` chặn. Không phải do thiết kế.

**Đề nghị:** đổi hình dạng bản vá từ **đen** sang **trắng**: (1) đọc `ct` TRƯỚC (mọi pathname), có `ct`
hợp lệ ⇒ dùng nó, dừng; (2) chỉ khi vắng `ct` mới đọc `type`, và **chỉ trên allowlist** pathname mà
`type` = ContractType (`/my-contracts`, `/contracts/new`, `/inbox`, `/hard-copies`). Ô acceptance kèm:
bấm leaf `Hdc_ThauPhu` **không** làm đổi trạng thái accordion `Ct_*`.

---

### 🟠 F-6 (MAJOR, W5 test-matrix) — "V1 `Phase>=DangInKy` VẪN chặn" là câu **nhập nhằng**: V1-flat cũng đứng ở `ChoDuyet=10` (≥5). Phân nhánh phải theo **cờ pin**, không theo **số phase**.

- Guard thật: `ContractFeatures.cs:897` `if (entity.Phase >= ContractPhase.DangInKy) throw ConflictException(...)`,
  `DangInKy = 5` (`ContractPhase.cs`).
- Bảng số: `DaPhatHanh=9` · `ChoDuyet=10` · `TraLai=98` · `TuChoi=99` — **tất cả ≥ 5** ⇒ đều bị chặn hôm nay
  (đúng finding S160).
- 🔴 Điểm nhập nhằng: **V1-flat (Mig 21) cũng dùng `ChoDuyet=10`** làm trạng thái trung gian
  (`ContractPhase.cs` comment tự khai: *"ChoDuyet = 10 — generic intermediate, CurrentWorkflowStepIndex
  tracking"*). ⇒ nếu bản vá nới theo **tập phase** thì nó nới **cả V1-flat** mà vẫn tick được ô
  "V1 Phase>=DangInKy vẫn chặn" (vì ô đó chỉ nói tới 5..9). Đúng lớp gotcha **#42** (dual-schema
  branch phải theo field pin: `ApprovalWorkflowId != null` ⇒ V2, ngược lại V1).
- Ô test còn **thiếu 2 ca**: **V2 @ `DaPhatHanh=9`** (terminal thành công + đã gen mã HĐ — phải VẪN cấm
  xóa) và **V1-flat @ `ChoDuyet=10`** (giữ cấm hay mở? owner phải chốt).

**Đề nghị:** viết acceptance theo **ma trận (schema × phase × actor)**, mỗi ô một dòng, và nêu
discriminator là `ApprovalWorkflowId`.

---

### 🟢 F-7 (GỠ NỢ — trả lời câu lead treo ở `②` dòng 62-66) — `db.Contracts.Remove` **ĐÃ LÀ soft-delete**. W5 không cần đổi đường xóa.

- `AuditingInterceptor.cs:54-62`: mọi `AuditableEntity` ở `EntityState.Deleted` bị **lật** thành
  `Modified` + `IsDeleted = true` + `DeletedAt` + `DeletedBy`.
- Handler tự khai `// ========== DELETE (soft) ==========` (`ContractFeatures.cs:885`), và màn "Đã xóa"
  đọc được chính là hệ quả (`ContractsController.cs:34-40` `/contracts/deleted`).

⇒ Bỏ hạng mục *"xác minh soft-vs-hard-delete"* khỏi W5 ✅(i), hoặc hạ xuống **1 assert trong test**
(xóa xong: row còn tồn tại, `IsDeleted == true`). Đây là chỗ spec **rộng hơn việc thật** — cắt được.

---

### 🟡 F-8 (MINOR, W1) — Sau W1, `ct` từ **trang trí** thành **danh tính**, nhưng chưa ai định nghĩa `ct` rác.

`grep "get('ct')" fe-user/src fe-admin/src` = **0 hit** ⇒ hôm nay `ct` chỉ được **ghi** (`Layout.tsx:151`)
để phân biệt active-state, **không ai đọc**. Khi trang bắt đầu đọc nó, cần clamp y khuôn `group`
(`WorkflowMatrixViewPage.tsx:84-86`: regex `/^[1-8]$/` + *"group rác/thiếu ⇒ BỎ QUA hoàn toàn… thà hiện
đủ còn hơn lọc theo một con số vô nghĩa rồi ra trang trắng"*). Spec nên nói thẳng: `ct ∉ 1..7` ⇒ **không
lọc** (hiện đủ), không phải lọc-ra-rỗng.

### 🟡 F-9 (MINOR, W1) — Bookmark cũ mất highlight (dữ liệu vẫn đúng).

URL đã lưu `/contracts/workflow-matrix?type=3&ct=1`, leaf mới trỏ `?ct=1`. `queryMatches:368-374`: `type`
**không** thuộc `TRANSIENT_QUERY_KEYS` (`:350`) ⇒ không ignorable ⇒ `aKeys=[ct,type]` (2) ≠ `bKeys=[ct]` (1)
⇒ **không sáng**. Không mất chức năng (miễn trang vẫn chấp nhận `type` — xem F-1 phương án (a)/(b)),
nhưng nên có 1 dòng "backward-compat: trang nhận CẢ 2 dạng URL".

### 🟡 F-10 (MINOR, W2 acceptance — xác nhận DƯƠNG) — Baseline mirror **đang sạch**, nên ô SHA có nghĩa.

Đo tại HEAD: `usePipelineStages.ts` `1b9b5c611663666d3c8853a88b020d57b712c3cfd9bb524790bcd0359152ff48`
(fe-user ≡ fe-admin) · `PipelineTreePanel.tsx`
`4ffc96beded5788a7463efa0e7df405c217a5d60ef7f5ca1ce83f323c4c6c334` (fe-user ≡ fe-admin). ⇒ ô ✅(iv) của
W2 không phải "gate hỏng sẵn"; nếu lệch sau wave thì đúng là lỗi của wave.

### 🟢 F-11 (XÁC NHẬN DƯƠNG) — Root-cause A + B của invest **ĐỨNG**; cơ chế `view=approved` **thật sự** diệt 2-leaf-cùng-sáng; `phase=9` **vẫn là** bộ lọc đúng.

- **A dựng lại được**: `Layout.tsx:151` sinh `?type=3&ct=N` → `:470-485` nhánh else (pathname không phải
  `/purchase-evaluations`) → `INT_TO_TYPE_CODE[3] = 'NhaCungCap'` (`:16-29`) → `setExpandedCtCode` mà state
  chỉ có **1 slot** (`:467`) ⇒ nhóm đang mở bị đóng. Chuỗi HELD.
- **B — kiểm bằng tay theo đúng luật `queryMatches`**: đứng `?type=1&phase=9&view=approved`:
  leaf "Đã duyệt" (đích ghim `type,phase,view`) ⇒ `phase` **được pin nên so nghiêm** ⇒ khớp ✓;
  leaf "Danh sách" (đích `type`) ⇒ `phase` bị bỏ qua **nhưng `view` KHÔNG thuộc TRANSIENT** (`:350`) ⇒
  `aKeys=[type,view]` (2) ≠ `bKeys=[type]` (1) ⇒ **tắt** ✓. Đúng như PE `:180-185`.
- `phase=9` vẫn đúng nghĩa sau S188: `ContractPhase.cs` comment tự khai *"last step done ──►
  DaPhatHanh (terminal + gen mã HĐ)"* ⇒ HĐ duyệt xong V2 rơi vào **9**, không phải một mã khác. Ô lọc
  không mục ruỗng.

---

## Đề nghị sửa spec (theo thứ tự chặn)

1. **W1 §② dòng 37-38** — xoá mệnh đề *"trang tự suy applicableType=3 theo path, khuôn alias-path khkk sẵn"*.
   Thay bằng phương án (a) **giữ `type=3`, chỉ sửa accordion** *hoặc* (b) **thêm path-inference như code MỚI**
   + ô acceptance không-hồi-quy cho `/khkk/workflow-matrix?type=10`.
2. **W3 §② dòng 45-46** — xoá *"BE param `pendingMe` sẵn"*. Chốt đường: FE đổi endpoint sang
   `/contracts/inbox` + lọc `type` client (kèm 1 câu vì sao lọc client được chấp nhận ở đây: inbox
   **không phân trang**), hoặc nâng W3 thành wave BE.
3. **W2 §② dòng 33-34** — nới scope: `stage3Content` + `stage3Count` + `onOpenContract` + prop
   `stage3Options` (liệt **từng key** trong `Pick<>`), và **chốt** cây có cắt theo `?type=N` hay không.
   Sửa luôn ký hiệu: analog thật ở hook là **flat** `opts.onOpenKhkk` (`:244`) — `stage2Options` là tên
   **prop của panel** (`PipelineTreePanel.tsx:97,111`), hai tầng khác nhau, spec đang trộn.
4. **W1 ✅(iii)** — thêm "≥1 dòng" + "header = nhãn Hợp đồng" + control-âm `ct=5` (F-4).
5. **W1 accordion** — đổi từ blacklist-pathname sang **`ct` trước / allowlist `type` sau**, + ô acceptance
   `Hdc_*` không đụng accordion `Ct_*` (F-5).
6. **W5 ✅(i)** — ma trận (schema × phase × actor), discriminator = `ApprovalWorkflowId` (gotcha #42);
   thêm ca **V2@DaPhatHanh=9 vẫn cấm** và **V1-flat@ChoDuyet=10** (owner chốt); **bỏ** hạng mục xác minh
   soft-vs-hard (đã đóng ở F-7, hạ thành 1 assert).
7. **W1** — thêm 1 dòng clamp `ct ∉ 1..7 ⇒ không lọc` (F-8) và 1 dòng backward-compat URL cũ (F-9).

---

### Ghi chú phạm vi

Lane này **không** chấm 3 trục: văn phong/UX (lane khác), phân quyền/authz, và deploy — spec tự khai
DEPLOY CHỜ nên không có claim prod để curl. Không đo `dotnet build`/`npm run build` (spec là **đề xuất**,
chưa có diff). Mọi số liệu trên đo tại cây làm việc `main @ 1c677fb7`.
