REVIEW-F1: PASS-WITH-FLAGS 7 — 3 MAJOR (G-1 default ngược PE-live · G-2 sơ đồ nói CEO "đã duyệt" · G-6 cây phình 5→7 file, K4a bị commit ké) / 4 MINOR; 0 blocker kỹ-thuật trên 5 file F-1, 1 blocker quy-trình (G-6: tách commit trước khi push) # Reviewer F-1 — ô-tích opt-out `applyLevelFinalize` panel duyệt KHKK Ghi-đĩa TRONG LÚC SOI (chống #53). Đo từ ĐĨA, HEAD `24ea71e`, diff CHƯA commit. Phạm vi: `git diff -- src fe-user fe-admin` = **5 file M, +137 / −14**. ## §0 Mirror 2 app — chứng bằng blob-SHA của git (KHÔNG tin lời khai) `git diff` in ra index-line của cả 2 app: | Cặp file | blob TRƯỚC | blob SAU | |---|---|---| | `fe-{admin,user}/src/pages/khkk/KhkkWorkflowPanel.tsx` | `e2fa3bc` (cả hai) | `6913182` (cả hai) | | `fe-{admin,user}/src/types/khkk.ts` | `a99658c` (cả hai) | `1ffd79b` (cả hai) | ⇒ 2 cặp **byte-identical cả trước lẫn sau** (git blob = sha1 nội dung) — mirror §3.9 ĐẠT, và không có nhánh "sửa 1 app quên app kia". ✅ ## §1 Ranh giới BE — cờ có tới FE bằng ĐÚNG một đường - `ContractSigningPlanWorkflowLevelDto` (`ContractSigningPlanFeatures.cs:116`) +1 positional `bool AllowApproverFinalize`; **construction site = 1** (`:640`, grep toàn `src/Backend` cho đúng 3 hit: `:116` def · `:132` kiểu List · `:640` new) ⇒ không có site thứ 2 lỡ truyền `false` cứng. - `l.AllowApproverFinalize` đọc thẳng từ `ApprovalWorkflowLevel` của **đúng workflow đã pin** (cùng bảng, cùng row mà `ResolveActingLevel` sẽ chấm lúc duyệt) ⇒ không có nguồn thứ hai. - 0 đổi logic BE: `git diff` trên `src` chỉ 2 hunk, cả 2 nằm trong DTO + projection. ✅ ## §2 (đề bài 1) `actingLevel` FE có mirror ĐÚNG `ResolveActingLevel` không BE (`ContractSigningPlanWorkflowService.cs:463-468`): `own = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)` → `if (own is not null) return own;` → `if (isAdmin) return pendingLevelGroup.First();` → còn lại **ném Forbidden**. FE (`KhkkWorkflowPanel.tsx:135-137`): `currentLevels.find(l => l.approverUserId === user?.id) ?? (isAdmin ? currentLevels[0] : undefined)`. | Ca | BE chấm level nào | FE chấm level nào | Khớp? | |---|---|---|---| | Actor ∈ Cấp, cấp CÓ cờ | own (có cờ) | own (có cờ) → hiện ô-tích | ✅ | | Actor ∈ Cấp OR-of-N, **cờ ở NGƯỜI KHÁC cùng Cấp** | own (KHÔNG cờ) ⇒ không finalize | own (KHÔNG cờ) ⇒ ẩn ô-tích, gửi `true` = no-op | ✅ (ca đề bài hỏi kỹ — không rò) | | Admin **có** slot riêng trong Cấp | own | own (vì `find` chạy TRƯỚC nhánh isAdmin) | ✅ | | Admin **không** có slot | `pendingLevelGroup.First()` | `currentLevels[0]` | ⚠️ xem §6 (thứ tự 2 truy vấn) | | Không phải approver, không Admin | **Forbidden 403** | `undefined` ⇒ ẩn ô-tích | ✅ (và nút Duyệt đã `disabled` bởi `blockedByLevel` `:130`) | | `currentLevels` **RỖNG** (phase ≠ ChoDuyet, hoặc con-trỏ null, hoặc idx ngoài biên) | không tới `ApproveV2Async` (409 `:250-251`) | `find`→undefined, `currentLevels[0]`→`undefined` ⇒ eligible=false | ✅ **không nổ** (index-out-of-range trên mảng rỗng trong JS = `undefined`, không throw) | | `user` chưa nạp (`user?.id` undefined) | — | `find` không khớp (`approverUserId` là `string` non-null) ⇒ undefined; `isAdmin`=false ⇒ nút Duyệt disabled | ✅ | Vế **so-khớp GUID dạng chuỗi**: `user.id` sinh từ `res.data.user` (`AuthContext.tsx:53-56`, DTO login serialize `Guid` → chữ thường có gạch), `approverUserId` cũng là `Guid` serialize cùng kiểu ⇒ `===` hợp lệ; và đây là **cùng phép so đã sống từ W3** (`:127` `actorIsCurrentApprover`), không phải phép mới. ⇒ **Mirror ĐÚNG 6/7 ca**; ca thứ 7 (Admin duyệt-thay trên Cấp **trộn cờ**) là finding §6/G-4. ## §3 (đề bài 2) Body — rò field ở action khác? khớp hợp-đồng `bool?`? `KhkkWorkflowPanel.tsx:151-157`: `{ action, comment, ...(a === Approve ? { applyLevelFinalize: eligible ? state : true } : {}) }` | Action | Key có trong JSON? | Giá trị | BE nhận | Ảnh hưởng | |---|---|---|---|---| | `submit` | **KHÔNG** | — | `ApplyLevelFinalize = null` → `?? true` (`Controller:213`) | `SubmitAsync` **không nhận tham số** (`Service:122`) ⇒ vô hại | | `return` / `reject` | **KHÔNG** | — | như trên | `ReturnOrRejectAsync` **không nhận tham số** (`:130-136`) ⇒ vô hại | | `approve`, cấp thường | CÓ | `true` | `true` | `actingLevel.AllowApproverFinalize && true` = **false** ⇒ no-op (§4) | | `approve`, cấp có cờ, giữ tick | CÓ | `true` | `true` | finalize (đúng ý người bấm) | | `approve`, cấp có cờ, bỏ tick | CÓ | `false` | `false` | rơi xuống advance thường ⇒ trình tiếp (đúng mục tiêu F-1) | ⇒ **0 rò field** sang action khác; và ngay cả khi rò cũng không có chỗ đọc ở BE (3 nhánh kia không có tham số). **absent vs null vs true** — hợp-đồng `ContractSigningPlanTransitionBody(string Action, string? Comment = null, bool? ApplyLevelFinalize = null)`: key vắng ⇒ System.Text.Json để `bool?` = `null` (bằng ĐÚNG giá trị mặc định khai báo, nên **không phụ thuộc** vào chuyện STJ có tôn trọng default-parameter-value của positional record hay không — cả hai đường đều ra `null`) ⇒ `?? true`. FE **không bao giờ** gửi `null` tường minh (spread bỏ hẳn key, không set `undefined`). ⇒ 3 trạng thái absent/null/true hội tụ về CÙNG một hành vi. ✅ Khớp hợp-đồng. ## §4 (đề bài 3) Regression cấp thường + rò ô-tích ở phase khác **Cấp thường (không cờ), trước ⟂ sau vá:** - trước: body `{action, comment}` ⇒ BE `?? true` ⇒ `applyLevelFinalize=true` - sau: body `{action, comment, applyLevelFinalize: true}` ⇒ BE `true` ⇒ **cùng một giá trị đi vào cùng một biểu thức** `if (actingLevel.AllowApproverFinalize && applyLevelFinalize)` (`Service:276`), mà vế trái = `false` ⇒ nhánh không vào ở CẢ HAI thế giới. Hành vi **y hệt**, không phải "giống về mặt cảm tính". ✅ (Điểm yếu còn lại là của thiết kế BE, không phải của diff: no-op này chỉ đúng vì BE **AND** với cờ cấp — FE gửi `true` cho người không có quyền finalize là dữ liệu thừa, không phải quyền thừa.) **Rò ô-tích ở phase khác — 3 lớp chặn ĐỘC LẬP, hỏng 1 lớp vẫn không rò:** 1. `currentLevels` chỉ khác `[]` khi `isWaiting` (`:118-121`) ⇒ DaDuyet/TraLai/TuChoi/Nháp ⇒ `actingLevel=undefined` ⇒ `approverFinalizeEligible=false`. 2. Nút "Duyệt" (đường DUY NHẤT đặt `action=Approve`) chỉ render trong `{isWaiting && (…)}` (`:312-335`). 3. Chính JSX ô-tích đòi `action === Approve` (`:443`). ⇒ **0 rò**. Kiểm chéo: `KhkkTransitionAction.Approve` được gán ở đúng 1 site (`:323`), grep 2 app cho thấy không có site thứ hai. ✅ **Reset trạng thái:** `setApplyLevelFinalize(true)` đặt ở `onClick` mở dialog (`:322`) ⇒ bỏ tick rồi **Huỷ** rồi mở lại vẫn về mặc định. (PE chỉ reset trong `onSuccess` `:288` ⇒ PE **giữ** tick sau khi Huỷ — chỗ này KHKK **chặt hơn** khuôn.) ✅ ## §5 (đề bài 4) Khuôn-PE fidelity — lệch CỐ Ý vs SÓT | # | Trục | PE (live) | KHKK (diff) | Phán | |---|---|---|---|---| | a | JSX ô-tích | `:772-791` | `:443-462` — cùng khung, cùng nhãn "Cấp này KẾT THÚC quy trình" | ✅ trung thành | | b | Câu "Nhớ chọn 1 giá chốt" | có (`:786`) | bỏ | ✅ **CỐ Ý** — KHKK 0 price-picker, BE tự chốt ở `ApplyApprovedValuesOnFinalize` | | c | Gửi field | luôn gửi, `!isReject && (...)` (`:259`) | chỉ gửi khi approve | ✅ tương đương (3 nhánh kia BE không đọc) | | d | Reset state | `onSuccess` | mở-dialog | ✅ KHKK chặt hơn | | e | **Default ô-tích** | **`useState(false)` = OPT-IN** (`:61`) | **`useState(true)` = OPT-OUT** (`:85`) | 🔴 **G-1 — lệch NGƯỢC khuôn LIVE** | | f | Nguồn eligibility | BE precompute `currentLevelOptions` chọn level theo actor (`PurchaseEvaluationFeatures.cs:1083-1085`) | FE **tự mirror** `ResolveActingLevel` | 🟡 **G-4 — nợ drift** | | g | Badge cấp mang cờ trong sơ đồ | có (`:441` `lv.allowApproverFinalize &&`) + làm mờ Bước/Cấp SAU (`:161-170`) | **không có** | 🔴 **G-2 — SÓT** | ### 🔴 G-1 (MAJOR) — default TICK ngược chiều khuôn PE ĐANG SỐNG; nhãn "khuôn PE S96" trích bản ĐÃ BỊ ĐẢO `fe-*/src/pages/khkk/KhkkWorkflowPanel.tsx:15-18` (header) + `:83-85` (`useState(true)`). Đo tại chỗ: `fe-*/src/components/pe/PeWorkflowPanel.tsx:61` = `useState(false)`, chú thích ngay trên nó: *"[S97 2026-07-01 — anh Kiệt FDC] … = ô-tick **OPT-IN**: default UNCHECKED → trình tiếp CEO/cấp sau; TÍCH mới KẾT THÚC tại cấp này. **Đảo opt-out S96**."* ⇒ PE **đã đi qua đúng ngã ba này** và owner ĐẢO sang opt-IN một phiên sau S96. KHKK vừa nhặt lại **đúng vế PE đã bỏ**. Vì sao lọt: chú-thích PE `:769-771` (ngay trên khối JSX được trích) vẫn viết *"NAY là ô-tích BỎ được (opt-out): default tick"* — **tự nó STALE** so với `:61` cùng file ⇒ implementer trích đúng dòng nhưng trúng **chú-thích chết**. Đây là tái phát lớp *đối-chứng-khuôn-nguồn* (S165 F-1): đo khuôn ở tầng CHÚ-THÍCH thay vì tầng **giá trị khởi tạo**. Hệ quả hành vi: Chương bấm "Duyệt" → "Xác nhận" mà không đọc ⇒ phiếu **DaDuyet + chốt giá**, CEO không bao giờ thấy. Hướng-sai-lệch nghiêng về **ÍT phê duyệt hơn** ở đúng cổng tiền; chiều ngược lại chỉ tốn thêm 1 trạm và cứu được. Vế bào chữa PHẢI ghi cùng: default=`true` **giữ nguyên** hành vi BE `?? true` ⇒ **0 regression** so với trước diff, và acceptance F-1 của gate K3 chỉ đòi "mirror `PeWorkflowPanel.tsx:778`" (JSX) — implementer làm đúng chữ. ⇒ Đây là **quyết định của owner, không phải bug**. Nhưng KHÔNG được để nhãn *"khuôn PE S96"* nói rằng PE đang làm thế. **Acceptance (1 trong 2):** (i) owner ratify default=tick + sửa nhãn thành *"ĐẢO NGƯỢC PE-live (PE S97 = opt-IN) — owner chốt @S166, lý do: …"*; HOẶC (ii) đổi `useState(true)`→`useState(false)` + đổi 2 câu mô tả cho khớp. ### 🔴 G-2 (MAJOR — carry-over, KHÔNG do diff này đẻ) — sơ đồ nói CEO "đã duyệt" trong khi CEO chưa đụng `KhkkWorkflowPanel.tsx:100-106` `stepStatus`: `if (isApproved) return 'Done'` cho **MỌI** Bước; `:108-114` `levelStatus` trả thẳng `s` khi `s !== 'Current'` ⇒ mọi Cấp = `'Done'`; `:234` render `ls === 'Done' && "đã duyệt"`. ⇒ Phiếu kết thúc ở CCM Cấp 2 ⇒ Bước 3 «Ban Giám đốc» + cấp của `truong.nguyen` hiện ✓ emerald **"đã duyệt"**. Dấu vết phân biệt duy nhất = **vắng** dòng "✓ ký {giờ}" (`:238-242`) — bắt người đọc suy từ sự vắng mặt. PE đã giải đúng bài này: badge `allowApproverFinalize` trên chính Cấp (`:441`) + làm mờ Bước/Cấp SAU (`:161-170`), **gate bằng runtime `endsBeforeCeo`** (chú thích tại chỗ: *"bug 'CEO duyệt mà mờ'"*). KHKK làm được **nửa rẻ ngay bây giờ** (badge theo `allowApproverFinalize` — cờ vừa được chính diff này đưa vào DTO); nửa còn lại cần `EndedByLevelFinalize` ra DTO = **F-7 gate K3 vẫn treo**. Ghi thật: lỗi có từ K3 (nhánh BE + seed cờ đã land ở `15349e8`), **diff này không đẻ ra nó** — nhưng diff này biến đường kết-thúc-sớm thành đường **mặc định có UI**, nên tần suất từ hiếm → thường. ## §6 (đề bài 5) Gotcha #44 — có ca nào CÓ quyền mà ô-tích KHÔNG hiện? Quét đủ 3 đường có thể giấu control khỏi đúng người: 1. **Cấp trộn cờ + Admin duyệt thay** — 🟡 **G-4 (MINOR, điều-kiện)**. BE lấy `pendingLevelGroup.First()` (IGrouping dựng trong `LoadStepsAsync`+`ResolvePointer`, `Service:410-442`); FE lấy `currentLevels[0]` (mảng dựng trong query detail `ContractSigningPlanFeatures.cs:621-643`). **Hai truy vấn khác nhau**, cả hai chỉ `OrderBy(l => l.Order)` — **không có khoá phụ** ⇒ thứ tự các row cùng `Order` do DB quyết, không có gì bảo đảm 2 lượt trả giống nhau. Lệch ⇒ FE ẩn ô-tích trong khi BE finalize = **mất opt-out im lặng** (đúng hình #44). **Vô hại trên 8 workflow seed** — đo `DbInitializer.cs:626-660`: cờ gán theo **CẤP** (`foreach (var (emails, finalize) in new[] { (r.Level1, false), (r.Level2, r.Level2Finalize) })` → mọi approver trong cùng Cấp nhận CÙNG `finalize`) ⇒ trong 1 Cấp cờ **đồng nhất** ⇒ `First()` chọn ai cũng ra cùng cờ; thêm nữa Cấp mang cờ chỉ có **1 người** (`chuong.phan`, `:576-580`). **Có hại khi admin dựng Cấp trộn cờ qua Designer** — schema cho phép (cờ là per-slot), và chính PE đã ghi thành chú thích cảnh báo: `PurchaseEvaluationFeatures.cs:1072-1075` *"OR-of-N approvers cùng Cấp = N rows cùng Order, mỗi row có Allow* riêng. Lookup KHÔNG discriminate → luôn lấy row đầu DB"*. Đối chiếu thiết kế: PE **không** mirror ở FE — BE trả `currentLevelOptions` đã chọn level theo actor. KHKK chọn mirror FE ⇒ mỗi lần `ResolveActingLevel` đổi, FE **im lặng lệch**, không có test nào kêu. 2. **Cấp CÓ cờ mà actor là người khác trong Cấp** — KHÔNG phải lỗi: BE cũng chấm `own` (không cờ) ⇒ 2 bờ cùng nói "không finalize" (§2 hàng 2). ✅ 3. **Sợi dây DTO đứt** — 🟡 **G-3 (MINOR)**: `ContractSigningPlanFeatures.cs:643` `l.AllowApproverFinalize` là **điểm chết duy nhất** của tính năng, mà **0 test chạm nó**. Grep `tests/`: `AllowApproverFinalize` chỉ xuất hiện ở `ContractSigningPlanApprovalTests.cs:877-904` (hành vi service) và `:1065-1138` (create-definition); **0 hit** trên projection của `GetContractSigningPlanQuery`. Xoá đúng 1 argument đó ⇒ 614 test vẫn xanh, `tsc` vẫn xanh (field vẫn đúng kiểu, chỉ luôn `false` lúc chạy) ⇒ ô-tích **biến mất im lặng** với đúng người có quyền. Đúng lớp S161-W2 (*hợp-đồng FE↔BE vỡ mà build+test đều xanh*). Phase-9 test-after cho phép hoãn — nhưng phải hoãn **có ý thức**, vì đây là sợi dây duy nhất. 4. 🟡 **G-5 (MINOR) — chữ "(CEO)" là suy đoán theo seed, không theo cấu hình.** `:457` *"duyệt xong kế hoạch TRÌNH TIẾP lên cấp sau (CEO)"*. Đúng với 8 workflow seed (Bước 3 = Ban Giám đốc). Nếu admin cắm cờ ở **Cấp cuối của Bước cuối**: bỏ tick ⇒ BE rơi xuống nhánh terminal `Service:320-329` ⇒ **vẫn `DaDuyet` ngay** (khác duy nhất: `EndedByLevelFinalize=false` + câu changelog) ⇒ ô-tích hứa "trình tiếp" mà phiếu đóng luôn. Test T-a có sanity chặn "cấp mang cờ = cấp cuối" **trong test**, nhưng BE **không có validator** cấm cấu hình đó ở Designer. ## §6b (đề bài 6) Header-comment — còn tự mâu thuẫn chỗ nào? Quét toàn khối `:1-27` sau khi supersede: - `:6-8` *"KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt, KHÔNG 4 return-mode"* → vẫn ĐÚNG với diff (ô-tích không phải price-picker). ✅ - `:10-14` supersede-1 (BE đã port + Designer đã gỡ khoá) → kiểm chứng: `earlyFinalizeLocked` chỉ còn ở **2 chỗ bia-mộ** `ApprovalWorkflowsV2Page.tsx:153` + `:641` (đều là chú thích "ĐÃ BỎ HẲN"), 0 site dùng. ✅ - `:15-18` supersede-2 → nội dung mô tả ĐÚNG việc diff làm, nhưng **nhãn "khuôn PE S96"** sai với PE-live ⇒ G-1. - `:20-27` *"KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` … KHKK BE trả CÂY THÔ"* → vẫn đúng, và giờ **thiếu một vế**: từ diff này KHKK còn phải tự suy **cả level-của-actor** (thứ PE cũng nhận sẵn từ BE) — chính là G-4. Khối "KHÁC PE" nên nêu thêm vế đó, nếu không người sau đọc sẽ tưởng chỉ lệch ở `status`. - Chú thích `:132-134` mô tả mirror đúng 2 vế BE, nhưng **không khai** rằng thứ tự nhóm đến từ 2 truy vấn khác nhau (G-4). - Citation kiểm từng cái: `PeWorkflowPanel.tsx:259` = đúng dòng gửi `applyLevelFinalize` ✅; `:772-791` = đúng khối JSX ✅; `ContractSigningPlanWorkflowService.cs:455-469` = đúng `ResolveActingLevel` ✅. **Số dòng không bịa.** ⇒ 0 mâu thuẫn logic còn sót; 2 chỗ **thiếu-khai** (nhãn khuôn PE · vế mirror mới) đã tính vào G-1/G-4. ## §6c NGOÀI ĐỀ BÀI — cây làm việc PHÌNH GIỮA LƯỢT REVIEW (đo 2 lần, cách nhau ~40 tool-call) | Mốc đo | `git status --porcelain -- src fe-user fe-admin tests` | `git diff --stat` | |---|---|---| | Lúc spawn (§0) | **5 file** | **+137 / −14** | | Đo lại lúc viết §6c | **7 file** (+`Domain/Identity/MenuKeys.cs`, +`Infrastructure/Persistence/DbInitializer.cs`) | **+253 / −19** | 🔴 **G-6 (MAJOR — quy trình, chặn commit):** 2 file mới (+42 / +79 dòng) **KHÔNG thuộc spec F-1** và **KHÔNG nằm trong phạm vi tôi soi**. Nội dung là **K4a** (khuôn (b) menu-key nhóm): `MenuKeys.cs:81-84` thêm `KhkkGroupMenuKeys` rồi **spread `.. KhkkGroupMenuKeys` vào `All`** (`:244`). Đo hệ quả canonical: `All` hiện gồm **64 định danh tĩnh + 1 spread**; spread = `KhkkGroupNumbers` (7 số `[2..8]`) × (1 group + 6 `KhkkLeafActions`) = **49 key** — chính chú thích `MenuKeys.cs:79` cũng ghi *"49 key"* ⇒ `|MenuKeys.All|` = **113**, policies DERIVED = **452**. ⇒ Nếu commit "vá F-1" ngay bây giờ, nó **nuốt trọn** 121 dòng chưa qua cổng nào **và** đổi 2 con số canonical (gấp ~1,8 lần). Đây đúng lớp đã ăn đòn ở gate K1 cùng chuỗi (*commit gộp = vượt quyền owner*). **Acceptance:** tách 2 commit — (1) F-1 đúng 5 file; (2) K4a riêng, có gate riêng + cập nhật STATUS. *(Ghi thật: tôi KHÔNG soi nội dung 2 file đó; con số 113/452 là DERIVE từ hình dạng mã + chú thích tại chỗ, chưa chạy initializer — người ship phải tự đếm lại bằng máy trước khi ghi vào STATUS.)* 🟡 **G-7 (MINOR — carry-over K3 F-3, mới vá NỬA):** `docs/STATUS.md:6` (văn xuôi CURRENT) đã sửa *"Policies **256** · Menu keys **64**"*, nhưng **2 row canonical** vẫn ở số S161: `:470` `| Policies | **220** |` và `:471` `| Menu keys | **55** |`. Đó lại **đúng nơi máy đọc**: `scripts/governance-detectors.ps1:175-181` `Get-StatusValue` khớp `^\|\s*