diff --git a/.claude/WAL.md b/.claude/WAL.md index 2cf5fd7..a71f65d 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -28,7 +28,11 @@ chain: [x] A-S2 spec v1 XONG — `spec-pe-delete-approver.md` (4 hạng mục A-E + checklist 28 mục + 4 nợ khai N1-N4) [!] A-yêu-cầu-(7) MỚI anh giao giữa chừng: quy trình duyệt cho **sửa TẠI CHỖ** (thêm người / chỉnh quyền) thay vì ép tạo version mới; chỉ đổi CẤU TRÚC mới bắt buộc version mới → Invest #4 `sub-invest-wfver-4.md` đang chạy [ ] ⚠️ mâu-thuẫn CẦN GIẢI ở (7): nếu "nhiều người 1 Cấp" = nhiều bản ghi Level cùng Order thì "thêm người" CHÍNH LÀ "thêm Level" ⇒ ranh AN-TOÀN/PHÁ-VỠ của anh có thể tự mâu thuẫn ở tầng dữ liệu. W4 của Invest #4 phải trả lời -[ ] A-S2-bis: Invest #4 về → bổ sung hạng mục F vào spec → S3 reviewer → S4 hmw +[x] A-S2-bis spec PHẦN II (hạng mục F) + S3 reviewer `PASS-WITH-FLAGS 17 FLAG (4H/8M/5L)`; F.0 KHÔNG bác được sau 6 đường tấn công; lead vá 4H+8M vào spec +[x] A-owner lượt 3: (8) TÁCH 2 quyền xóa · (9) F6 PER-NGƯỜI · (10) F5 = PHÁ VỠ · (11) ngưỡng CEO = PHÁ VỠ · (12) Step.DepartmentId = AN TOÀN · (13) CHIA 2 ĐỢT +[!] wf: A-S4-đợt1 hmw — run-id engine `wf_f1bfea65-ed9` · run-folder `.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/` · 2 task: implementer-backend (F6 schema+mig+UpdateCommand+validator T1/T2+authz+F-5a+F-6+F-10) · implementer-frontend (F-11 5 dây, bẫy :605). Chết giữa chừng ⇒ /tiep §4 relaunch ĐÃ-CẮT-GỌT, đọc sub-*.md trên đĩa trước +[ ] A-S4-đợt1 wave 2 (sau khi wave 1 xanh): test-specialist F-12..F-16 + reviewer soi diff +[ ] A-S5-đợt2: mục 3-28 (nút xóa FE · menu restructure · màn "Đã xóa" 8a/8b/8c) — CHỈ sau khi đợt 1 UAT xanh [ ] A-ràng-buộc CỨNG cho spec: `IgnoreQueryFilters()` chỉ mở ĐÚNG 1 endpoint list "Đã xóa" — rộng tay = phiếu xóa lọt lại `PeBudgetAccumulator` = phá đúng ý (3) của anh next: [A] chờ 2 Invest về → viết file chi tiết + checklist → hỏi anh điểm quyết → reviewer → hmw. || [B] CHỜ anh 3 số (42) canonRows · (43) carry con-trỏ-vs-slug · (44) END-line-thành-luật + (E2) harvest-curator 111,1% trần. diff --git a/.claude/agent-memory/reviewer/MEMORY.md b/.claude/agent-memory/reviewer/MEMORY.md index bf1abea..9cb404d 100644 --- a/.claude/agent-memory/reviewer/MEMORY.md +++ b/.claude/agent-memory/reviewer/MEMORY.md @@ -1,5 +1,7 @@ # Reviewer Agent — Persistent Memory +- **S155 (07-27) SPEC-review tiền-hmw (PE xoá phiếu ở màn duyệt + lệnh Update workflow) — PASS-WITH-FLAGS 4H/8M/5L; spec trích-dẫn SẠCH nhưng 4 lỗ chí-mạng:** 🔴 **H1 class MỚI `nới-authz-mà-không-ai-có-quyền`** — spec gắn `[Authorize(Policy="PurchaseEvaluations.Delete")]` lên endpoint đang **KHÔNG có policy**; sqlcmd Dev đo: CanDelete=1 chỉ `Admin`+`DeptManager`, **11/13 role = 0** (`DbInitializer.cs:2515` `canDelete = roleName==DeptManager`, seeder-2 chỉ nâng Read/Create + skip-if-exists ⇒ prod không tự vá) ⇒ vừa **regression** (Drafter mất xoá-nháp đang chạy) vừa **feature chết** (Procurement 403). `MenuPermissionHandler.cs:41-51` khớp MenuKey **CHÍNH XÁC, 0 kế-thừa** — inheritance của `GetMyMenuTreeQuery` chỉ là display. **LUẬT: đóng API = phải đo bảng `Permissions` xem AI còn qua được; test-403 một-chiều luôn xanh-giả.** · **H3** `IgnoreQueryFilters()` **gỡ** filter chứ không đảo ⇒ list "Đã xoá" thiếu `.Where(IsDeleted)` + thiếu tái-lập IDOR `:596-617` = lộ toàn hệ. · **H2** cờ trên `ApprovalWorkflowLevel` = **1 row = 1 NGƯỜI**, repo có SẴN 2 idiom cho cờ anh-em F5 (`Service:859` per-row ⟂ `Features:1182` `g.Any()` per-Cấp) ⇒ "per-Cấp" trong spec = N người xoá được (owner muốn 1). · **H4** lệnh Update workflow không khai policy mà chính nó là **cổng cấp quyền** (thêm-người + bật-cờ). · **M1 phản-ví-dụ cho luật "AN TOÀN ⟺ giữ 2 tập Order"**: cờ `AllowApproverFinalize` đổi ĐIỂM KẾT THÚC phiếu đang chờ (`:859-878`) · `pendingLevelGroup.First()` (`:733-734`, tie **không có tie-breaker**) → thêm/bớt row cùng Order đổi slot chữ-ký Admin · thêm người vào Cấp ĐÃ QUA → `ComputeLevelStatus` thuần-con-trỏ (`:1135-1148`) hiện "Done" dù chưa ký. **⇒ con-trỏ-không-dịch ≠ phiếu-không-hỏng.** · **M3** soft-delete ⇒ cascade không chạy ⇒ `LevelOpinion` mồ-côi vẫn Restrict Level ⇒ nợ hoãn (N1) **ăn thẳng** vào luật mới F-5 cùng spec. · **M4** phiếu ChoDuyet chỉ vào **2/4** phép cộng ⇒ test "cả 4 giảm" bất-khả-xanh. · **M6** thêm key `Pe_*` **KHÔNG** đổi `Policies`/`Menu keys` (derived từ `MenuKeys.All`, `Pe_*` sinh factory) ⇒ spec bảo sửa số canonical = hỏng detector. **Cách phá F.0 (6 đường, tắc cả 6)** ⇒ mệnh-đề trung-tâm ĐỨNG, chỉ tiêu-đề rộng quá. Tag `[s155, spec-review-pre-impl, authz-widen-nobody-has-it, ignorequeryfilters-not-inverted, order-set-rule-underfit]` + - **[verbatim → git 8b6df0a closeout] S146 (07-22) PRE-COMMIT spec-v2-applied (governance 20 file) — 2 MAJOR THẬT (cả hai của LEAD); nấc `verified` tự-chạy-lệnh không-tin-lead:** F1 `session-end.md` còn `hook-3-path` GẠCH-NỐI dù worker CE đã chỉ đích-danh (class "residual có người chỉ tận nơi vẫn rớt") + bất-đối-xứng nội-bộ (`pause.md` đã 4-path). F2 phép quét cardinality của lead siết LỆCH-TRỤC (co-occurrence CÙNG-DÒNG + 1 mẫu-chữ) → re-run HEAD tiền-vá lọt 4 ca thật gồm chính F1. F3 HELD (git show da7ee8a: hook 4-path + `-File` re-đọc mỗi Stop). 🔴 Bài rút: lead sửa 1 phép quét 4 lần/phiên vì mã-hoá ý-định NGỮ-NGHĨA vào regex; nấc đúng = lưới-soát-cho-người + allowlist CÓ TÊN + use⟂mention; lần-4 = citation-trap (dò bắt chính nốt-vá vì nó trích giá-trị cũ). - **[→ archive/2026-07.md @S145-curate] S145 Axis-E lane — FAIL 2C/1M/4m:** cross-cut seam catch (hook 3→4 path sessions/ orphan phá Sàn-3 tiep.md; session_ctx_kb=64 nghịch owner + ghost-wire #H18). LESSON: bất-biến chia-đôi giữa 2 axis nằm ĐÚNG SEAM → chỉ cross-cut sweep bắt. diff --git a/.claude/agent-memory/reviewer/archive/2026-07.md b/.claude/agent-memory/reviewer/archive/2026-07.md index 7531ac1..ec87b30 100644 --- a/.claude/agent-memory/reviewer/archive/2026-07.md +++ b/.claude/agent-memory/reviewer/archive/2026-07.md @@ -115,3 +115,18 @@ - **S145b (07-22) gate GOVERNANCE-roster vai-KIỂM #2/#3 `h24-audit`+`sleep-audit` (C4/C4b TÁCH, roster 18→20) — PASS 1m.** ⭐⭐ **POSITIVE-VALIDATION: implementer VÁ ĐÚNG mọi finding vai#1 S145a + tự mở rộng sweep:** de-number `fable-clone/real` argument-hint+heading ("roster 17"→"roster"), fix `harness-11-engine:345` completeness-claim "14→17"→"17→20"+trọn-roster, +3 skill-matrix row, +3 decision-tree branch; PLUS tự tìm-diệt 2 NUMERIC site NGOÀI checklist (`vocab-alias-map` "17/17"→"TOÀN-ROSTER" · `README` auto-toan-vong "roster 17"→trỏ-canonical) = **bài "grep NUMERIC lẫn name-enum repo-wide" S145a ĐÃ NGẤM**. Axis4 CLEAN: STATUS 20 + hmw VALID_ROLES 20/20 (bidirectional cross-check mọi agent.md↔VALID_ROLES = 0 MISS) + README×6 + subset-enum-detector (đếm 1-2/3 per-line) xác nhận mọi ROSTER-enum FULL(3/3), 7 "subset" đều per-role hợp-lệ (decision-tree/skill-matrix/VALID_ROLES split 2-dòng). Role-file mirror đủ (RANH SẮC h24 "KIỂM con-đo H24 KHÔNG soi-lead trực-tiếp" · gist-ADDITIVE invariant · fail-safe NO-OP · JSON-pin THẬT: `flag_classes`=11/`h24_cadence`/`class_repeat` tồn-tại). Wiring: (ii-bis) AUTO sau cặp H24 KHÁC (ii) consent-gate H24 (khai thẳng, KHÔNG mâu-thuẫn) · Phase4.5 sau Phase3-Fidelity+Phase4-WRITE trước Phase5. Axis5 no-over-claim (executed/verified-pending-restart; counter "KHONG H24 audit phien nay"=khớp NO-OP). 🔸 **1 MINOR — measured-label gate:** `README:249` "cả 3 hiện chưa có folder ⇒ 17 folder vật-lý là đúng" FALSIFIED bằng `ls agent-memory/` = **18 dir** (`tooling-harvest-audit/` RỖNG tồn-tại từ phiên này); nhưng 17 dir CÓ MEMORY.md + git KHÔNG track empty-dir ⇒ committed-state=17 khớp enum ⇒ low-impact, fix=reword "17 folder-có-MEMORY". ⭐ **LESSON tự-thân: regex/bracket-count trên `hmw.js` VALID_ROLES DỐI** (non-greedy `[\s\S]*?]` + bracket-depth-parser CẢ HAI trượt vì `[2]`/`[S110` trong comment → báo "18/0" giả) ⇒ **đếm VALID_ROLES = per-file grep bidirectional (agent.md↔literal), KHÔNG regex**. Tag `[s145b, governance-roster-followup, PASS, vai1-lesson-absorbed, numeric+name-sweep-repowide, folder-count-vat-ly-falsified, regex-on-hmw-unreliable, bidirectional-crosscheck-wins]` - **S145 Axis-E lane (/fable-clone tiep/pause/snapshot + CROSS-CUT) review SPEC apply-hub — FAIL 2C/1M/4m.** Corroborated Axis-A lane CRITICAL independently: A.4/A2 "4 surface→2" FALSE (grep `scripts/nhip-no-probe.ps1` = 4 caller pause:44·tiep:112·**session-start:216**·**session-end:104**; A2 grep-0-hit = anti-teeth Goodhart). ⭐ **UNIQUE to Axis-E = 2 seam-catch 4 lane kia RƠI:** (CRITICAL-2 C↔E) Axis C.2 hook 3→4 path (+sessions/) phá Sàn-3 tiep.md §0 — "3 path"/"3 điểm mù" hardcode **4 site** (:21/:23/:75/:78); sau +sessions/ ⇒ (a) 4 stale, (b) BẬC-MẠNH signal④ chỉ phủ runs/ ⇒ sessions/ orphan KHÔNG signal = reopens "sổ trống≠sạch" blind-spot ON THE SAFETY-CRITICAL RECOVERY FLOOR, (c) BẬC-TRUNG :75/:78 "bẩn ngoài 3 path" misclassify sessions/-dirt (logic bug THẬT ko chỉ prose). Đã biết: adap-report 07-17:23 "meld-forward-gap (hook 3 path vs manifest 4)". Cả C.death-path(spec:33) lẫn E.death-path(spec:47) KHÔNG nhắc ripple = gap ĐÚNG khe 2 lane. (MAJOR-3) C.1 session_ctx_kb=64 nghịch owner-authority: 4 artifact phiên trước phân-loại OWNER-GATED + K6.2 CẤM "mượn số hub chưa đo" (session-model-se-draft:55); spec cite "canonical default" KHÔNG cite owner-directive + acceptance C1 ko đòi `_owner_set` lineage (bulk=30 CÓ); + **ghost-wire #H18: 0 script đọc session_ctx_kb** (pull_warn_days mà spec analogize thì CÓ reader nhip-no-probe:190) ⇒ analogy hỏng 2 mặt. ⭐ **LESSON cross-cut lane:** bất-biến chia-đôi giữa 2 axis (hook-path-count ⟂ Sàn-3-blind-spot-count) nằm ĐÚNG SEAM, mỗi lane-đơn thấy nửa → chỉ cross-cut sweep bắt; "áp hub-answer" (bulk/C7 hub-trả) ≠ "số owner-giữ" (session_ctx_kb SE CỐ Ý từ-chối) — đừng gộp. Tag `[s145-axisE, crosscut-seam-catch, hook-4path-breaks-san3-recovery-floor, session_ctx_kb-owner-gated+ghost-wire, corroborate-4surface-critical, FAIL]` + +--- + +## @S155-curate (2026-07-27) — moved verbatim từ L1 MEMORY.md (hook 23.9KB → <17.1KB) + +- **[verbatim → git 8b6df0a closeout] S146 (07-22) PRE-COMMIT spec-v2-applied (governance 20 file) — 2 MAJOR THẬT (cả hai của LEAD); nấc `verified` tự-chạy-lệnh không-tin-lead:** F1 `session-end.md` còn `hook-3-path` GẠCH-NỐI dù worker CE đã chỉ đích-danh (class "residual có người chỉ tận nơi vẫn rớt") + bất-đối-xứng nội-bộ (`pause.md` đã 4-path). F2 phép quét cardinality của lead siết LỆCH-TRỤC (co-occurrence CÙNG-DÒNG + 1 mẫu-chữ) → re-run HEAD tiền-vá lọt 4 ca thật gồm chính F1. F3 HELD (git show da7ee8a: hook 4-path + `-File` re-đọc mỗi Stop). 🔴 Bài rút: lead sửa 1 phép quét 4 lần/phiên vì mã-hoá ý-định NGỮ-NGHĨA vào regex; nấc đúng = lưới-soát-cho-người + allowlist CÓ TÊN + use⟂mention; lần-4 = citation-trap (dò bắt chính nốt-vá vì nó trích giá-trị cũ). + +- **S147 (07-23) DIAGNOSIS-review PE attach TraLai (no diff — verify root-cause + stress fix) — Diagnosis PASS 5/5, fix CORE-safe/EXT-unsafe:** 5 claims verified from disk; BE-no-guard CONFIRMED **intentional** by S78 changelog L9 ("handler KHÔNG guard drafter-only → approver upload no-403") + sole pipeline behavior=ValidationBehavior. 🔴 **MAJOR blind-spot:** `attachEditable=isDrafter&&isEditablePhase` SAFE for GeneralAttachmentsSection (:2013 readOnly=raw, no carve-out) but UNSAFE for SupplierAttachmentsCell (:2758) whose readOnly=**itemsReadOnly** (`readOnly&&!approverEditMode`, Mig28-F3 ChoDuyet approver-edit) → naive REPLACE breaks approver QuoteDocument edit in ChoDuyet; SAME gap in optional BE guard (carve-out {ChoDuyet+ApprovalAttachment} misses ChoDuyet+QuoteDocument). **Scope trap:** isDrafter@:140 in MAIN scope, OUT-of-scope at :2013/:2758 (child comps take only {ev,readOnly}); changing :421 over-broadly hits HoSoLinkRow:2017. **Completeness:** WORKSPACE (readOnly=false, picker editableOnly∋TraLai) already edits comparison-doc in TraLai → bug is LIST-detail-only (:574/:669 hardcode). LESSON: **decouple a gate → trace if shared prop already carries a carve-out (itemsReadOnly ≠ raw readOnly); "same fix" for sibling surface may hit a DIFFERENT gate source.** + +### S152 (2026-07-26) — D2 Fidelity-gate 6 gist "CỤM BỔ SUNG" (coverage-diff) — **PASS_WITH_FIXES** (A/B/C/D PASS · E FAIL) +- **Census thay spot:** 307/307 trích có trong verbatim CẶP (275 exact + 32 sau khi hoàn `'`→`"`), 307/307 chứa ĐÚNG token gắn nhãn, 307/307 token thật-sự VẮNG khỏi gist tiền-append (0 gap thổi-phồng). `ref`=46 khớp đúng claim lead. Trích = cửa-sổ ~150 ký-tự cố-định, token ở ~27% → cắt giữa từ ở 2 biên nhưng payload (file:line/finding) còn nguyên ⇒ điều-hướng được, KHÔNG rác. +- 🔴 **E FAIL — class-gap hệ-thống, chữ-ký sạch:** máy-trích emit file-ref CHỈ cho `{.cs 101/.tsx 45/.ts 16/.ps1 8}` = 170 token, **ZERO cho `{.yml,.json,.slnx,.csproj,.sql,.config}`** ⇒ 13 token infra vắng ở 5/6 cặp. Nặng nhất: `deploy.yml` (chính file pipeline, trong gist của vai CI/CD!) · `appsettings.Production.json` (16 lần trong verbatim) · 2× `s59-*.sql` (chạy TAY prod ngoài pipeline) · `memory-budget.json` (single-source canonical CLAUDE.md). Header cụm TỰ đặt chuẩn "file-ref phải sống trong gist" mà KHÔNG khai N/A ⇒ trượt bằng chính thước mình. +- 🔴 **3 THƯỚC-HỎNG (bug ở phép đo, không ở vật):** (1) md5 disk vs `git show` — spec gate ghi vậy — báo FAIL GIẢ 3/6 verbatim do worktree CRLF ⟂ blob LF (autocrlf=true + 188 file legacy `i/lf w/crlf`); đúng phải `git diff HEAD --quiet` hoặc strip-CR 2 phía. (2) `grep -c distill-gen` vô-dụng vì CHÍNH header cụm chứa chữ "distill-gen giữ" (citation-trap nhẹ) → phải so VALUE `distill-gen:\s*\d+`. (3) `grep -iF` trên MSYS trả **0-hit im-lặng** cho pattern ASCII trong file UTF-8 (`Mig42`: `-F`=1 nhưng `-iF`=0) = bẫy vắng-mặt-trông-giống-sạch S146 — thoát nhờ đối-chiếu Python. +- **Tự bắt 2 lỗi của CHÍNH mình trước khi báo:** "Mig 47/50/42/46 MISS" = artifact chuẩn-hoá của tao (`Mig42` liền không dấu cách, có ĐỦ 2 phía) · context-regex `.{70}` trả RỖNG đọc nhầm thành sạch → phải `.{0,70}`. ⇒ **LUẬT: mọi MISS phải literal-grep XÁC-NHẬN 2 phía bằng công-cụ THỨ HAI trước khi vào report.** +- D re-ground từ đĩa: `docs/gotchas.md` count=83/max=83 ⇒ 0 nhãn `gotcha #N>83`; soi NGƯỢC 24 dòng gotcha ≤83 tìm mirror-error (run-number đội lốt gotcha) → 0 ca, mọi #N đều là gotcha thật trong ngữ-cảnh nguồn. Tag `[s152, d2-fidelity-gate, coverage-diff, extractor-ext-blindspot, thuoc-hong-x3]` diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-spec-review.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-spec-review.md new file mode 100644 index 0000000..ee0b80f --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/reviewer-spec-review.md @@ -0,0 +1,224 @@ +# reviewer-spec-review — chấm đối-kháng SPEC `spec-pe-delete-approver.md` + +> run `2026-07-27-S155-pe-delete-approver` · S3 · vai reviewer (READ-only, verdict-only) +> Quy ước: mọi FLAG đều có `file:line` mở được. Mọi trục PASS đều kèm **đã thử phá bằng cách nào**. +> Ghi TRONG LÚC LÀM (chống #53). + +**VERDICT: PASS-WITH-FLAGS — 17 FLAG (4 HIGH / 8 MED / 5 LOW).** +Khung spec đứng vững: trích dẫn chịu lực đúng, mệnh đề trung tâm F.0 **không bác được**. Nhưng 4 lỗ HIGH sẽ cho ra bản cài đặt **hỏng hoặc hở** nếu giao thẳng cho hmw. H1/H2/H3 phải chốt **trước** khi bắt đầu code (H1 + H2 cần owner trả lời 1 câu; H3 là 2 dòng ràng buộc phải viết vào checklist). + +--- + +## 🔴 HIGH + +### H1 — Nới authz mà **không một ai** có quyền: checklist 7 vừa **phá chức năng đang chạy** vừa **giết luôn tính năng mới** + +**Điều sai.** Checklist 7 gắn `[Authorize(Policy = "PurchaseEvaluations.Delete")]` lên action Delete, và spec kết luận *"0 key mới, 0 migration quyền"* (§B3 dòng 66). Nhưng spec **không kiểm ai đang có cờ `CanDelete`**. + +**Bằng chứng đo được (Dev DB, `SolutionErp_Dev`, lệnh chạy thật):** +```sql +SELECT r.Name, p.CanDelete FROM Permissions p JOIN Roles r ON r.Id=p.RoleId +WHERE p.MenuKey='PurchaseEvaluations' +``` +→ `Admin=1 · DeptManager=1` · **Accounting/AuthorizedSigner/CatalogManager/CostControl/Director/Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager = 0** (11/13 role = 0). + +Nguồn của con số đó nằm trong mã: `DbInitializer.cs:2515` — `bool canDelete = roleName == AppRoles.DeptManager; // chỉ TPB xóa nháp`. Và seeder thứ hai chỉ nâng `CanRead/CanCreate`, **không bao giờ chạm `CanDelete`** (`DbInitializer.cs:2120-2126`), lại **skip-if-exists** (`:2118`) ⇒ sửa seeder cũng **không** vá được prod. + +Tầng policy **không kế thừa**: `MenuPermissionHandler.cs:41-51` lọc `p.MenuKey == req.MenuKey` **khớp chính xác** rồi `AnyAsync(p => p.CanDelete)`. Cơ chế kế thừa mà spec dẫn (`GetMyMenuTreeQuery:66-72`) chỉ là **display-layer** — đúng như spec nói, nhưng nó **không** cấp quyền API. + +**Hai hệ quả cụ thể:** +1. **Regression trên chức năng đang chạy:** nút "Xóa phiếu" bản Nháp (`fe-user/src/components/pe/PeDetailTabs.tsx:445` + `:460`) hôm nay chạy được vì endpoint **không có policy**. Gắn policy vào ⇒ **role `Drafter` (người soạn phiếu) mất quyền xoá nháp của chính mình**. +2. **Tính năng mới chết ngay:** approver trong ảnh UAT thuộc Phòng Cung ứng ⇒ role `Procurement`, `CanDelete=0` ⇒ **403** dù đúng lượt và đúng cờ F6. + +Test 25 chỉ kiểm chiều **403** nên nó sẽ **xanh trong cả hai kịch bản hỏng trên**. Không có test chiều thuận (đủ quyền ⇒ 204). + +**Đề xuất sửa.** (a) Thêm mục checklist: cấp `CanDelete` cho tập role đích + ghi rõ đường cấp (tick tay `/system/permissions` trên prod **hay** seeder có upgrade-path như khối `:2120-2126`); (b) chốt với owner: quyền xoá-nháp (Drafter) và quyền xoá-khi-đang-duyệt (approver) **dùng chung 1 cờ hay tách**; (c) thêm test happy-path 204 cạnh test 25. + +--- + +### H2 — `AllowApproverDelete` **per-ROW hay per-CẤP**: spec chốt "per-Cấp" nhưng giấu hệ quả, và mã có sẵn **cả hai** cách đọc + +**Điều sai (2 lớp).** + +*Lớp 1 — mơ hồ thi hành.* Nhà của cờ là `ApprovalWorkflowLevel`, mà **1 row = 1 NGƯỜI** (`ApprovalWorkflow.cs:83-88`, `ApproverUserId` là field của row). "Cấp" chỉ là **tập row cùng `Order`**. Vậy `AllowApproverDelete` của "Cấp" phải đọc bằng cách nào? Repo đang dùng **cả hai** cho cờ anh em F5: +- per-ROW: `PurchaseEvaluationWorkflowService.cs:859` — `if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)` +- per-CẤP: `PurchaseEvaluationFeatures.cs:1182` — `g.Any(l => l.AllowApproverFinalize)` + +⇒ hai người cài đặt sẽ ra **hai kết quả khác nhau** từ đúng câu chữ §B2 dòng 60 (*"Cấp đó có `AllowApproverDelete == true`"*). + +*Lớp 2 — hệ quả chưa khai.* Đọc theo per-Cấp (`Any`) ⇒ **mọi người trong Cấp đều xoá được**. Ảnh prod (run.md dòng 74) ghi rõ Cấp 2 Phòng Cung ứng có **3 người** (Bùi Lê Thủy Trà, Trần Xuân Lưu, Lê Trần Đăng Trường). Owner chốt (1) là *"Người đứng đầu phòng đc xóa khi đến lượt họ"* = **1 người**. Spec §2 có khai "đổi nghĩa" nhưng chỉ khai vế *"thành cấp nào admin tick"* — **không** khai vế *"và như vậy N người cùng Cấp đều xoá được"*. Đó là hệ quả trực tiếp lên quyền huỷ chứng-từ tài chính, phải nằm trên bàn. + +**Đề xuất sửa.** Chốt bằng chữ trong spec + checklist: dùng `matchingLevel.AllowApproverDelete` (per-row, 1 người — sát ý owner) hay `pendingLevelGroup.Any(...)` (per-Cấp, N người). Nếu chọn per-Cấp thì thêm 1 dòng khai hệ quả "N người" cho owner duyệt lại. + +--- + +### H3 — Endpoint "Đã xóa" thiếu **2 ràng buộc bắt buộc**; D3 kín về rò-accumulator nhưng **hở về rò-dữ-liệu** + +**Điều sai.** Checklist 8 chỉ ghi *"handler RIÊNG list 'Đã xóa' với `IgnoreQueryFilters()` — tuân D3"*. Thiếu hai thứ: + +1. **Thiếu `.Where(x => x.IsDeleted)`.** `IgnoreQueryFilters()` gỡ **toàn bộ** filter chứ không đảo nó ⇒ query trả **cả phiếu sống lẫn phiếu xoá**. Màn "Đã xóa" sẽ liệt kê mọi phiếu trong hệ thống. Không mục nào trong spec nói câu này; test 22 chỉ soi accumulator nên **không bắt được**. +2. **Thiếu tái lập IDOR-scope.** Handler list hiện hành có rào chặt: `PurchaseEvaluationFeatures.cs:596-617` — non-admin chỉ thấy phiếu khi (i) mình là Drafter, (ii) phase ∈ `eligiblePhases` theo role, hoặc (iii) mình là approver trong workflow đã pin. Handler MỚI dựng từ đầu **mặc định không có gì**. Kết quả: mọi user đăng nhập đọc được **toàn bộ phiếu đã xoá của mọi dự án**, kèm `BudgetPeriodAmount` + tên gói thầu + NCC (đúng bộ field của `PurchaseEvaluationListItemDto`). + +Đây là 2 "đường rò thứ 4/thứ 5" mà 3 điều cấm của D3 **không phủ** — D3 chỉ chống rò **sang accumulator**, không chống rò **ra người dùng**. + +**Đề xuất sửa.** Viết thẳng vào checklist 8: `IgnoreQueryFilters().Where(x => x.IsDeleted)` **∧** sao chép nguyên khối IDOR `:596-617` (hoặc siết hơn: chỉ Drafter của phiếu + approver của workflow pin + Admin). Thêm 2 test: (a) list "Đã xóa" **không chứa** phiếu sống; (b) user ngoài phạm vi **không thấy** phiếu đã xoá của người khác. + +--- + +### H4 — Lệnh Update quy trình (F-1…F-11) **không khai policy authz**, trong khi chính nó là **cổng cấp quyền** + +**Điều sai.** F-1 chỉ nói *"`UpdateAwDefinitionCommand` mới"*. Không dòng nào nói endpoint mới phải mang policy. Mọi verb GHI hiện có của controller này đều có: `ApprovalWorkflowsV2Controller.cs:29` (POST), `:40` (PATCH user-selectable), `:48` (DELETE) — cả ba đều `[Authorize(Policy = "Workflows.Create")]`; class chỉ `[Authorize]` trần (`:18`). + +Vì sao nghiêm trọng hơn một endpoint thường: hai thao tác mà F-1 **cho phép** đều là hành vi **cấp quyền**: +- op #1 "thêm người vào Cấp có sẵn" ⇒ tự thêm mình vào `pendingLevelGroup` ⇒ lọt guard `PurchaseEvaluationWorkflowService.cs:702-708` ⇒ **duyệt được phiếu người khác**; +- op #2 "bật/tắt cờ `Allow*`" ⇒ **gồm chính F6** ⇒ tự cấp quyền **xoá** phiếu đang duyệt. + +Thiếu 1 dòng attribute = leo thang đặc quyền cho mọi tài khoản đăng nhập. + +**Đề xuất sửa.** Ghi rõ trong F-1: `[Authorize(Policy = "Workflows.Create")]` (hoặc key riêng nếu owner muốn tách), + 1 test 403 cho user thường, + cân nhắc ghi vết thay-đổi-quy-trình (spec đòi changelog cho xoá phiếu ở B4 nhưng **không** đòi vết cho việc sửa quyền — bất đối xứng). + +--- + +## 🟠 MED + +### M1 — Quy tắc F.2 **không đủ chặt**: 3 phản ví dụ giữ nguyên cả 2 tập Order mà **vẫn hỏng phiếu đang chạy** + +Phát biểu đang xét: *"AN TOÀN ⟺ KHÔNG đổi tập `{Step.Order}` và tập `{Level.Order}`"* (§F.2 dòng 208). + +**(a) Cờ `AllowApproverFinalize` (F5) bật/tắt giữa chừng — đổi ĐIỂM KẾT THÚC của phiếu đang chờ.** +`PurchaseEvaluationWorkflowService.cs:859-878`: khi `matchingLevel.AllowApproverFinalize` bật, người duyệt tại cấp đó **kết thúc luôn** (`Phase = DaDuyet`, `:866`, kèm `ApplyBudgetSnapshotOnFinalizeAsync` `:865` = **đóng băng số tiền**). Bật cờ này cho một Cấp mà phiếu **chưa đi tới** ⇒ phiếu đang chờ nay **không lên Ban Giám đốc nữa**. Tắt ⇒ ngược lại. +Spec xếp **mọi** cờ `Allow*` vào ✅ AN TOÀN (#2) *"thuộc tính row, 0 đụng Order"*, trong khi xếp `CeoApprovalThreshold` vào ❓HỎI OWNER (#12) với lý do *"đổi nghĩa quyết định đang chờ"*. **Cùng một lý do, hai kết luận.** F5 còn nặng hơn threshold: threshold vẫn cần CCM chủ động tích (`:881-901`), F5 thì đọc thẳng ở `:859`. + +**(b) `matchingLevel` fallback `pendingLevelGroup.First()` — thao tác #1 "thêm người" chạm đúng chỗ này.** +`:733-734`: `var matchingLevel = pendingLevelGroup.FirstOrDefault(l => … l.ApproverUserId == actorUserId) ?? pendingLevelGroup.First();`. Nhánh `.First()` chạy khi **Admin duyệt thay**. Thứ tự bên trong nhóm do `OrderBy(l => l.Order)` (`:689`) quyết — mà các row **cùng `Order`** thì **không có tie-breaker** ⇒ thứ tự do DB trả về. Thêm/bớt row cùng Cấp có thể đổi "row đầu tiên" ⇒ (i) chữ ký admin gắn sang **Level row của người khác**, (ii) UPSERT `LevelOpinion` khoá theo `(PE, ApprovalWorkflowLevelId)` (`:736-738`) ghi vào row khác. Spec có nhắc `First()` ở nguồn (sub-invest-wfver-4 F9) nhưng **không** đưa vào bảng 13 thao tác — đúng chỗ trục 3 nghi ngờ. + +**(c) Thêm người vào Cấp **ĐÃ QUA** ⇒ lịch sử duyệt hiển thị SAI.** +`ComputeLevelStatus` (`PurchaseEvaluationFeatures.cs:1135-1148`) thuần con-trỏ: `stepIdx0 < currentIdx → "Done"`. Row mới thêm vào một Cấp đã qua sẽ render **"Done"** dù người đó **chưa từng ký** và **không có** `LevelOpinion` nào. Ở phiếu `DaDuyet` còn tuyệt đối hơn: `:1137` `if (isTerminalDone) return "Done"`. Bảng 13 thao tác chỉ soi đường duyệt (`:689-702`), không soi 4 read-site hiển thị cho ca **thêm**. + +**Đề xuất sửa.** Tách #2 thành 2 dòng: cờ **không đổi luồng** (F1 4 mode, F3, F4) = ✅; cờ **đổi luồng/quyền** (F5 `AllowApproverFinalize`, F6 `AllowApproverDelete`) = ⚠️/❓ cùng hạng với `CeoApprovalThreshold`. Thêm 2 dòng vào bảng: "thêm người vào Cấp ĐANG CHỜ khi có khả năng Admin-duyệt-thay" và "thêm người vào Cấp ĐÃ QUA". Bổ sung câu: quy tắc F.2 chỉ bảo đảm **con trỏ không dịch**, KHÔNG bảo đảm **ngữ nghĩa/hiển thị không đổi**. + +### M2 — Bảng 13 thao tác **bỏ sót 2 trường** admin sửa được mà vẫn giữ nguyên 2 tập Order + +`ApprovalWorkflow.Code` (`ApprovalWorkflow.cs:23`) và `ApplicableType` (`:25`). +- Đổi `Code` ⇒ vỡ chuỗi version vì `nextVersion = MAX(Version) WHERE Code = request.Code` (`ApprovalWorkflowV2AdminFeatures.cs:325-328`) — bản kế tiếp có thể tụt về v1 hoặc đụng version cũ. +- Đổi `ApplicableType` ⇒ (i) phiếu đang pin bỗng chạy workflow của **module khác**; kiểm tra khớp loại **chỉ xảy ra lúc pin** (`PurchaseEvaluationFeatures.cs:258-265`), không có re-validate; (ii) vỡ bất-biến "chỉ 1 active mỗi type" vì khối hạ-active lọc theo `ApplicableType` (`:331-334`). + +Cả hai **giữ nguyên** `{Step.Order}` và `{Level.Order}` ⇒ theo chữ của F.2 chúng là "AN TOÀN". Nếu F-1 là allow-list đóng (chỉ #1#2#3) thì thực thi vẫn an toàn, nhưng **bảng phân loại là tài liệu người sau dùng để mở rộng** ⇒ phải khai tường minh 2 trường này thuộc NGOÀI-PHẠM-VI/PHÁ-VỠ. + +### M3 — Nợ N1 **không trung tính** lượt này: nó ăn thẳng vào F-5 của chính spec + +Xoá mềm ⇒ cascade không chạy (`AuditingInterceptor.cs:56-62` đổi `Deleted → Modified`) ⇒ `PurchaseEvaluationLevelOpinions` của phiếu đã xoá **ở lại DB** và **vẫn Restrict** FK về `ApprovalWorkflowLevel` (`PurchaseEvaluationLevelOpinionConfiguration.cs:25-28`). +F-5 lại chốt: *"chặn khi row đã có `LevelOpinion`"*. ⇒ Sau khi tính năng chạy, admin sẽ bị **chặn bỏ người khỏi Cấp vì một phiếu đã bị xoá** — chặn thật, không phải cảnh báo. +Quan trọng: **trước** lượt này rác chỉ sinh từ phiếu Nháp (hiếm có `LevelOpinion`); **sau** lượt này mọi phiếu bị xoá đều ở `ChoDuyet` ⇒ **chắc chắn** đã có `LevelOpinion` + `Approvals` + `Changelogs`. Tức lượt này **làm N1 tệ hơn theo đúng cái trục mà spec vừa dựng luật**. +**Đề xuất sửa.** F-5 phải nói rõ: đếm `LevelOpinion` **loại trừ** opinion thuộc PE đã `IsDeleted` (hoặc khai nhận hành vi chặn-do-phiếu-xoá là chấp nhận được, kèm thông điệp lỗi nói đúng lý do). + +### M4 — Test 21 phát biểu **sai số học** ⇒ không thi hành được như viết + +Phiếu `ChoDuyet` chỉ nằm trong **2/4** phép cộng: +- ✅ `PrevSubmitted` — `PeBudgetAccumulator.cs:53-59` (`ChoDuyet || DaDuyet`); +- ✅ `PendingSelected` — `:114-117` (loại `{DangSoanThao, DaDuyet, TuChoi}` + có winner); +- ❌ `PrevSelected` — `:61-62` đòi `Phase == DaDuyet`; +- ❌ `PendingSubmitted` — `:107` **loại tường minh** `ChoDuyet`. + +Test 21 viết *"cả 4 phép cộng lũy kế **giảm đúng**"*. Với 2 phép sau, delta đúng = **0**. Ai viết assert "giảm" cho cả 4 sẽ có test không thể xanh; ai viết assert "giảm" cho 2 và quên 2 kia sẽ mất chốt chặn hồi quy. +Ngoài ra `ComputePendingAsync` còn output thứ **5**: `PriorPes` (`:128-133`, gồm cả phiếu Nháp) cũng đổi sau khi xoá — spec không nhắc. +**Đề xuất sửa.** Viết lại test 21 thành 4 assert có hướng: `PrevSubmitted ↓`, `PendingSelected ↓`, `PrevSelected` **không đổi**, `PendingSubmitted` **không đổi**, + `PriorPes` mất đúng 1 phần tử. + +### M5 — §E1 **nói mạnh hơn bằng chứng**, và checklist 14 ⟂ 15 mô tả **hai nút khác nhau** + +- Sự thật đo được: `PeWorkflowPanel` nhận **đúng 3 prop** `{evaluation, readOnly, onApproved}` — `fe-user/src/components/pe/PeWorkflowPanel.tsx:31-43`. **Không có** `onDelete`, không có mutation xoá trong file. +- `onDelete` được truyền cho **`PeDetailTabs`**, không phải cho panel: `PurchaseEvaluationsListPage.tsx:570-575` và `:665-670` (đúng số dòng spec dẫn, **sai component**). +- §E2 lại đặt nút mới vào `PeWorkflowPanel.tsx:458-506`. + +⇒ Câu *"Dây đã nối sẵn"* (§E1) **không đúng cho chỗ đặt nút**. `sub-invest-fe-2` đã khai đúng ở PH-14 mục 4 (*"phải nâng mutation `del` lên rồi truyền prop mới cho `PeWorkflowPanel`, hoặc tạo mutation riêng"*) — spec **bỏ mất caveat** này. +Kèm theo: checklist **14** đặt nút trong panel; checklist **15** bảo *"bỏ chặn `readOnly` cho riêng nút Xóa"* — nhưng nút bị chặn đó nằm trong `PeDetailTabs` với gate **3 điều kiện** `mode === 'workspace' && canEditPhase && !readOnly` (`PeDetailTabs.tsx:445`) **cộng** `phase === DangSoanThao` (`:460`); màn Duyệt truyền `mode` mặc định `'detail'` và `canEditPhase=false` ở `ChoDuyet`. ⇒ "bỏ chặn readOnly" **không đủ**, và hai mục sẽ đẻ ra **2 nút Xoá** hoặc **1 nút không nối dây**. +**Đề xuất sửa.** Chốt 1 chỗ đặt nút duy nhất; ghi rõ prop/mutation phải thêm; xoá hoặc viết lại mục 15 cho khớp mục 14. + +### M6 — Checklist 27 bảo sửa row **`Policies` + `Menu keys`** trong STATUS.md — **hai số đó KHÔNG được đổi** + +`docs/STATUS.md:450` ghi rõ `Policies = |MenuKeys.All| × |MenuKeys.Actions| = 54 × 4 = 216` (**DERIVED**, `governance-detectors.ps1` resolve canonical từ đúng row này) và `:451` `Menu keys = 54` (đếm `MenuKeys.All`). +Key mới "Đã duyệt"/"Đã xóa" là `Pe_*` sinh qua **factory** (`MenuKeys.cs:134-141`), **không** nằm trong `All` (`:147-165` — đã đọc toàn bộ, `Pe_*` vắng mặt; đây cũng chính là điều spec dùng để loại `[Authorize(Policy="Pe_….Delete")]`). +⇒ Cả 2 số **giữ nguyên 54 / 216**. Làm theo mục 27 = ghi số sai vào chỗ canonical đang bị detector soi. +**Đề xuất sửa.** Mục 27 chỉ giữ "Mig mới"; nếu muốn ghi số mục menu thì ghi ở chỗ khác và nói rõ đó là số row `MenuItems`, không phải `MenuKeys.All`. + +### M7 — Thứ tự thi công: văn xuôi và checklist **đá nhau** + +§F.0 chốt *"A-E KHÔNG CHẠY nếu thiếu F"*, nhưng §4 CHECKLIST xếp A-E (mục 1→28) **trước**, còn F (F-1→F-16) nằm ở PHẦN II **cuối file**, không có mốc thứ tự nào. Người làm top-down sẽ dựng đúng thứ tự mà spec vừa cảnh báo là hỏng. Migration F6 (mục 2) đứng trước lệnh Update (F-1) cũng đúng, nhưng **phải nói ra**. +**Đề xuất sửa.** 1 dòng thứ-tự cứng đầu §4: `mục 1-2 (schema F6) → F-1…F-4 (lệnh Update + validator) → 3-8 → 9-13 → 14-20 → test`. + +### M8 — Mục 3 và F-11 là **cùng một việc, hai độ sâu** ⇒ mục 3 sẽ rơi đúng bẫy F-11 cảnh báo + +Mục 3: *"DTO + Designer admin: tick F6 per-Cấp (mirror chỗ tick F5)"*. F-11: liệt **5 dây FE** `:54 / :110 / :159 / :177 / :605` + cảnh báo 🔴 *"thiếu `:605` = tick xong KHÔNG gửi lên BE"* + `fe-user:282` + **4 neo BE** `:41 / :230 / :188 / :368`. +Ai làm theo mục 3 mà không lật xuống PHẦN II sẽ sót đúng cái `:605`. (Neo BE của F-11 kiểm chứng được: `ApprovalWorkflowV2AdminFeatures.cs:230` `AllowApproverFinalize` trong `CreateAwLevelInput`, `:368` map vào entity — khuôn đúng.) +**Đề xuất sửa.** Gộp mục 3 vào F-11 hoặc trỏ chéo tường minh. + +--- + +## 🟡 LOW + +### L1 — Tiêu đề F.0 mạnh hơn thân bài +*"A-E KHÔNG CHẠY nếu thiếu F"* → chính xác phải là *"A-E không **tới được phiếu đang treo**"*. Phiếu **tạo mới sau** khi kích hoạt v(n+1) vẫn có cờ và vẫn xoá được. Thân bài (dòng 195-196) nói đúng điều này; chỉ tiêu đề quá rộng. Giữ nguyên kết luận thứ-tự-thi-công, chỉ sửa chữ để người sau không suy rộng sai. + +### L2 — §1 bỏ mất dữ kiện đối trọng cho vế "quay lại không được" +Vế lũy kế của spec **đúng** (đã kiểm, xem trục 2). Nhưng ở `TraLai`, người soạn **được đổi hạng mục**: `UpdatePurchaseEvaluationDraftCommand.WorkItemId` (`PurchaseEvaluationFeatures.cs:221`) + write `:301-302`, guard cho phép `DangSoanThao ∥ TraLai` (`:248-250`). Tức "bấm sai gói thầu" **có** đường tự sửa (dời lũy kế sang gói đúng), chỉ là vẫn ăn số trong lúc chờ sửa. Không đổi quyết định owner, nhưng nên khai để §1 không bị đọc thành "không còn cách nào khác". + +### L3 — 2 trích dẫn lệch biên 1 dòng (nội dung đúng) +- `DbInitializer.cs:1893-1903`: nhánh `if (existingItems.TryGetValue…)` bắt đầu đúng `:1893`, nhưng `continue;` nằm ở **`:1904`** (đóng nhánh `:1905`). Nội dung "chỉ update Order rồi continue, KHÔNG đụng Label" **đúng 100%** (comment `:1897-1898` tự khai). +- `:1917-1923`: `var labelBackfill = …` khai ở **`:1918`**, 4 entry `:1920-1923`, đóng `:1924`. Dòng `:1917` là comment. Vô hại nhưng nên chỉnh cho khớp. + +### L4 — F-12 bị dán nhãn "bài kiểm chứng F.0" nhưng chỉ chứng **chiều thuận** +F-12 chứng "update tại chỗ ăn ngay vào phiếu đang chạy". Mệnh đề F.0 còn nửa kia: "POST version mới **không** tới phiếu đang treo". Không có test cho nửa đó, mà nó rẻ: tick F6 bằng POST ⇒ phiếu treo **vẫn** bị từ chối xoá. Thêm 1 test này thì F.0 mới có răng. + +### L5 — N4 ("`TuChoi` là nhánh chết") đúng về transition nhưng đừng dọn vội +Guard `PurchaseEvaluationWorkflowService.cs:101-106` chặn **đường đi tới** `TuChoi`, nhưng chính comment `:99` ghi *"Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter"* ⇒ **phiếu `TuChoi` cũ vẫn tồn tại**, và nhánh allow-list xoá vẫn phục vụ chúng. Dọn nhánh = mất quyền xoá phiếu lịch sử. Nên ghi rõ trong N4. + +--- + +## ✅ ĐIỂM SPEC LÀM ĐÚNG — đừng sửa hỏng + +1. **Trích dẫn chịu lực: 16/16 mẫu ĐÚNG DÒNG + ĐÚNG NỘI DUNG.** Gồm `PeBudgetAccumulator.cs:53-59` · `:104-126` · `:114-126` · `PurchaseEvaluationFeatures.cs:1404-1406` · `:149` · `:249-251` · `:291` · `ApprovalWorkflowV2AdminFeatures.cs:325-328` · `:331-334` · `:336` · `PurchaseEvaluationWorkflowService.cs:674` · `:683` · `:686` · `:694-695` · `:705` · `:101-106` · `PurchaseEvaluationsController.cs:146-151` · `MenuKeys.cs:147-165` · `Program.cs:82-89` (khớp **chính xác** khối `foreach menu → foreach action → AddPolicy`) · `DbInitializer.cs:2092-2096` + `:2492-2496` (**cả hai đúng từng dòng** — 5 dòng `Add(...)` mỗi bên) · `PurchaseEvaluationConfiguration.cs:84` · `ApprovalWorkflow.cs:81-82`. +2. **E3 md5 kiểm lại bằng lệnh thật:** `md5sum` cho **cùng** `02671be6334110028c55fe82f5e70dee` ở cả `fe-user` và `fe-admin`, **942 dòng** — đúng y spec. Luật "mirror 2 app thay cho 4-place" là luật đúng cho việc này. +3. **B3 chọn policy root thay `Pe_*` là ĐÚNG** và lý do đúng: `Pe_*` thật sự vắng khỏi `MenuKeys.All` (đọc trọn `:147-165`), mà `Program.cs:82-89` chỉ đăng ký policy cho phần tử của `All`. +4. **Bẫy `labelBackfill` là THẬT và quan trọng:** vòng upsert gặp key cũ chỉ sửa `Order` rồi `continue`, comment `:1897-1898` tự khai *"KHÔNG đụng Label / ParentKey / Icon"*. Sửa `tree.Add` mà không thêm `labelBackfill` sẽ **không đổi được nhãn** trên DB đã seed. Giữ nguyên cảnh báo 🔴 ở mục 11. +5. **D3 (3 điều cấm + "handler riêng, KHÔNG thêm cờ `includeDeleted`") là đúng và đáng giữ nguyên chữ** — đã kiểm: `IgnoreQueryFilters()` là toán tử trên một `IQueryable`, 2 hàm accumulator đều dựng `peers` mới (`:49-51`, `:100-102`), nên không rò chéo. (Lỗ của D3 nằm ở chỗ khác — xem H3.) +6. **F-3 (CẤM delete-then-insert, diff theo `Level.Id`) và F-9 (usage-check trước khi xoá version)** là hai điều đúng và bắt buộc: FK `Restrict` ở `PurchaseEvaluationLevelOpinionConfiguration.cs:25-28` + `PurchaseEvaluationConfiguration.cs:67-73`, và handler xoá hiện tại (`ApprovalWorkflowV2AdminFeatures.cs:406-418`) **thật sự** `Remove()` thẳng, 0 usage-check, đúng như nợ tự khai `:400-402`. +7. **Khảo cổ S60 (`6db195d`) và mắt xích "guard tự trỏ sang Xóa phiếu"** — kiểm lại trên đĩa: `PurchaseEvaluationWorkflowService.cs:101-106` đúng nguyên văn. Đây là phần chẩn-đoán mạnh nhất của spec. + +--- + +## 🎯 THÁCH-CLEAN: đã thử phá bằng cách nào + +### Trục 2 — mệnh đề F.0: **KHÔNG bác được** (6 đường tấn công, đều tắc) + +| # | Đường thử bác | Kết quả | +|---|---|---| +| 1 | Có write-site nào khác ghi `PE.ApprovalWorkflowId` (job/seeder/admin action)? | `grep -rn "ApprovalWorkflowId\s*=" --include=*.cs src/Backend \| grep -v Migrations` → PE **đúng 2** write-site: `:149` (create) + `:291` (update). Các module Office (`LeaveOtApprovalFeatures.cs:238/:509/:537/:702`, `ProposalFeatures.cs:277/:339`, `TravelVehicleApprovalFeatures.cs:189/:549/:774/:802`) **có** re-pin — nhưng **không phải PE**. Mắt xích 4 đứng. | +| 2 | Có migration/`Sql()` nào backfill lại con trỏ workflow? | `grep -rniE "ApprovalWorkflowId" Migrations/*.cs \| grep -iE "UPDATE\|Sql\("` → **0 hit**. `grep "Sql(" Migrations/*.cs \| grep -i purchaseeval` → **0 hit**. Đứng. | +| 3 | Có chỗ nào phiếu resolve workflow theo `Code`+`IsActive` thay vì Id? | `ApproveV2Async` `:671-675` chỉ `w.Id == awId`. Inbox `:832-836` lấy `wfIds` từ chính phiếu. Badge list `:672-685` join `lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId`. Flow-tree dùng workflow đã pin. **Không có** đường `Code`/`IsActive`. Đứng. | +| 4 | Admin xoá bản cũ để ép phiếu rơi sang bản mới? | FK `Restrict` (`PurchaseEvaluationConfiguration.cs:67-73`) ⇒ nổ FK, không "rơi sang". Đứng. | +| 5 | Drafter sửa phiếu lúc `ChoDuyet` để re-pin? | Chặn `:248-250`. Đứng. | +| 6 | Trả lại → sửa → gửi lại có re-pin không? | **CÓ** (`:248-250` cho `TraLai`, write `:291`) — nhưng spec đã tự khai ở dòng 196 và đúng là **không giải được ca gốc** trong 1 bước. Chỉ làm yếu **tiêu đề**, không làm yếu kết luận (⇒ L1). | + +⇒ **Kết luận F.0 ĐỨNG.** Thứ tự thi công "F trước" là đúng. + +### Trục 1 — vì sao không cho FAIL: đã kiểm **từng** trích dẫn chịu lực bằng cách mở file, không đọc lại spec. Cách phá đã dùng: (a) so từng số dòng với `sed -n`/`Read` có đánh số; (b) đếm ngược thủ công 2 khối `DbInitializer` (2075→2096 và 2470→2496) để chắc 2 danh sách permission song song đúng dòng; (c) tính lại md5 thay vì tin con số spec chép. Lỗi duy nhất tìm được là **diễn giải** (M5), không phải số dòng. + +### Trục 5 — an toàn/số liệu: đã thử phá bằng 4 câu hỏi +(i) "3 điều cấm D3 có đủ không" → **KHÔNG** (H3, 2 đường rò khác trục); +(ii) "nới allow-list rồi ai vẫn xoá được" → đo bảng `Permissions` thật (H1); +(iii) "test có răng không" → test 21 sai số học (M4), test 25 xanh-giả (H1), test 22 **có** răng cho đúng thứ nó tuyên bố; +(iv) "hoãn N1 có trung tính không" → **KHÔNG** (M3, đụng thẳng F-5). + +### Trục 6 — trung thực: 3 chỗ nói mạnh hơn bằng chứng đã tìm được là M5 (dây FE), L1 (tiêu đề F.0), H2-lớp-2 (hệ quả "N người cùng Cấp"). Ngoài ra spec **giữ đúng** nhãn nguồn: những chỗ sub gắn `[SUY LUẬN]` (vd F10 "chỉ cần nới guard là số tự đúng") được spec trình bày kèm cơ chế query filter chứ không đội lốt `[CODE]`. + +--- + +## 📌 Việc phải làm trước khi giao hmw (rút gọn) + +1. **Owner trả lời 1 câu:** F6 đọc per-ROW (1 người) hay per-CẤP (N người)? → H2. +2. **Chốt đường cấp quyền `PurchaseEvaluations.Delete`** cho tập role đích + xác nhận không cắt quyền xoá-nháp của Drafter → H1. +3. **Thêm 2 ràng buộc vào checklist 8** (`.Where(IsDeleted)` + IDOR-scope) → H3. +4. **Thêm policy vào F-1** → H4. +5. Sửa bảng F.2 (M1/M2), sửa test 21 (M4), gỡ mâu thuẫn 14/15 (M5), bỏ "Policies/Menu keys" khỏi mục 27 (M6), thêm dòng thứ tự (M7). + + diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/run.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/run.md index 354ad87..e3356b9 100644 --- a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/run.md +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/run.md @@ -99,4 +99,29 @@ NHƯNG cùng filter đó làm phiếu xóa **vô hình với MỌI query** ⇒ m **Nền đã có (lead peek):** `ApprovalWorkflow.Version` (int, monotonic per `Code`) · `IsActive` · `ActivatedAt` · `IsUserSelectable`; note `ApprovalWorkflowV2AdminFeatures.cs:402` *"Sau UAT khi link với PE/Contract thật cần check usage trước khi delete"* ⇒ usage-check là việc còn treo. -- [ ] S1-ter — Invest #4: cơ chế versioning hiện hành + ranh AN-TOÀN/PHÁ-VỠ + phiếu đang chạy chịu ảnh hưởng gì +- [x] S1-ter — Invest #4 XONG (25 phát hiện) — `sub-invest-wfver-4.md` +- [x] S2 — spec XONG 37.490 B — `spec-pe-delete-approver.md` (2 phần, ~50 mục checklist) +- [x] S3 — reviewer XONG `PASS-WITH-FLAGS 17 FLAG (4H/8M/5L)` — `reviewer-spec-review.md`; **F.0 KHÔNG bác được** sau 6 đường tấn công; lead đã vá **4H + 8M** vào spec + +--- + +## 🔴 OWNER CHỐT — LƯỢT 3 (sau review) + +| # | Câu | Anh chốt | Vá FLAG | +|---|---|---|---| +| 8 | Quyền xóa nháp vs xóa-khi-duyệt | **TÁCH 2 quyền riêng** | H1 | +| 9 | Cờ F6 per-người hay per-cấp | **PER-NGƯỜI** (`matchingLevel`, 1 người) | H2 | +| 10 | Cờ F5 `AllowApproverFinalize` | **PHÁ VỠ** (nhất quán ngưỡng CEO) | M1 | +| 11 | `CeoApprovalThreshold` | **PHÁ VỠ** | W3#12 | +| 12 | `Step.DepartmentId` | **AN TOÀN** | W3#13 | +| 13 | Cách chạy hmw | **CHIA 2 ĐỢT — F trước, A-E sau** | — | + +🎁 **Lời giải gọn cho (8):** đường xóa **nháp GIỮ NGUYÊN không policy** (Drafter không mất gì) · đường xóa-khi-duyệt = **endpoint RIÊNG** mang policy **root có sẵn** `PurchaseEvaluations.Delete` ⇒ **0 key mới** ⇒ `Menu keys=54` / `Policies=216` **không đổi** (khớp M6). + +## ĐỢT 1 (hmw lượt này) — hạng mục F + schema F6 + +**Phạm vi:** checklist mục **1-2** (schema F6 + migration) → **F-1…F-4 + F-1a/b/c** (lệnh Update + validator 2 phép thử + authz) → **F-5/F-5a** (hạng NỬA) → **F-6** (message 403) → **F-10** (vá comment sai) → **F-11** (5 dây tick F6) → test **F-12…F-16**. +**KHÔNG làm đợt này:** nút xóa FE · menu restructure · màn "Đã xóa" (mục 3-28) — đợt 2. +**Lý do phân đợt:** F.0 chứng minh A-E không chạy nếu thiếu F ⇒ đợt 1 xong là UAT được ngay, biết chắc nền đúng trước khi xây tiếp; blast radius ≥6 module nên tách để truy lỗi dễ. + +- [ ] S4-đợt1 — hmw fan-out Opus 5 MAX diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/spec-pe-delete-approver.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/spec-pe-delete-approver.md index 723afd9..527a89b 100644 --- a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/spec-pe-delete-approver.md +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/spec-pe-delete-approver.md @@ -57,7 +57,13 @@ Khuôn có sẵn, đã lặp: F1 4 mode `AllowReturn*` · F2 `AllowApproverSkipT **B2 — 🔴 Điều kiện người (HIỆN KHÔNG CÓ).** Handler **không inject `ICurrentUser`** ⇒ hiện **không kiểm ai đang xóa**. Với `ChoDuyet` phải thoả **CẢ HAI**: - đang là **lượt duyệt của actor** — dùng `CurrentWorkflowStepIndex` + `CurrentApprovalLevelOrder` khớp `ApproverUserId` (khuôn `ApproveV2Async` đã có) -- **Cấp đó có `AllowApproverDelete == true`** +- 🔴 **`matchingLevel.AllowApproverDelete == true` — PER-ROW, KHÔNG per-Cấp** *(owner chốt sau review H2)* + +> 🔴 **Viết chính xác thế này, đừng diễn giải lại** *(vá H2 — repo đang dùng CẢ HAI cách đọc cho cờ anh em F5, nên 2 người cài đặt sẽ ra 2 kết quả từ cùng một câu chữ)*: +> - ✅ **DÙNG** khuôn per-ROW: `matchingLevel.AllowApproverFinalize` (`PurchaseEvaluationWorkflowService.cs:859`) +> - ❌ **KHÔNG dùng** khuôn per-CẤP: `g.Any(l => l.AllowApproverFinalize)` (`PurchaseEvaluationFeatures.cs:1182`) +> +> **Vì sao per-ROW:** 1 row = **1 người** (`ApproverUserId` là field của row); "Cấp" chỉ là **tập row cùng `Order`**. Đọc per-Cấp (`Any`) ⇒ **mọi người trong Cấp đều xóa được** — ảnh prod cho thấy Cấp 2 Phòng Cung ứng có **3 người**, tức quyền huỷ chứng-từ tài chính rộng gấp 3. Owner chọn per-ROW để giữ sát ý ban đầu *"người đứng đầu phòng"* = **1 người, admin chỉ đích danh**. Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ luồng đang chạy). @@ -123,15 +129,29 @@ Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ lu ## 4. ✅ CHECKLIST THỰC THI +> 🔴 **THỨ TỰ THI CÔNG CỨNG** *(vá M7 — văn xuôi §F.0 và checklist đá nhau: F.0 nói "A-E không chạy nếu thiếu F" nhưng checklist lại xếp A-E trước)*: +> **`1-2` (schema F6) → `F-1…F-4` + `F-1a` (lệnh Update + validator + authz) → `3-8` → `9-13` (menu/seed) → `14-20` (FE) → `21-26` + `F-12…F-16` (test) → `27-28` (docs)** +> Làm top-down theo số thứ tự sẽ dựng đúng thứ tự mà chính spec cảnh báo là hỏng. + ### Backend - [ ] **1.** `ApprovalWorkflowLevel` + `AllowApproverDelete` (bit, default `false`) — F6, khuôn F4/F5 - [ ] **2.** Migration `AddPeAllowApproverDelete` — **3-file rule** (Migration + Designer + ModelSnapshot), no new table -- [ ] **3.** DTO + Designer admin: tick F6 per-Cấp (mirror chỗ tick F5 `AllowApproverFinalize`) +- [ ] **3.** DTO + Designer admin: tick F6 **per-NGƯỜI** (mirror chỗ tick F5). 🔴 **KHÔNG làm mục này một mình — nó là F-11 ở độ sâu nông** *(vá M8)*: F-11 liệt **5 dây FE** + cảnh báo **thiếu `:605` = tick xong KHÔNG gửi lên BE** (tick vô hình, rất khó bắt). **Làm thẳng theo F-11**, bỏ qua mục 3 này - [ ] **4.** `DeletePurchaseEvaluationCommandHandler` — inject `ICurrentUser` - [ ] **5.** Nới allow-list: thêm nhánh `ChoDuyet` **kèm** điều kiện (đúng lượt ∧ F6). `DangSoanThao` giữ nguyên - [ ] **6.** Ghi changelog `ChangelogAction.Delete` theo khuôn `LogTransitionAsync` (+ chỗ chứa **lý do**) -- [ ] **7.** `[Authorize(Policy = "PurchaseEvaluations.Delete")]` lên action Delete — 🔴 **KHÔNG** dùng `Pe_*` (policy không tồn tại) -- [ ] **8.** Endpoint + handler **RIÊNG** list "Đã xóa" với `IgnoreQueryFilters()` — tuân D3 +- [ ] **7.** 🔴 **TÁCH 2 ĐƯỜNG XÓA** *(owner chốt sau review H1 — đo thật trên DB Dev: **11/13 role có `CanDelete=0`**, chỉ `Admin` + `DeptManager` = 1; nguồn `DbInitializer.cs:2515` `canDelete = roleName == AppRoles.DeptManager`)*: + - [ ] **7a. Đường CŨ — xóa NHÁP: GIỮ NGUYÊN, KHÔNG gắn policy.** Rào vẫn là **phase** (`{DangSoanThao}`) + là người soạn. 🔴 Gắn policy vào đây = **role `Drafter` mất quyền xóa nháp của chính mình** (nút `PeDetailTabs.tsx:445` + `:460` đang chạy được **nhờ** endpoint không có policy) = **regression trên chức năng đang sống** + - [ ] **7b. Đường MỚI — xóa KHI ĐANG DUYỆT: command + endpoint RIÊNG**, mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]`. Rào = policy **∧** đúng lượt **∧** cờ F6 + - [ ] **7c.** Cấp `CanDelete` cho tập role đích (approver thật — ví dụ `Procurement` trong ảnh UAT đang = 0 ⇒ **403 dù đúng lượt và đúng cờ**). Ghi rõ đường cấp: tick tay `/system/permissions` trên prod **hay** seeder có upgrade-path (⚠️ khối `DbInitializer.cs:2120-2126` **skip-if-exists** `:2118` và **không bao giờ chạm `CanDelete`** ⇒ sửa seeder **KHÔNG** vá được prod) + - [ ] **7d.** Test **chiều thuận**: đủ quyền + đúng lượt + đúng cờ ⇒ **204**. (Test 25 chỉ kiểm chiều 403 nên nó **xanh trong cả 2 kịch bản hỏng** ở trên — mù đúng chỗ cần thấy) + > ✅ **Vì sao cách này không đụng `MenuKeys.All`:** đường mới tái dùng policy **root** `PurchaseEvaluations.Delete` (đã có trong `All`), đường cũ không cần policy ⇒ **0 key mới** ⇒ 2 số `Menu keys=54` / `Policies=216` ở `STATUS.md:450-451` **giữ nguyên**, khớp mục 27. + > 🔴 Tầng policy **KHÔNG kế thừa**: `MenuPermissionHandler.cs:41-51` lọc `p.MenuKey == req.MenuKey` **khớp chính xác**. Cơ chế kế thừa `GetMyMenuTreeQuery:66-72` chỉ là **display-layer** — không cấp quyền API. +- [ ] **8.** Endpoint + handler **RIÊNG** list "Đã xóa" — tuân D3 **VÀ 2 ràng buộc bắt buộc dưới đây** *(vá H3 sau review)*: + - [ ] **8a.** 🔴 `IgnoreQueryFilters().Where(x => x.IsDeleted)` — `IgnoreQueryFilters()` **GỠ** filter chứ **không ĐẢO** nó; thiếu `.Where` ⇒ màn "Đã xóa" liệt kê **toàn bộ phiếu sống lẫn chết**. Test 22 soi accumulator nên **không bắt được** lỗi này + - [ ] **8b.** 🔴 Tái lập **IDOR-scope**: sao khối `PurchaseEvaluationFeatures.cs:596-617` (non-admin chỉ thấy phiếu khi là Drafter ∥ phase ∈ `eligiblePhases` theo role ∥ là approver của workflow đã pin). Handler MỚI **mặc định không có rào nào** ⇒ thiếu bước này = mọi user đọc được **toàn bộ phiếu đã xoá của mọi dự án** kèm `BudgetPeriodAmount` + tên gói thầu + NCC + - [ ] **8c.** Test: (a) list "Đã xóa" **không chứa** phiếu sống · (b) user ngoài phạm vi **không thấy** phiếu đã xoá của người khác + > 🔴 D3 chống rò **sang accumulator**; 8a/8b chống rò **ra người dùng**. Hai trục khác nhau, đừng gộp. ### Menu / seed - [ ] **9.** `MenuKeys.cs` — 2 factory const mới (Approved / Deleted) @@ -141,8 +161,9 @@ Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ lu - [ ] **13.** Restart API để seed chạy (không cần EF migration cho menu) ### Frontend (🔴 mọi mục = **× 2 app**, giữ byte-identical) -- [ ] **14.** `PeWorkflowPanel.tsx` — nút Xóa cạnh `next.map`, gate E4 -- [ ] **15.** `PurchaseEvaluationsListPage.tsx` — bỏ chặn `readOnly` cho riêng nút Xóa (dây `onDelete` đã sẵn) +- [ ] **14.** 🔴 **MỘT chỗ đặt nút DUY NHẤT** = `PeWorkflowPanel.tsx` cạnh `next.map`, gate E4. **Phải THÊM prop + mutation mới** — xem 14a *(vá M5)* + - [ ] **14a.** 🔴 **§E1 bản đầu SAI, đã đính chính:** `PeWorkflowPanel` nhận **đúng 3 prop** `{evaluation, readOnly, onApproved}` (`fe-user/src/components/pe/PeWorkflowPanel.tsx:31-43`) — **KHÔNG có `onDelete`**, không có mutation xoá. `onDelete` được truyền cho **`PeDetailTabs`**, **không phải** cho panel (`PurchaseEvaluationsListPage.tsx:570-575` / `:665-670` — số dòng đúng, **component sai**). ⇒ **"Dây đã nối sẵn" KHÔNG đúng cho chỗ đặt nút.** Phải nâng mutation `del` lên rồi truyền prop mới cho panel, hoặc tạo mutation riêng trong panel +- [ ] ~~**15.**~~ 🚫 **GỠ** *(vá M5)* — mục cũ bảo *"bỏ chặn `readOnly` cho riêng nút Xóa"*, nhưng nút bị chặn đó nằm trong **`PeDetailTabs`** với gate **4 điều kiện** (`mode === 'workspace' && canEditPhase && !readOnly` `:445` **+** `phase === DangSoanThao` `:460`), mà màn Duyệt có `mode='detail'`, `canEditPhase=false`. "Bỏ chặn readOnly" **không đủ**, và giữ cả 14 lẫn 15 sẽ đẻ ra **2 nút Xóa** hoặc **1 nút không nối dây**. Nút nháp trong `PeDetailTabs` **giữ nguyên không đụng** (đường 7a) - [ ] **16.** `Layout.tsx` regex — nới cho 2 key mới, **và** vá lệch `fe-admin:107` thiếu `WfView` - [ ] **17.** Mục "Đã duyệt" trỏ `?type=1&phase=7` — **0 route mới, 0 page mới** - [ ] **18.** Trang "Đã xóa" — gọi endpoint mục 8, chỉ-xem, **không** nút khôi phục @@ -150,7 +171,13 @@ Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ lu - [ ] **20.** `npm build` × 2 app ### Test (test-before cho phần financial — luật dự án) -- [ ] **21.** 🔴 Xóa phiếu `ChoDuyet` ⇒ **cả 4** phép cộng lũy kế giảm đúng (`PrevSubmitted`, `PrevSelected`, `PendingSubmitted`, `PendingSelected`) +- [ ] **21.** 🔴 Xóa phiếu `ChoDuyet` ⇒ **4 assert CÓ HƯỚNG, không phải "cả 4 cùng giảm"** *(vá M4 — phát biểu cũ SAI số học)*: + - `PrevSubmitted` **↓** (`PeBudgetAccumulator.cs:53-59` — gom `ChoDuyet || DaDuyet`) + - `PendingSelected` **↓** (`:114-117` — loại `{Nháp, DaDuyet, TuChoi}` + có winner) + - `PrevSelected` **KHÔNG ĐỔI** (`:61-62` đòi `Phase == DaDuyet`) + - `PendingSubmitted` **KHÔNG ĐỔI** (`:107` **loại tường minh** `ChoDuyet`) + - `PriorPes` **mất đúng 1 phần tử** (`:128-133` — output thứ 5 spec bản đầu quên) + > Viết assert "giảm" cho cả 4 ⇒ test **không thể xanh**. Viết cho 2 rồi quên 2 kia ⇒ **mất chốt hồi quy**. - [ ] **22.** 🔴 Endpoint "Đã xóa" **KHÔNG** làm phiếu xóa lọt lại `PeBudgetAccumulator` (anti-regression cho D3) - [ ] **23.** Cấp **không** có F6 ⇒ xóa **bị từ chối** (403/409), kể cả khi đúng lượt - [ ] **24.** Đúng F6 nhưng **không đúng lượt** ⇒ từ chối @@ -158,7 +185,7 @@ Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ lu - [ ] **26.** Changelog có bản ghi `Delete` sau khi xóa ### Docs -- [ ] **27.** `docs/STATUS.md` — Mig mới, Menu keys, Policies +- [ ] **27.** `docs/STATUS.md` — **CHỈ row Mig mới** *(vá M6)*. 🔴 **KHÔNG đụng row `Policies` (216) và `Menu keys` (54)** — key mới là `Pe_*` sinh qua **factory**, **không** vào `MenuKeys.All` ⇒ cả 2 số **giữ nguyên**. Sửa chúng = ghi số sai vào đúng chỗ canonical mà `governance-detectors.ps1` đang soi (`STATUS.md:450-451`, `Policies` là **DERIVED** = `|All| × |Actions|`) - [ ] **28.** `docs/gotchas.md` — bẫy `labelBackfill` (sửa `tree.Add` vô tác dụng) + bẫy `Pe_*` không có policy --- @@ -210,6 +237,8 @@ FE: Designer chỉ ở **`fe-admin/…/ApprovalWorkflowsV2Page.tsx`** (fe-user ` > **(T1) Khung không đổi** — tập `{Step.Order}` và tập `{Level.Order}` giữ nguyên. > **(T2) Luật chơi không đổi** — không đụng trường quyết định **đường đi** của phiếu. Hiện tập này = `{CeoApprovalThreshold}`. +🔴 **KHAI GIỚI HẠN (vá M1 — đừng đọc quy tắc mạnh hơn nó thật):** (T1) chỉ bảo đảm **con trỏ không dịch** ⇒ phiếu không kẹt. Nó **KHÔNG** bảo đảm **ngữ nghĩa** và **hiển thị** không đổi. Ba phản ví dụ đều giữ nguyên cả 2 tập Order mà vẫn hỏng: cờ F5 đổi điểm kết thúc · fallback `.First()` gắn chữ ký sang người khác · thêm người vào Cấp đã qua thì hiện "Done" dù chưa ký. Vì vậy bảng dưới có hạng **⚠️ NỬA**, và (T2) tồn tại. + Lý do **(T1)**: con trỏ phiếu là **LAI** — `CurrentWorkflowStepIndex` dùng **INDEX** vào list đã sort (`:686`); `CurrentApprovalLevelOrder` so **GIÁ TRỊ Order** (`:694`). Giữ nguyên 2 tập Order ⇒ **không con trỏ nào dịch**. 🔴 Lý do **(T2)** — **owner chốt 2026-07-27, và nó SỬA quy tắc chứ không chỉ điền một ô.** Bản đầu của quy tắc chỉ có (T1), nên `CeoApprovalThreshold` (0 đụng Order) **tự động rơi vào AN TOÀN**. Owner xếp nó **PHÁ VỠ** ⇒ lộ ra rằng "không dịch con trỏ" **chưa đủ** để gọi là an toàn: một thay đổi có thể **không làm kẹt phiếu nào** mà vẫn **đổi luật chơi giữa chừng** (phiếu đang ở nhánh "không cần CEO" bỗng thành "cần CEO"). Đó là an toàn **kỹ thuật** nhưng không an toàn **về niềm tin** — người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó. @@ -220,8 +249,13 @@ Lý do **(T1)**: con trỏ phiếu là **LAI** — `CurrentWorkflowStepIndex` d | # | Thao tác | Hạng | Lý do (mã) | |---|---|---|---| | 1 | **Thêm người vào Cấp có sẵn** (row trùng `Order`) | ✅ AN TOÀN | `levelGroups` giữ nguyên khoá; người mới duyệt được ngay, người cũ không mất gì (`:689-695`, `:702`) | -| 2 | **Bật/tắt cờ `Allow*`** (gồm **F6**) | ✅ AN TOÀN | thuộc tính row, 0 đụng Order | +| 2a | **Cờ QUYỀN-TẠI-CHỖ**: F1 4 mode `AllowReturn*` · F3 `AllowApproverEditDetails` · F4 `AllowApproverEditBudget` · **F6 `AllowApproverDelete`** | ✅ AN TOÀN | chỉ đổi **người đó được làm gì tại chỗ họ đứng**; 0 đụng Order, 0 đổi đường đi phiếu. 🔴 F6 **buộc** phải ở hạng này — nếu không thì §F.0 vô nghiệm | +| 2b | **Cờ ĐỔI-ĐƯỜNG-ĐI**: **F5 `AllowApproverFinalize`** | ❌ **PHÁ VỠ** *(owner chốt sau review M1)* | Qua (T1) nhưng **trượt (T2)** — đổi **ĐIỂM KẾT THÚC**: bật ⇒ người duyệt tại cấp đó **kết thúc luôn** `Phase = DaDuyet` (`:866`) kèm **đóng băng số tiền** `ApplyBudgetSnapshotOnFinalizeAsync` (`:865`) ⇒ phiếu đang chờ **không lên Ban Giám đốc nữa**. Nặng hơn `CeoApprovalThreshold` (cái kia còn cần CCM chủ động tích `:881-901`, F5 đọc thẳng `:859`) | | 3 | **Đổi tên hiển thị** | ✅ AN TOÀN | display-only | +| 3b | Đổi `ApprovalWorkflow.Code` | ❌ PHÁ VỠ *(vá M2)* | vỡ chuỗi version — `nextVersion = MAX(Version) WHERE Code = …` (`:325-328`) ⇒ bản kế tiếp tụt về v1 hoặc đụng version cũ | +| 3c | Đổi `ApplicableType` | ❌ PHÁ VỠ *(vá M2)* | phiếu đang pin bỗng chạy workflow **module khác** (khớp loại **chỉ kiểm lúc pin** `PurchaseEvaluationFeatures.cs:258-265`, không re-validate) + vỡ bất-biến "1 active mỗi type" (`:331-334`) | +| 3d | **Thêm người vào Cấp ĐÃ QUA** | ⚠️ NỬA *(vá M1c)* | `ComputeLevelStatus` thuần con-trỏ (`:1135-1148` `stepIdx0 < currentIdx → "Done"`) ⇒ người mới render **"Done"** dù **chưa từng ký**, không có `LevelOpinion` nào. Phiếu `DaDuyet` còn tuyệt đối hơn (`:1137`) | +| 3e | **Thêm/bớt row cùng Cấp khi có khả năng Admin-duyệt-thay** | ⚠️ NỬA *(vá M1b)* | `matchingLevel` fallback `pendingLevelGroup.First()` (`:733-734`); row cùng `Order` **không có tie-breaker** ⇒ thứ tự do DB trả ⇒ "row đầu tiên" đổi ⇒ chữ ký admin gắn **sang Level row người khác** + UPSERT `LevelOpinion` khoá `(PE, LevelId)` ghi nhầm row | | 4 | **Bớt người khỏi Cấp** | ⚠️ NỬA | con trỏ không dịch, NHƯNG người bị gỡ đang chờ → **403** in **GUID trần** (`:705`); row đã ký → **FK `Restrict` chặn**; Cấp rỗng người ⇒ **phiếu KẸT** | | 5 | **Đổi `ApproverUserId`** | ⚠️ NỬA | như #4 **+** chữ ký cũ (`LevelOpinion` khoá theo `LevelId`) **đổi nghĩa IM LẶNG** — ý kiến người A hoá thành của người B | | 6 | Thêm Cấp (`Order` mới) | ❌ PHÁ VỠ | đổi `maxLevelOrder`, chèn chặng | @@ -249,12 +283,18 @@ Lý do **(T1)**: con trỏ phiếu là **LAI** — `CurrentWorkflowStepIndex` d ## F.4 ✅ CHECKLIST hạng mục F -- [ ] **F-1.** `UpdateAwDefinitionCommand` mới — **CHỈ** nhận trường hạng ✅ (#1 #2 #3). Trường hạng ❌ gửi lên ⇒ **từ chối kèm thông điệp bảo tạo version mới** +- [ ] **F-1.** `UpdateAwDefinitionCommand` mới — **CHỈ** nhận trường hạng ✅ (#1 #2 #3 #13). Trường hạng ❌ gửi lên ⇒ **từ chối kèm thông điệp bảo tạo version mới** +- [ ] **F-1a.** 🔴 **AUTHZ — KHÔNG ĐƯỢC QUÊN** *(vá H4 sau review)*: endpoint mới phải mang `[Authorize(Policy = "Workflows.Create")]`, khớp **cả 3** verb ghi hiện có của controller này (`ApprovalWorkflowsV2Controller.cs:29` POST · `:40` PATCH · `:48` DELETE); class chỉ `[Authorize]` trần (`:18`) nên **không tự bảo vệ**. + > 🔴 Vì sao nghiêm trọng hơn endpoint thường: **cả 2 thao tác F-1 cho phép đều là hành vi CẤP QUYỀN.** (i) "thêm người vào Cấp" ⇒ tự thêm mình vào `pendingLevelGroup` ⇒ lọt guard `PurchaseEvaluationWorkflowService.cs:702-708` ⇒ **duyệt được phiếu người khác**; (ii) "bật cờ `Allow*`" ⇒ gồm **chính F6** ⇒ **tự cấp quyền xoá** phiếu đang duyệt. Thiếu 1 dòng attribute = **leo thang đặc quyền cho mọi tài khoản đăng nhập**. +- [ ] **F-1b.** Test **403** cho user thường gọi endpoint Update +- [ ] **F-1c.** Ghi **vết** cho thay-đổi-quy-trình (spec đòi changelog cho xoá phiếu ở B4 nhưng quên đòi vết cho sửa quyền — bất đối xứng, review bắt) - [ ] **F-2.** 🔴 Validator server-side thi hành **F.2 — CẢ HAI phép thử**: **(T1)** so tập `{Step.Order}` + `{Level.Order}` trước/sau · **(T2)** chặn thay đổi `CeoApprovalThreshold`. Vi phạm bất kỳ ⇒ **409**. Răng thật, KHÔNG dựa FE ẩn nút - [ ] **F-17.** Designer FE **ép nhập** `Step.DepartmentId` (`:573-575`) trong khi BE coi là **optional** (chỉ hint) — lệch sẵn có, nay owner cho sửa field này tại chỗ nên phải chốt: nới FE cho optional, hay giữ ép. Hỏi lại nếu không rõ, ĐỪNG tự đổi - [ ] **F-3.** 🔴 **CẤM delete-then-insert Levels** — **diff theo `Level.Id`**. Replace-all sẽ **nổ FK `Restrict`** trên workflow đang chạy + mồ côi `LevelOpinion` - [ ] **F-4.** Update **KHÔNG** tăng `Version`, **KHÔNG** đụng `IsActive`, **KHÔNG** đổi `Id` — giữ Id là toàn bộ mục đích -- [ ] **F-5.** Hạng ⚠️ NỬA (#4 #5): cho phép nhưng **cảnh báo trước** + chặn khi row đã có `LevelOpinion` (đón trước FK) + chặn khi Cấp sẽ **rỗng người** +- [ ] **F-5.** Hạng ⚠️ NỬA (#4 #5 #3d #3e): cho phép nhưng **cảnh báo trước** + chặn khi row đã có `LevelOpinion` (đón trước FK) + chặn khi Cấp sẽ **rỗng người** + - [ ] **F-5a.** 🔴 Đếm `LevelOpinion` phải **LOẠI TRỪ** opinion thuộc PE đã `IsDeleted` *(vá M3)*. Lý do: xoá mềm ⇒ cascade **không chạy** (`AuditingInterceptor.cs:56-62` đổi `Deleted→Modified`) ⇒ opinion của phiếu đã xoá **ở lại DB** và **vẫn Restrict** FK. Không loại trừ ⇒ admin bị **chặn thật** khỏi việc bỏ người ra khỏi Cấp, **chỉ vì một phiếu đã bị xoá**. + > 🔴 **Lượt này làm nợ N1 TỆ HƠN, phải khai:** *trước* đây rác chỉ sinh từ phiếu **Nháp** (hiếm có `LevelOpinion`); *sau* tính năng này mọi phiếu bị xoá đều ở **`ChoDuyet`** ⇒ **chắc chắn** đã có `LevelOpinion` + `Approvals` + `Changelogs`. Tức ta vừa dựng luật trên đúng cái trục mà ta đang làm xấu đi. - [ ] **F-6.** Sửa message 403 `:705` — đang in **GUID trần**, đổi sang **tên người** - [ ] **F-7.** Designer fe-admin: thêm nút **"Sửa"** (khác "Nhân bản"), chỉ mở trường hạng ✅ - [ ] **F-8.** Port badge **"N phiếu còn chạy"** từ Designer HĐ V1 sang V2 + cảnh báo trước khi sửa