From d19ea13ae16b8a29ad0204861988ccbcc927296c Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Sun, 12 Jul 2026 14:54:18 +0700 Subject: [PATCH] wal: flush 20260712T1454 --- .claude/WAL.md | 5 +- .../implementer-frontend/MEMORY.md | 7 ++ .claude/agent-memory/reviewer/MEMORY.md | 8 +- .../agent-memory/reviewer/archive/2026-07.md | 18 +++ ...roject_pe_signoff_history_schema_review.md | 22 ++++ .../review-L1-state.md | 95 +++++++++++++++ .../review-L2-security.md | 111 ++++++++++++++++++ .../run.md | 12 +- .../spec-supplier-close-review-12-07-2026.md | 20 ++++ 9 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 .claude/agent-memory/reviewer/project_pe_signoff_history_schema_review.md create mode 100644 .claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L1-state.md create mode 100644 .claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L2-security.md create mode 100644 .claude/workflows/runs/2026-07-12-S112-supplier-import-close-review/spec-supplier-close-review-12-07-2026.md diff --git a/.claude/WAL.md b/.claude/WAL.md index 21f08ad..33bb23f 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -10,8 +10,9 @@ chain A (Supplier import): chain B (PE sign-off change): [x] investigate wf_fd8f2bfe-9f6 + anh Kiệt chốt 6 decision: (1)A1-bypass-subordinate-only+creator-ký-tay (2)no-hard-guard-single-person (3)EDGE-5-hard-lock-DaDuyet (4)1-nút (5)opinion-history (6)phiếu-mới-only -[!] /fable-clone review close-review (BG wf) 3-lane Opus — validate cách-làm + điều-chỉnh + history-mechanism a/b/c + DEPLOY GO/NO-GO -[ ] fan-out Opus implement (BE A1+EDGE-5+history + FE mirror + test) → reviewer pre-commit → commit+push → cicd DEPLOY (gates PHẢI pass — KHÔNG deploy nếu review NO-GO/test fail) +[x] close-review wf_7df56b89-7bb GO-WITH-ADJUSTMENTS (2/3 lane, L3 garble): A1 Min-rule OK · EDGE-5 guard đỉnh-method SVC:53 (L2 refine, chặn admin-override :290) · history=Changelog-MVP · 🔴 A1 MỞ CEO-skip MỚI (finalize-flag) L2-catch +[!] CHẶN fan-out — anh Kiệt chốt 3 oQ: (oQ1)CEO-skip creator-self-finalize [rec: guard hẹp] (oQ2)"cứng" có khóa admin-edit-budget/giá phiếu DaDuyet? (oQ3)confirm mất admin-escape-hatch un-terminal +[ ] sau chốt → fan-out Opus implement (A1+EDGE-5-đỉnh-method+CEO-skip-guard+history-Changelog+test) → reviewer → commit+push → cicd DEPLOY (gates pass) next: chờ FE-Supplier(BG) + PE-close-review(BG wf) land → synthesize; PE: review GO → fan-out implement → deploy. Supplier: FE → close-review → deploy verify: Supplier build+450test PASS(DONE)+header-baked ; PE review GO-GATE + implement build+test PASS + cicd prod-verify TRƯỚC khi coi deploy xong diff --git a/.claude/agent-memory/implementer-frontend/MEMORY.md b/.claude/agent-memory/implementer-frontend/MEMORY.md index a33e3b8..706ebda 100644 --- a/.claude/agent-memory/implementer-frontend/MEMORY.md +++ b/.claude/agent-memory/implementer-frontend/MEMORY.md @@ -6,6 +6,13 @@ --- +## 🆕 2026-07-12 — Supplier import Excel FE (Phase B Approach A) ×2 app byte-identical (lead chốt UX; BE done+450 tests) +NEW `components/master/SupplierImportDialog.tsx` (folder `master/` MỚI cả 2 app) + wire `pages/master/SuppliersPage.tsx` (nút "Import Excel NCC" outline cạnh "Thêm NCC" trong PageHeader actions flex + render dialog). KHÔNG 4-place mirror — dialog trên page ĐÃ routed → no new route/menuKey/Layout. +- **Flow:** .xlsx → POST `/suppliers/import/preview` multipart field `file` → preview. `layoutValid===false`→alert đỏ+warnings, ẩn nút xác nhận. Else: badge counts (New/Update/Skip/Error) + warnings amber + `` raw (Dòng·StatusBadge·Mã·Tên·Loại·Thông báo). "Xác nhận nhập" disabled `errorCount>0` → POST `/import/confirm` body `{rows}` = echo `preview.rows` VERBATIM. committed→toast+`invalidateQueries(['suppliers'])`+close; !committed→alert `errors[]`. +- **BE contract (Read-only DTO):** enum serialize = **INT** (chỉ UtcDateTimeJsonConverter đăng ký, KHÔNG JsonStringEnumConverter) → `type` 1-6 reuse `SupplierTypeLabel`, `status` RowImportStatus 0-3 const-object; camelCase wire (default web JSON); confirm bind positional record `(IReadOnlyList Rows)`. Round-trip 30 field verbatim → TS type partial vẫn OK runtime. +- **Mirror lesson (tái xác nhận S97-badge):** Dialog (fe-user gradient title bar) + PageHeader + types/master (khác MỖI `Department.parentId` admin-only S65) ĐỀU DIVERGED 2 app — NHƯNG import alias-path `@/…` + symbol-signature giống → new file VẪN byte-identical. Byte-identical SuppliersPage cũ (build 2 app) = proof mọi symbol tương thích. UI: KHÔNG shadcn Badge/Alert/Table primitive (chỉ PhaseBadge) → inline Tailwind badge/alert + `
`. Multipart = pattern `ContractAttachmentsSection` (FormData + `Content-Type: multipart/form-data`). +- **SHA256 ×2 pair:** SupplierImportDialog `6313529c…`, SuppliersPage `e9143d93…`. Build PASS ×2 (admin 1950mod `index-DyOk34HV.js` 19.5s / user 1939mod `index-DXK9ebaz.js` 8.75s, 0 TS err). Pre-existing warn: @import CSS-order + >500KB + realtime.ts dynamic. NO BE touch. Full precedent. Tag `[supplier-import-excel, multipart-preview-confirm, enum-int-wire, byte-identical-2app, no-4place-dialog-on-routed-page]`. + ## 🆕 S97-badge (2026-07-01) — PE chip "✅ Đã được CEO duyệt" khi phiếu ĐÃ DUYỆT đi hết lên CEO (anh Kiệt FDC UAT, em-main chốt) FE-ONLY, PROD LIVE. Đối xứng fix endsBeforeCeo (Mig 60). EDIT1 mở rộng `PeFinalizeChip.tsx` (2 app) + EDIT2 wire `phase={p.phase}` ở 6 call-site. - **EDIT1 component:** +prop `phase?` + nhánh: `if(!endsBeforeCeo){ if(phase===DaDuyet) return emerald "✅ Đã được CEO duyệt"; return null }` else giữ violet "⚑ Kết thúc·X". Import `PurchaseEvaluationPhase` từ `@/types/purchaseEvaluation` — **cùng alias 2 app** → byte-identical (KHÔNG ngoại-lệ import-path). SHA256 IDENTICAL ×2 = `da9c3ed9…`. diff --git a/.claude/agent-memory/reviewer/MEMORY.md b/.claude/agent-memory/reviewer/MEMORY.md index 37271fa..d27cbbe 100644 --- a/.claude/agent-memory/reviewer/MEMORY.md +++ b/.claude/agent-memory/reviewer/MEMORY.md @@ -13,6 +13,7 @@ - **S101 H18 WF2 governance re-run** — PWC; detector fault-inject 4/4; lesson: exact-token sweep misses paraphrased labels → widen concept-phrase khi retire named artifact; #53 garble recovered first-hand. → `archive/2026-07.md`. - [S103 H20+crystallized-backfill review](project_s103_h20_crystallized_backfill_review.md) — WF2 PASS (fable-clone ensemble); 6/6 hash recompute MATCH + 4 already-met verified file:line. **Lesson: multi-axis warning phải nằm trong OUTPUT của script, không chỉ doc** — reader chạy script inline. - **S101 H19 WF2 review LANE-C (adap-reports+honesty)** — PASS; 4 hash recompute MATCH; hex→SHA = recompute CẢ strip/no-strip chứng content-hash ≠ commit-SHA. → `archive/2026-07.md`. +- [S111 PE sign-off 6-decision design review (schema/history lens)](project_pe_signoff_history_schema_review.md) — GO-WITH-ADJ; Decision-5 opinion-history: RECOMMEND (a) append-only table, REJECT (b) version-col (drops UNIQUE + breaks UPSERT read every site), (c) Changelog zero-mig fallback. Hook @ ApproveV2Async UPSERT :784-790. Decision-3 caller-safe (SlaExpiryJob=Contract-only, not PE). A1 breaks 4 bypass tests. --- @@ -89,9 +90,10 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod - **S88 (2026-06-25) Fidelity-gate L2 gist distill (test-specialist) — FAIL (1 fabrication-by-merge):** gist gán "Mig 45" cho cluster span 2 episode khi chỉ 1 mang số đó (Master = Mig 47). Anti-pattern: merge-distill fabricates false specificity; token-PRESENCE pass BLIND to cross-episode mis-attribution → QUALITY gate after presence gate. Detail → `archive/2026-06.md`. - **2026-07-10 (S108 first-real-run H19) `[fable-real-single]`** — **FAIL** (1 CRITICAL + 4 MAJOR + 6 minor; em-main 37-fix cùng phiên): email claim “đã sửa comment” khi hmw.js chưa sửa. Lesson: doc assemble-từ-N-worker → grep stale-claim theo CLASS toàn file sau fix đại diện; mọi outward-claim “đã sửa X” phải cat X trước gate. → `archive/2026-07.md`. -- **2026-07-10 (S109 sleep-recovery P3b fidelity-gate 2 gist L2 tooling-auditor + harvest-curator) `[fable-real-single]`:** **PASS_WITH_FIXES** (3 fix, 0 re-distill): (1) gist-2 meta-count "15 entry" ≠ disk 14 — count COPY từ brief, không đếm disk; (2) delta tự-tính "+8.8KB" cạnh cặp 32.2→41.1 (=8.9) — **self-computed derivative = lớp false-specificity MỚI** ngoài S88 merge-mis-attribution; (3) N/A-justification "(resolved monthly S96)" over-claim — ERD-debt §16+ CÒN pending (root CLAUDE.md), chỉ "(55)" resolved → **fact-claim trong N/A-note verify như content**. Bịa=0 ("S49" vượt-verbatim nhưng TRUE — chéo reviewer `_INDEX.md:28`); đảo-nghĩa=0 ("suýt" giữ ×2); drop-oan=0; 3 self-flag honesty NGUYÊN; pointer 10/10 unique+đúng-entry. Lesson: gist-metadata + số-tự-tính + drop-justification = 3 bề mặt lỗi máy token-coverage mù. Return Verdict-header dòng-1, 0 garble. -- **2026-07-12 (S111 H22 WAL session-continuity adopt cổng-cuối, working-tree + 2 commit `wal:`) `[engine: fable-real-single · Fable-S111]`:** **PASS_WITH_FIXES** (0C/1M/~7m). Fault-inject cây tạm 2 ca PASS (1-path-vắng → commit 2-path-còn exit-0, không nuốt ngoài-whitelist; 0-delta im lặng — bịt đúng nhánh lead chỉ "quan sát tự nhiên"). CATCH M-duy-nhất: engine CAVEAT còn "STAGED-until-verified" trong khi N.3 CÙNG FILE đã nâng VERIFIED-RUNTIME — lớp lỗi **nâng-nấc-quên-sync-câu-cũ cùng-diff**. Lesson mới: (1) **live-witness > forensics > lời-khai** — commit `67be443` sinh GIỮA lúc mình đang review (status→diff lệch 3 path) = tự chứng kiến hook fire, không cần tin claim; (2) **NTFS CreationTime × commit-time phân định script-RUN-tay vs hook-FIRE** — commit 11:05:39 rơi GIỮA cửa sổ worker-write (ps1 :32 → pause.md :48) → không thể là tay; (3) WAL `updated:` lead tay-ghi đi TRƯỚC commit-time 47-61' → field máy-đọc phải sinh từ `Get-Date` thật, không ước — false-positive cảnh-báo phiên-song-song /tiep; (4) PATH 3-context data-point: PS-của-mình trả Git-Bash (spawn từ bash, env-inherit) ≠ lead đo WSL-system32 — KHÔNG mâu thuẫn, càng chứng exec-env bất-định → PS1 path-independent đứng. Return verdict-header dòng-1. -- **2026-07-11 (S110 H21+MTv3 adopt cổng-cuối, uncommitted 13-file) `[fable-real-single]`:** **PASS_WITH_FIXES** (0C/5M/~9m). Sàn-5-điểm + 4-note a-d + MTv3-4-vế ĐỦ trên bề mặt chính (2 command + 0.5b/0.6 + engine K.E); X1 hmw nguyên; marker xóa; X8 sạch (mọi "code-enforced" negated). CATCH: (1) worker đổi-fact khi reword — fable-clone:33 "VALID_ROLES nhận cả 12 vai" nhưng hmw.js:22-28 = **10** (engine K.C nói đúng 10) → **claim-về-code trong doc phải grep code lại kể cả khi chỉ reword**; (2) roster-12 promise × VALID_ROLES-10 → lệnh-B 2 vai monitor degrade DEFAULT-subagent silent (hmw.js:104 fail-soft, mất persona+memory-pack) — floor mới EXPOSE edge code cũ mà H19 2-vai-cố-định che khuất; (3) point-edit sót cụm GIỮA-section: runbook §5.1 P5 "EXCLUDED all-but-2" đối đầu P0-mới roster-12 CÙNG checklist; §4.2 fixed nhưng mirror §3.2/§3.3 MISS (asymmetric-mirror-edit); §2.5 q1/q3+bảng marker nguyên un-🧊; (4) 3 supersede-note trỏ 2 adap-report CHƯA tạo = dangling-pointer + outward chưa qua gate. Lesson: sweep hậu-supersede phải đi theo CONCEPT-cluster từng section — acceptance token-hẹp ("Test-Path ngoài 🧊 = 0") PASS trong khi semantics cũ còn nguyên cụm. #53 garble lượt return (status-line thay verdict) → coordinator re-emit request trong-session = recover OK, diary ghi trước re-emit. +- **2026-07-10 (S109 fidelity-gate 2 gist L2) `[fable-real-single]`:** **PASS_WITH_FIXES** (3 fix): (1) gist meta-count "15" ≠ disk 14 (count COPY brief không đếm disk); (2) delta tự-tính "+8.8KB" cạnh 32.2→41.1 (=8.9) = **self-computed derivative = false-specificity MỚI** ngoài S88; (3) N/A-just "(resolved S96)" over-claim ERD-debt §16+ pending → **fact-claim trong N/A-note verify như content**. Lesson: gist-metadata + số-tự-tính + drop-just = 3 bề mặt token-coverage mù. → `archive/2026-07.md`. +- **2026-07-12 (S111 H22 WAL adopt cổng-cuối) `[fable-real-single]`:** **PASS_WITH_FIXES** (0C/1M). Fault-inject cây tạm 2 ca PASS; CATCH engine CAVEAT "STAGED" còn khi N.3 cùng-file đã nâng VERIFIED-RUNTIME = **nâng-nấc-quên-sync-câu-cũ cùng-diff**. Lessons: live-witness>forensics>lời-khai (commit sinh GIỮA review = tự chứng hook fire); NTFS CreationTime×commit-time phân-định RUN-tay vs hook-FIRE; PATH 3-context bất-định → PS1 path-independent. → `archive/2026-07.md`. +- **2026-07-11 (S110 H21+MTv3 adopt cổng-cuối) `[fable-real-single]`:** **PASS_WITH_FIXES** (0C/5M). CATCH: (1) worker đổi-fact khi reword — doc "VALID_ROLES 12 vai" nhưng code=10 → **claim-về-code phải grep lại kể cả chỉ reword**; (2) roster-12×VALID_ROLES-10 → 2 vai monitor degrade DEFAULT silent (floor mới EXPOSE edge code cũ); (3) point-edit sót cụm GIỮA-section + asymmetric-mirror-edit §3.2/§3.3; (4) supersede-note trỏ adap-report CHƯA tạo = dangling. Lesson: sweep hậu-supersede theo CONCEPT-cluster; #53 garble return → in-session re-emit recover. → `archive/2026-07.md`. +- **2026-07-12 (S112 PE sign-off approach-review PRE-fan-out+deploy, security/governance lane) `[fable-real-single]`:** **GO-WITH-ADJUSTMENTS.** (1) EDGE-5 decision-3 hard-lock: spec placement (reject-branch SVC:92 + EnsureCanReject:321 + handler) **INCOMPLETE — bỏ sót admin-override path SVC:290** (`if(isAdmin) evaluation.Phase=targetPhase`) → admin un-terminal DaDuyet bằng decision=Approve (KHÔNG qua reject). Fix = 1 guard TOP-of-method sau `var fromPhase` SVC:53. Lesson: **terminal-lock gác FROM-state ở ĐỈNH method, KHÔNG rải per-branch** (per-branch bỏ sót admin/fall-through). (2) CEO-skip decision-2: A1 (creator tự ký ô mình thay auto-bypass) **TẠO đường CEO-skip MỚI** multi-step-có-CEO — trước A1 bypass advance-qua slot creator (bỏ check finalize); sau A1 creator qua ApproveV2Async → slot có `AllowApproverFinalize`=true + tick → DaDuyet bỏ CEO (SVC:867). invest-C "đã tồn hôm nay" IMPRECISE (chỉ đúng 1-step-no-CEO). Escalate owner. Lesson: **đổi auto-advance→manual-approve = mở lại per-slot flag mà auto-path che khuất** — re-scan flag khi đổi ai-đi-qua-code-nào. --- diff --git a/.claude/agent-memory/reviewer/archive/2026-07.md b/.claude/agent-memory/reviewer/archive/2026-07.md index ad11a68..e59d256 100644 --- a/.claude/agent-memory/reviewer/archive/2026-07.md +++ b/.claude/agent-memory/reviewer/archive/2026-07.md @@ -16,3 +16,21 @@ - **S101 H19 fable-clone WF2 Lane-A (toggle mechanism) — PASS_WITH_CONCERNS (1 CONCERN, 0 BLOCKING):** 3 Lane-A asks all CONFIRMED — (Q1) `/fable-real`=Write · `/fable-clone`=Remove-Item mirror ultra-on/off, both carry no-hot-reload warning; (Q2) semantic INVERSE (marker-absent=ảo-DEFAULT · present=thật) consistent across 8 statements + `git check-ignore .claude/fable-real-mode.on`=IGNORED (line 92 AFTER `!.claude/**` neg line 83, verified `check-ignore -v`) + tracked cmd-file NOT-ignored; (Q3) GAP-1 scope-guard "N lane CÙNG 1 vai, KHÔNG mixed-roster" pinned in BOTH cmd-files + §K.C, and hmw.js VALID_ROLES independently confirmed = all 9 roles no 2-position gate (resolveModel unrestricted) → GAP-1 leak framing ACCURATE not fabricated. **CONCERN (refutation that landed):** marker is a WRITE-ONLY dead artifact — `/fable-real.md:10` claims "Mode persist qua marker → SỐNG qua session/compact" but NOTHING reads `fable-real-mode.on`; `/session-start` BƯỚC 0.5 reads only `hmw-mode.on` and was NOT extended (session-start.md unmodified in git status, empty grep for any reader). Write half-loop exists, read→report→route half-loop absent → wording overstates mechanism. Non-blocking because vacuously-met-today (all-inherit → 2 positions ALREADY top-model → 0 runtime-delta; toggle manual/owner-driven by design) — bites only when Fable returns + tiering resumes. **Anti-pattern: mirror-a-toggle copies the WRITER side but silently drops the READER side** — a persist-claim needs BOTH write-on-set AND read-on-session-start; verify the consumer exists, not just the producer. Vacuously-met disclosure itself was clean (§K.D + mark both say "KHÔNG overclaim runtime-delta"). *(dời từ Role-baseline @S102 — H2 Placement-flag; NOTE @S102: reader BƯỚC 0.5b ĐÃ wire tại S101-cuối → CONCERN closed, marker có consumer.)* - **S100 H18 WF2 synthesis + Lane-B** — SYNTHESIS reviewer 2-lane JSON→overall **PASS_WITH_CONCERNS** (LaneA PASS + LaneB PWC, neither BLOCKING). Re-verified indep: ledger +2/0-del revert-clean · ratio-band replaced `|diff|>=10` · glob replaced 3-fixed-list · 6/6 consumers 🧊-marked · every LIVE `_ledger` marked (unmarked=history) · CONCERN workflows/README:51 no-token points-authoritative=non-block · KEY anti-pattern HELD (stale WF2 `wf_31ea3985` NOT stamped, both placeholders await fresh run). Prior S100-bis Lane-B PASS (2 minor) folded in. - **2026-07-10 (S108 first-real-run H19) `[engine: fable-real-single · Fable-S108]`** (em-main harvest B3, GAP-2/3): Task single-deep-pass adversarial 3 artifact S108 (runbook 58.7KB + adap-request + email outward — $outwardFlag=Y Cat-6). Verdict **FAIL** — 1 CRITICAL (email claim “đã sửa comment” khi hmw.js:31-32 chưa sửa, cat xác nhận) + 4 MAJOR (2 stale-outage-claim sót §3.5/§4.3 · clone-example thiếu tier:'opus' trái K.B/floor-2 khi Fable UP · AP-4 tự-toggle trái K.D-2 · drift-note 9-vai sai vs engine-10) + 6 minor; core JSON-shape/biến/checklist/GAP-guard **SOUND** (7 refutation: 5 đứng · 2 vỡ về phía artifact). Learned: doc assemble-từ-N-worker → grep stale-claim theo CLASS toàn file sau khi fix đại diện (header “đã sửa 6” ≠ sửa hết) · mọi outward-claim “đã sửa X” phải cat X trước gate. Surprise: đoạn drift-note (viết để cảnh báo drift) là chỗ duy nhất mô tả sai canonical. Em-main áp 37-fix + vá hmw.js thật → verdict-fixes closed cùng phiên. Return dòng-1 Verdict-header chuẩn, 0 garble. + +--- +## S112 curate (self, 2026-07-12, hook 20.5KB) — collapsed 2026-06 digest cluster from L1 Recent-activity (verbatim below; detail already in 2026-06.md / linked area files) + +- **2026-06-29 S93 Harness-16 MFE adoption review (WF2 `wf_13e3d35a` 3-lane PASS 0-blocking):** ⭐ code-gate re-derived denom-29 + leading-verb-trap DEFEATED (`NEVER commit push`→0 content-word) + READ-ONLY-budget proof (1 write-op `.mfe-state.json`); WF1-reviewer caught BLOCKING vocab-fork (memory-fidelity H6.7≠H16 → MFE+alias-map §H). Detail → adap-report harness-16-mfe. → _INDEX. +- **S92 (2026-06-29) Adversarial PROD-security hide 5 menu-groups admin-only on eoffice (uncommitted) — PASS (0 blocking, 1 awareness note):** A-E all upheld; CatalogManager Danh-mục access stripped (intended-by-spec, role assigned to 0 users post-S89, flag em-main only). Detail + per-attack file:line → project_s92_admin_only_modules_revoke.md. +- **S91 (2026-06-25) PE D2 create-contract 1→N multi-winner + winner-names (FROZEN, NOT-deployed) — PASS:** fix for S89-bis dead-end; codegen mid-loop SaveChanges flushes ONLY seq-row (contract built LOCAL); GiaTri per-winner Quote-sum join PES.Id; positional DTO arg-order verified 3 sites; FE mirror byte-identical; 419 PASS. Detail → `archive/2026-06.md`. +- **S90 (2026-06-25) PE stability-fix batch D1 (5 chg+11 test, FROZEN) — PASS:** Block B re-key SelectedSupplierId→IsWinner (single unchanged proven); CEO-notify SaveChanges (was Add-but-never-flush); HoSoLink null-safe+clear-via-empty option(b); #70 ||peFetching 2 PRO cells; 413 PASS. Detail → `archive/2026-06.md`. +- **S89-bis (2026-06-25) AREA-6 FE-consumers (PE FROZEN, investigator verify) — 4/4 confirm + 1 NEW miss:** all 4 hold; NEW catch = create-contract joint-winner DEAD-END (FE shows button on `some(isWinner)` but BE hard-blocks `SelectedSupplierId is null`). Anti-pattern: single→multi conversion stops at detail-display layer. Detail → `archive/2026-06.md`. +- **S89 (2026-06-25) AREA-4 workflow-edit (PE FROZEN, investigator verify) — 3 confirm/1 do-not-touch:** HoSoLink #73-class clear-on-partial-edit CONFIRMED + NEW 2nd destructive call-site (PeDetailTabs:817 InfoTab.save omits hoSoLink). Anti-pattern: echo-all-siblings convention rots when NEW absolute-set field added. Detail → `archive/2026-06.md`. +- **S86 (2026-06-24) PE Section B 3-cột Dự-án|PRO|CCM (Mig 59) — PASS:** authz byte-mirror UpdatePeSuggestedPriceCcm (NotFound→Forbidden, fail-closed); FE `canEditCcm` KHỚP BIT-EXACT BE gate; submit-guard untouched (grep CcmBudgetPeriod in Services=ZERO); div-by-0 safe; 402 PASS. Detail → `archive/2026-06.md`. +- **S82 (2026-06-21) Harness-15-v2 adopt review (0 code) — 3/3 lane PASS:** caught 3 MINOR. Memory-note: `broadcasts/_index.md` sha = notify self-declared `content_sha256` frontmatter NOT recompute → don't flag index-vs-file mismatch before reading frontmatter. Stamped-mark mid-session edit OK if only refresh confirmed-decision What-cell. +- **S76 (2026-06-19) PE budget 3-cột Mig 56 + badge — PASS:** MAJOR race fixed gotcha #70 (useIsFetching gate). Badge role-set MUST mirror gate bit-for-bit. SURPRISE: spec "KHÔNG migration" FALSE — đọc changed-set thật, đừng tin scope-framing em-main. → _INDEX S76. +- **S72* (2026-06-18) Mig 54 PE giá-đề-xuất + CCM-finalize OPT-IN — financial go-live PASS:** fail-closed guard throw BEFORE set Phase=DaDuyet; finalize-bypass = 3 orthogonal gate + server-recompute amount no-trust-client. → _INDEX S72*. +- **S71 (2026-06-18) Harness-10 run-trace — PASS/GAPS:** "TRACKED" 2-level = check-ignore(eligible) vs git-ls-files(committed), model only post `git add`. → _INDEX S71*. +- **S69 (2026-06-17) Office re-skin + golive authz — PASS:** re-skin proof = grep api-call+queryKey sorted -u byte-equal; public-grant = granted root NOT inherit-root (no sibling cascade); accent missing -800 stop = silent no-class Tailwind v4. → _INDEX S69*. +- **S65 (2026-06-16) public HRM Hồ sơ + PE mục E — PASS:** upgrade-path MUST MUTATE row (`if(!row.CanRead){...}`) NOT skip-existing when prior revoke pre-set false (S58-class); menu-hide ≠ API-lock. → _INDEX S65*. +- **S88 (2026-06-25) Fidelity-gate L2 gist distill (test-specialist) — FAIL (1 fabrication-by-merge):** gist gán "Mig 45" cho cluster span 2 episode khi chỉ 1 mang số đó (Master = Mig 47). Anti-pattern: merge-distill fabricates false specificity; token-PRESENCE pass BLIND to cross-episode mis-attribution → QUALITY gate after presence gate. Detail → `archive/2026-06.md`. diff --git a/.claude/agent-memory/reviewer/project_pe_signoff_history_schema_review.md b/.claude/agent-memory/reviewer/project_pe_signoff_history_schema_review.md new file mode 100644 index 0000000..ad7d615 --- /dev/null +++ b/.claude/agent-memory/reviewer/project_pe_signoff_history_schema_review.md @@ -0,0 +1,22 @@ +--- +name: pe-signoff-history-schema-review +description: PE sign-off 6-decision design review (S111, pre-fanout, prod-live) — schema/history lens; opinion-history mechanism pick + Decision-3 hard-lock caller-safety + A1 test breakage +metadata: + type: project +--- + +# PE sign-off change — schema/history lens review (GO-WITH-ADJUSTMENTS) + +Base = investigation `wf_fd8f2bfe-9f6` (PASS_WITH_CONCERNS). Design review BEFORE fan-out + prod deploy. My lens = SCHEMA/HISTORY (Decision 5 opinion-history + Decision 6 no-migrate). + +**Why (context):** anh Kiệt chốt 6 decisions (A1 bypass-only-below-creator; no self-approve-guard; EDGE-5 hard-lock DaDuyet/TuChoi; 1-button; opinion-history keep+edit+save-old; new-phiếu-only). Reviewing HOW, not re-debating WHAT. + +**How to apply (findings that held):** +- **Decision 5 mechanism — RECOMMEND (a) append-only `PurchaseEvaluationLevelOpinionHistory` table.** Reject (b) round/version-col: DESTRUCTIVE — must DROP existing UNIQUE `(PEId, ApprovalWorkflowLevelId)` at `PurchaseEvaluationLevelOpinionConfiguration.cs:30` + break UPSERT read `FirstOrDefault(PEId,LevelId)` at `PurchaseEvaluationWorkflowService.cs:766-768` (returns arbitrary version) at every read site. (c) Changelog = zero-migration fallback IF audit-tab-only display OK, but overloads `PurchaseEvaluationEntityType` (no LevelOpinion value; would reuse Workflow=5 already holding return-mode+transition logs). (a) = only additive CreateTable (safest mig class, no ALTER/backfill/index-change), reversible-by-drop, clean per-cell FE history. FK: Cascade on PEId (mirror parent), LOOSE-Guid on LevelId (avoid FK-Restrict-forever like parent `.cs:28`). +- **History hook = ApproveV2Async UPSERT else-branch `:784-790`** (capture existingOpinion BEFORE mutate). Bypass-path overwrite `:635-641` becomes DEAD under A1 (creator own-slot no longer auto-signed). +- **A1 × Decision-5 interaction (KEY):** under A1 the creator's own slot is NO LONGER auto-signed at submit → ONLY overwrite path = re-sign (TraLai-resubmit / return-mode-backward re-approve). History belongs at approve-UPSERT, not submit-bypass. +- **Decision 3 caller-safety VERIFIED:** only production caller of PE `TransitionAsync` = MediatR handler `PurchaseEvaluationFeatures.cs:523` via controller `/transitions`. `SlaExpiryJob.cs:100` uses `IContractWorkflowService` on `db.Contracts` — NOT PE. So top-of-method hard-lock guard `fromPhase∈{DaDuyet,TuChoi}→throw` is safe (no background-loop crash). EDGE-5 root cause = `EnsureCanRejectV2Async:321` early-returns when Phase!=ChoDuyet (does NOT block) → reject branch `:92-146` then mutates DaDuyet→TraLai. Guard must sit BEFORE reject branch + admin-override `:290` (block even Admin). +- **Decision 6 schema-safe:** new history table empty for old phiếu (auto no-backfill). Terminal old phiếu hard-locked post-D3 → never overwrite → never accrue history. BUT code changes are GLOBAL (no per-phiếu scheme flag) → "phiếu mới only" not literally enforced for in-flight old phiếu → clarify = no-backfill (auto) vs old-retain-old-semantics (needs flag = scope+). +- **Test breakage (spec change, §7 update-with-code):** `PeSubmitGuardAndBypassTests.cs` L465/L510/L580/L642 encode old S60 auto-sign-own-slot → BREAK under A1 (method names literally wrong e.g. `BypassesAllLevels_MovesToStep2`). L545 (drafter-not-in-step1) stays green. + +**Open (owner):** "cho sửa/cập-nhật" = re-sign-overwrite only (covered) vs NEW standalone edit-opinion endpoint (adds command+authz+own history-capture, NOT in lens scope). Verdict = GO-WITH-ADJUSTMENTS. diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L1-state.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L1-state.md new file mode 100644 index 0000000..952439f --- /dev/null +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L1-state.md @@ -0,0 +1,95 @@ +**HEADLINE:** GO-WITH-ADJUSTMENTS — A1 quy-tắc `drafterSlots.Min` là đúng về state-machine (không kẹt phiếu, không vỡ 5-state, creator ký tay chạy đúng qua ApproveV2Async có sẵn), nhưng cần 3 điều-chỉnh code chính-xác + viết lại 4 test bypass + 2 điểm chờ owner chốt (cấp không liền nhau + cơ-chế history) trước khi deploy prod live. + +## Lăng-kính STATE-MACHINE — Kết luận từng trục + +### 1. Validate A1 (decision 1): sửa bypass chỉ-subordinate — ĐÚNG, không kẹt phiếu + +Đọc chi tiết `PurchaseEvaluationWorkflowService.cs:571-681`. Code HIỆN TẠI dùng `k = drafterSlots.Max(l => l.Order)` (`:592`) rồi bypass + **auto-ký hộ** mọi cấp `1..k` (kể cả cấp của chính creator, `:617-642` UPSERT opinion canned comment). A1 muốn: chỉ bypass NV DƯỚI creator, creator KÝ TAY cấp mình. + +**Chuyển sang quy-tắc `Min` là chính xác và an-toàn state-machine.** Cụ thể: + +- `:592` đổi `k = drafterSlots.Max(...)` → `var minOwn = drafterSlots.Min(l => l.Order);` +- `:595` đổi `Where(o => o <= k)` → `Where(o => o < minOwn)` (chỉ cấp NV strictly dưới cấp thấp nhất của creator). +- `:597-643` — vòng `foreach`: vì mọi `order < minOwn` KHÔNG thể là slot của creator (minOwn là cấp thấp nhất của creator), nên nhánh `ownSlot is not null` (`:610-611`, `:617-642`) trở thành **dead code** → chỉ giữ Approval row "(bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)", XÓA hẳn khối UPSERT opinion. Không ghi hộ chữ ký ai. +- `:645-680` — thay TOÀN BỘ khối advance bằng: `if (minOwn > 1) { evaluation.CurrentApprovalLevelOrder = minOwn; await LogTransitionAsync(...ChoDuyet→ChoDuyet... "Bỏ qua Cấp 1..{minOwn-1}, chờ người soạn ký Cấp {minOwn}"); }`. **XÓA cả nhánh advance-sang-Bước-2 (`:657-666`) LẪN nhánh terminal-DaDuyet-khi-submit (`:667-680`)** — vì creator không còn được bypass qua chính cấp mình; pointer DỪNG tại `minOwn` để creator ký tay. + +**Không kẹt phiếu — chứng minh:** `minOwn` luôn là Order của một `drafterSlot` (level có thật trong firstStep, có ≥1 approver là creator). Pointer `(0, minOwn)` luôn hợp-lệ và luôn có approver ký được. Không có nhánh nào advance QUA creator, nên phiếu luôn dừng ở cấp creator ký được → không kẹt. + +**Creator ký tay chạy đúng qua `ApproveV2Async` có sẵn:** gate `:728-739` match `actorUserId ∈ pendingLevelGroup.ApproverUserId`. Creator LÀ approver tại `minOwn` → pass. Decision 2 (không guard cứng self-approve) → không có guard nào chặn creator duyệt cấp mình. FE cũng đã hỗ-trợ: `PeWorkflowPanel.tsx:98-103` `actorInV2Level = currentUser.id ∈ currentApproval.approvers` → creator THẤY nút "Duyệt" ngay tại cấp mình. **Không kẹt cả BE lẫn UX.** Không cần sửa FE cho luồng lõi A1. + +**Không vỡ 5-state:** submit vẫn set `Phase=ChoDuyet` (`:243`); bypass chỉ ghi Approval/Changelog + dịch `CurrentApprovalLevelOrder`, KHÔNG đổi Phase. Case terminal (1 bước, creator là cấp cuối) NAY tiến DaDuyet qua bước duyệt-tay bình-thường thay vì auto-terminal lúc submit → vẫn về đúng DaDuyet, chỉ khác thời-điểm (creator bấm Duyệt). + +### 2. Case creator chiếm cấp KHÔNG liền nhau (Cấp1 + Cấp3) — MURKY, cần chốt + +Với quy-tắc `Min`: `drafterSlots={1,3}` → `minOwn=1` → `bypassedOrders = {o < 1} = {}` (rỗng) → KHÔNG bypass gì, pointer đứng `(0,1)`. Diễn-tiến: creator ký tay Cấp 1 → advance Cấp 2 (người KHÁC ký) → advance Cấp 3 (creator ký tay lần 2) → advance tiếp. + +- **Không kẹt** (pointer luôn advance), **không vỡ 5-state**. Đây là hành-vi AN-TOÀN NHẤT và khớp ĐÚNG câu chữ decision ("bypass order < drafterSlots.Min"). +- **Nhưng murky về nghiệp-vụ:** creator ký 2 lần (Cấp 1 + Cấp 3), và người Cấp 2 (kẹp giữa 2 slot của creator) KHÔNG được bypass dù creator cũng ngồi Cấp 3 cao hơn. Rationale gốc S60 ("sếp tạo thì NV dưới khỏi duyệt lại") không phủ rõ tình-huống creator trải nhiều cấp không liền. +- **Đề xuất:** GIỮ quy-tắc `Min` (conservative, đúng câu chữ, không tự-ý bypass Cấp 2 mà creator chưa "vượt" ở slot thấp nhất). Ghi 1 comment trong code nêu rõ hành-vi non-adjacent. **Cần anh Kiệt xác-nhận** chấp-nhận "creator ký 2 lần + Cấp 2 vẫn duyệt". (Config này hiếm — Designer chỉ chặn duplicate cùng-cấp, cho phép 1 người ở Cấp 1 và Cấp 3.) + +### 3. Reject-return SAU A1 — CÒN ĐÚNG + +`EnsureCanRejectV2Async:316-341` + `ApplyReturnModeAsync:348-548` thao-tác theo POINTER hiện-tại, không phụ-thuộc cách bypass tính. Sau A1 pointer dừng tại `minOwn` (một cấp có thật) → mọi mode OneLevel/OneStep/Assignee/Drafter resolve bình-thường. + +- Lưu ý (pre-existing, KHÔNG do A1): nếu approver "Trả lại 1 Cấp" từ `(0, minOwn)` xuống cấp `< minOwn` (đã bypass lúc submit), phiếu re-pend tại cấp NV từng bị bỏ qua. Đây là hành-vi ĐÚNG của Return (approver chủ-động yêu-cầu cấp dưới xem lại) — bypass chỉ là tiện-ích lúc submit, không phải skip vĩnh-viễn. Hành-vi này y-hệt code hiện-tại, A1 không đổi → out-of-scope, chỉ note. + +### 4. Decision 3 (HARD-LOCK EDGE-5) — BẮT BUỘC thêm guard, đã verify đóng đúng lỗ + +**Xác nhận lỗ EDGE-5 CÓ THẬT:** hiện `EnsureCanRejectV2Async:321` `if (Phase != ChoDuyet) return;` → phiếu **DaDuyet + Reject** bỏ qua guard → vào `ApplyReturnModeAsync`, pointer DaDuyet đã null → mode Drafter default (`:418-424`) set `Phase=TraLai`. **Phiếu ĐÃ DUYỆT bị hồi về Trả-lại** — đúng bug decision 3 muốn khoá. Tương-tự TuChoi + Reject → hồi TraLai; và Admin override `:290-298` chuyển DaDuyet đi bất-kỳ đâu. + +**Fix chính-xác:** chèn ngay SAU `:55` (sau khi tính `isSystem`), TRƯỚC block `:57`: +``` +if (fromPhase == PurchaseEvaluationPhase.DaDuyet || fromPhase == PurchaseEvaluationPhase.TuChoi) + throw new ConflictException("Phiếu đã ở trạng thái cuối (Đã duyệt / Từ chối) — không thể Trả lại / Từ chối / chuyển trạng thái."); +``` +Đặt ở ĐẦU chặn CẢ reject-branch (`:92`), approve-step (`:268`) LẪN admin-override (`:290`) → "chặn MỌI transition" như decision. `EnsureCanRejectV2Async:321` giữ nguyên (redundant cho terminal nhưng vô-hại + còn phòng-thủ phase non-terminal khác). + +**Không regression:** grep tests chỉ thấy 2 nơi set `Phase=DaDuyet` (`PeFinalizeProjectionTests.cs:84`, `PeListWinnerNamesProjectionTests.cs:56`) — đều là test LIST/projection, KHÔNG gọi `TransitionAsync` trên phiếu DaDuyet. Contract-from-evaluation là command riêng, không qua `TransitionAsync`. Delete guard (`PurchaseEvaluationFeatures.cs:1347`) độc-lập, không đụng. An-toàn. + +### 5. Decision 4+5 (1-nút + history) tương-tác state-machine + +- **1-nút (decision 4):** `ApproveV2Async` vốn đã là 1 hành-động save=advance → giữ nguyên, không đụng state-machine. Sau A1 creator chỉ thêm 1 cú bấm Duyệt (trước auto-ký) — đúng ý decision 1. +- **Opinion history (decision 5) — cảnh-báo schema + state-neutral:** `PurchaseEvaluationLevelOpinion` UPSERT theo key `(PE, ApprovalWorkflowLevelId)` (`ApproveV2Async:763-790`), entity có UNIQUE composite `(PEId, LevelId)`. Muốn giữ history: + - **Nếu chọn append-only cùng bảng** → PHẢI drop/sửa UNIQUE index → cần migration 3-file (gotcha #17). Flag Category 2. + - **Đề-xuất MVP không migration:** trước khi overwrite opinion, ghi 1 `PurchaseEvaluationChangelog` snapshot comment cũ (bảng audit-log đã sẵn) → giữ history mà không đụng schema. + - **State-machine guardrail BẮT BUỘC:** cơ-chế sửa/cập-nhật opinion PHẢI đi endpoint/command RIÊNG, chỉ ghi opinion + history + changelog, **TUYỆT ĐỐI không route qua `TransitionAsync`** (tránh vô-tình advance pointer / đổi Phase). Đây là rủi-ro state-machine lớn nhất của decision 5. + +### 6. BONUS CATCH — thay-đổi hành-vi giá-chốt (cần biết trước UAT) + +Nhánh terminal-on-submit hiện-tại (`:667-680`) set DaDuyet mà **KHÔNG gọi `ApplyApprovedPriceOnFinalize`** → phiếu solo-drafter 1-bước hiện về DaDuyet với `ApprovedPriceAmount = null` (lỗ hổng thiếu giá chốt). Sau A1, creator ký tay cấp cuối đi qua `ApproveV2Async:942` → **BẮT BUỘC chọn giá chốt** (`:970-972` throw nếu thiếu, non-system). Đây là **đóng gap tốt** (creator = người duyệt cuối thật thì phải chọn giá) + FE đã lo (`PeWorkflowPanel.tsx:115,174` `currentIsFinalApprover` hiện bộ chọn giá). Nhưng là behavior-change: cần test + heads-up UAT "duyệt cấp cuối của phiếu mình phải chọn giá chốt". + +--- + +## Test cần cập-nhật (`PeSubmitGuardAndBypassTests.cs`) + +Baseline hiện encode hành-vi CŨ (auto-ký cấp creator). Sau A1: + +| Test | Hiện | Sau A1 | Hành-động | +|---|---|---|---| +| (9) `Submit_DrafterIsTopLevelOfFirstStep_...MovesToStep2` (`:464`) | k=2 max → StepIdx=1, 2 AutoApprove, 1 opinion | minOwn=2, bypass Cấp1, pointer `(0,2)`, 1 AutoApprove, **0 opinion** (creator chưa ký) | **Viết lại** | +| (10) `Submit_DrafterIsLevel1...MovesToLevel2SameStep` (`:509`) | k=1, pointer Cấp2, 1 AutoApprove, 1 opinion | minOwn=1, **0 bypass**, pointer đứng `(0,1)`, 0 AutoApprove, 0 opinion | **Viết lại** (case "Creator Cấp-1 → đứng (0,1)") | +| (11) `Submit_DrafterNotInFirstStep_NoBypass` (`:544`) | pointer `(0,1)`, 0 bypass | KHÔNG đổi | **Giữ** | +| (12) `Submit_OneStepWorkflow_DrafterIsLastLevel_TerminalDaDuyet` (`:579`) | terminal DaDuyet lúc submit, pointers null, 2 AutoApprove | minOwn=2, bypass Cấp1, pointer `(0,2)` Phase=**ChoDuyet** (KHÔNG DaDuyet), 1 AutoApprove | **Viết lại** | +| (14) `Resubmit_FromTraLai_ReAppliesBypass` (`:642`) | mỗi submit terminal DaDuyet | submit→ChoDuyet `(0,1)`, KHÔNG terminal | **Viết lại** (resubmit vẫn 0-bypass vì solo Cấp1) | + +**Test MỚI cần thêm:** +1. `Submit_DrafterAtLevel2_SignsOwnManually_ThenApproveReachesNext` — sau submit pointer `(0,minOwn)`; gọi `ApproveV2Async` bằng chính creator → advance đúng (creator ký tay chạy được). +2. `Submit_DrafterNonAdjacentLevels_Cap1AndCap3_NoBypass_SignsTwice` — chốt hành-vi murky mục 2. +3. `Transition_FromDaDuyet_AnyReject_ThrowsHardLock` + `Transition_FromTuChoi_ThrowsHardLock` + `Transition_FromDaDuyet_AdminOverride_AlsoBlocked` — regression EDGE-5 (test-before-fix theo §7 vì là bug fix). +4. `SoloDrafterFinalApprove_RequiresApprovedPrice` — behavior-change mục 6. + +## Risk trước deploy prod live +- **Cao:** decision 5 nếu route qua TransitionAsync → vỡ pointer. Enforce endpoint riêng. +- **Trung:** 4 test cũ FAIL nếu không sửa cùng commit → CI đỏ (đúng, vì spec-change → update test cùng commit §7). Đừng để lọt. +- **Thấp:** non-adjacent chờ chốt; nếu deploy trước khi chốt, quy-tắc Min vẫn KHÔNG kẹt phiếu (an-toàn để ship, chỉnh nghiệp-vụ sau nếu cần). + +## Open Questions +Còn 4 quyết-định chờ owner (anh Kiệt) chốt trước/ngay khi fan-out: + +1. **[anh Kiệt] Cấp không liền nhau (Cấp1+Cấp3):** xác-nhận quy-tắc `Min` — creator ký TAY cả Cấp 1 lẫn Cấp 3, người Cấp 2 (kẹp giữa) VẪN phải duyệt. Nếu không muốn vậy thì phải định-nghĩa rule khác (nhưng mọi rule "bypass tới Max" đều mâu-thuẫn decision 1 "ký tay"). Khuyến-nghị: giữ Min. + +2. **[anh Kiệt + AI_INFRA/schema] Cơ-chế opinion history:** chọn (a) reuse `PurchaseEvaluationChangelog` snapshot — KHÔNG migration (đề-xuất MVP), hay (b) bảng history mới / append-only cùng bảng — CẦN migration 3-file + drop UNIQUE `(PEId, LevelId)`. Quyết định này chặn thiết-kế endpoint sửa opinion. + +3. **[anh Kiệt] Opinion có được sửa SAU khi phiếu DaDuyet không?** Căng với decision 3 ("đã duyệt là cứng"). Hard-lock chặn TRANSITION, nhưng sửa ý-kiến (không đổi phase) là vùng xám — cho phép hay khoá luôn? + +4. **[anh Kiệt] Xác-nhận behavior-change giá-chốt:** phiếu solo-drafter / creator-là-cấp-cuối NAY bắt creator chọn giá chốt khi bấm Duyệt lần cuối (trước đây auto-DaDuyet bỏ qua). Đúng ý "người duyệt cuối phải chọn 1 giá" — chỉ cần xác-nhận không phá UAT. diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L2-security.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L2-security.md new file mode 100644 index 0000000..60b1508 --- /dev/null +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/review-L2-security.md @@ -0,0 +1,111 @@ +**HEADLINE:** GO-WITH-ADJUSTMENTS — hướng đúng, nhưng guard EDGE-5 theo spec BỎ SÓT đường admin-override (SVC:290) nên chưa "cứng" thật, và fix A1 MỞ một đường CEO-skip MỚI cần anh Kiệt chốt trước khi fan-out + deploy prod. + +## Lăng-kính SECURITY / GOVERNANCE — kết quả kiểm + +Tôi đã đọc source thật (không tin framing của spec/investigation). Hai vấn đề trọng tâm được xác minh dưới đây, kèm 4 mục phụ. + +--- + +### 1. EDGE-5 hard-lock (Decision 3) — CẦN ĐIỀU CHỈNH vị trí guard (must-fix trước implement) + +**Lỗ hổng hiện tại đã xác nhận (đúng EDGE-5):** một phiếu `DaDuyet` bị lật ngược được ngay hôm nay. Truy vết `TransitionAsync`: decision=Reject vào reject-branch (SVC:92) → `EnsureCanRejectV2Async` **early-return im lặng tại SVC:321** vì `Phase != ChoDuyet` (bỏ qua actor-scope) → `ApplyReturnModeAsync` mode mặc định Drafter → `evaluation.Phase = TraLai` (SVC:420). Phiếu đã duyệt quay lại Trả lại. Xác nhận. + +**Spec đề xuất đặt guard ở: reject-branch (SVC:92) + `EnsureCanRejectV2Async` :321 + handler FEAT:510-539 + controller. Đây là INCOMPLETE.** Nó phủ được đường reject/return, nhưng **bỏ sót đường admin-override tại SVC:290**: + +``` +289 // Admin manual override (vd test cứng phase) +290 if (isAdmin) { evaluation.Phase = targetPhase; ... return; } +``` + +Một Admin gửi `decision=Approve`, `targetPhase=` trên phiếu `DaDuyet`: KHÔNG vào reject-branch (SVC:92 đòi decision==Reject), KHÔNG vào approve-branch (SVC:268 đòi fromPhase==ChoDuyet) → rơi thẳng xuống SVC:290 → **un-terminal phiếu**. Decision 3 nói "chặn MỌI reject/trả-lại/transition" + "Đã duyệt rồi là cứng" ⇒ phải chặn CẢ Admin. Guard chỉ đặt ở reject-branch KHÔNG đạt yêu cầu này. + +**ĐỀ XUẤT CHÍNH XÁC (1 guard duy nhất, đầu method):** chèn ngay sau `var fromPhase = evaluation.Phase;` tại **SVC:53** (trước guard TraLai/TuChoi hiện có ở SVC:57): + +``` +if (fromPhase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi) + throw new ConflictException( + $"Phiếu đã ở trạng thái kết thúc ({fromPhase}) — không thể trả lại, từ chối hay chuyển trạng thái. Đã duyệt/từ chối là cố định."); +``` + +**Chứng minh ĐỦ KÍN — 5 đường ra của `TransitionAsync` đều nằm SAU SVC:53:** + +| Đường | Vị trí | Guard đầu method chặn? | +|---|---|---| +| Reject / 4 return-mode (qua `ApplyReturnModeAsync`, private, caller duy nhất = SVC:118) | SVC:92 | ✅ | +| Drafter submit/resubmit | SVC:150 | ✅ | +| Approve step (ApproveV2/V1) | SVC:268 | ✅ | +| **Admin override** | SVC:290 | ✅ (đây là chỗ spec bỏ sót) | +| Fall-through throw | SVC:300 | ✅ | + +**Bài học pattern (ghi diary):** terminal-lock phải gác **FROM-state ở ĐỈNH method**, KHÔNG rải guard per-branch — rải per-branch luôn bỏ sót nhánh admin/fall-through. + +**Lưu ý thêm:** +- **KHÔNG sửa semantics `EnsureCanRejectV2Async` :321.** Các early-return của nó phục vụ ca hợp lệ (system/cron reject phase khác). Khi đã có guard đỉnh-method, không cần đụng :321; đụng vào có rủi ro vỡ đường system. +- Controller PurchaseEvaluationsController :130-143 chỉ có `[Authorize]` class-level (đúng pattern gotcha #44), không kiểm phase; handler FEAT:510-539 nạp entity nhưng cũng không kiểm phase ⇒ **service là cổng duy nhất**, nên đặt ở đầu service là single-source đúng. Thêm guard ở handler chỉ là defense-in-depth thừa, KHÔNG bắt buộc. +- **Hệ quả cần anh Kiệt biết:** guard này gỡ luôn "escape hatch" của Admin (SVC:290) để lật một phiếu bị finalize nhầm — sau này chỉ sửa được bằng DB. Đây đúng là ý "cứng", nhưng cần xác nhận chấp nhận. + +--- + +### 2. Decision 2 (không hard-guard self-approve) — RỦI RO CEO-SKIP, phải escalate anh Kiệt + +**Xác minh chuỗi:** `DrafterUserId = currentUser.UserId` (FEAT:147) ⇒ creator chính là drafter. Sau A1, pointer đứng tại ô Cấp thấp nhất của creator; creator duyệt qua `ApproveV2Async`, match-approver SVC:728-739 CHO QUA vì creator ∈ `ApproverUserId`. + +**Đường CEO-skip MỚI do chính A1 tạo ra:** nếu ô của creator có `AllowApproverFinalize=true`, `ApproveV2Async` **SVC:867** (`if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)`) set `Phase=DaDuyet` → **bỏ qua mọi Bước/Cấp sau, kể cả CEO**. FE `PeWorkflowPanel.tsx:203` gửi `applyLevelFinalize` = giá trị ô-tick khi `approverFinalizeEligible`; creator VẪN thấy nút "✓ Duyệt" (nút Trả lại/Từ chối bị ẩn tại :446, nhưng Duyệt thì không). Mặc định ô-tick = false (opt-in S96/S97) ⇒ cần creator **chủ động tích**, nhưng hệ thống CHO PHÉP. + +**Đính chính framing của investigation (giá trị độc lập):** đường này **KHÔNG tồn tại trước A1** với workflow nhiều-bước-có-CEO. Trước A1, `ApplyDrafterBypassOnSubmitAsync` tự advance VƯỢT QUA ô creator (SVC:645-666) mà KHÔNG hề gọi `AllowApproverFinalize` ⇒ CEO không bị bỏ. Ca self-finalize duy nhất tồn-tại-nay là 1-bước-drafter-là-cấp-cuối (SVC:667-680) — ca đó **không có bước CEO nào cả**. Vậy A1 **thực sự MỞ RỘNG bề mặt phơi nhiễm**; câu invest-C "đã tồn tại hôm nay" là IMPRECISE (chỉ đúng cho 1-step-no-CEO). + +**`skipToFinal` KHÔNG phải CEO-skip:** nó chỉ nhảy pointer tới Cấp cuối, Phase giữ `ChoDuyet`, người cuối (CEO) VẪN phải ký (SVC:805-846). Chỉ nguy nếu creator ĐỒNG THỜI là approver Cấp cuối — cấu hình bệnh lý, rủi ro thấp. + +**Escalate:** lý do Decision 2 ("cấp chỉ 1 người → tạo và duyệt luôn được") nói về việc DUYỆT một cấp một-người, KHÔNG nói về việc trao cho creator quyền tự KẾT THÚC phiếu bỏ CEO. Đây nhiều khả năng NGOÀI Ý. Xem openQuestion 1. + +**Nếu anh Kiệt muốn bịt (đề xuất chính xác, tôn trọng Decision 2):** guard hẹp chỉ chặn self-**FINALIZE** (không phải self-approve), đặt tại **SVC:867** và **SVC:805**: +``` +// tại SVC:867, thêm điều kiện trước khi finalize: +if (matchingLevel.AllowApproverFinalize && applyLevelFinalize + && actorUserId == evaluation.DrafterUserId && !isAdmin && !isSystem) + → soft-warn hoặc throw (tùy anh Kiệt chốt a/b/c) +``` +Cách này khác guard B2 mà Decision 2 đã bác (B2 = Forbidden cứng lên self-approve); guard hẹp này chỉ chạm hành vi self-finalize. + +--- + +### 3. Ghi chú triển khai A1 (liên quan governance) + +- **Phải sửa ĐỒNG THỜI cả logic advance (SVC:645-680) lẫn `bypassedOrders` (SVC:595) + `k` (SVC:592).** Nếu chỉ đổi `bypassedOrders` mà advance vẫn nhảy vượt `k`, ô của creator sẽ bị bỏ qua mà chưa ai ký → phiếu kẹt hoặc mất chữ ký cấp creator. Yêu cầu: chỉ auto-sign `order < drafterSlots.Min`, set pointer = `drafterSlots.Min` (không advance qua). +- Điểm tích cực governance: chuyển từ auto-bypass ký-hộ (comment đóng hộp) sang creator ký tay comment thật (`SignedByUserId=creator`) là **nâng cấp** minh bạch audit, không phải nới lỏng. + +--- + +### 4. Mục phụ (anh Kiệt nắm — không chặn deploy) + +- **Admin ghi trên phiếu terminal:** `AdjustPurchaseEvaluationBudgetCommandHandler` bỏ TOÀN BỘ kiểm phase cho Admin (FEAT:367 `if (!isAdmin)`) ⇒ Admin sửa ngân sách được trên phiếu `DaDuyet`. Các endpoint suggested-price / ccm-period cũng theo pattern role-gate tương tự. Nếu "không thao tác gì được" (spec:8) hiểu chặt thì đây là đường ghi-terminal còn lại của Admin. Giảm nhẹ: `ApprovedPriceAmount` là snapshot (SVC:978) nên sửa giá sau finalize không đổi số đã chốt. Xem openQuestion 2. +- **Pre-existing minor (không thuộc thay đổi này):** reject từ `DangSoanThao`/`TraLai` — `EnsureCanRejectV2Async` :321 bỏ actor-scope cho phase ≠ ChoDuyet ⇒ user bất kỳ có thể đẩy phiếu Nháp của người khác sang TraLai (griefing). Guard đỉnh-method ở mục 1 KHÔNG che ca này (chỉ chặn DaDuyet/TuChoi). Ghi nhận, không đề xuất sửa trong scope này. + +--- + +### 5. Test cần thêm/cập nhật (rule §7 — bug-fix + security = test-before) + +- **MỚI (bắt buộc, EDGE-5):** `Transition_FromDaDuyet_RejectToTraLai_Throws`, **`Transition_FromDaDuyet_AdminApprove_Throws`** (đúng đường SVC:290 mà spec bỏ sót), `Transition_FromTuChoi_AnyDecision_Throws`. Grep xác nhận HIỆN CHƯA có test terminal-lock reject nào. +- **CẬP NHẬT (spec-change A1):** `PeSubmitGuardAndBypassTests.cs` — `Submit_OneStepWorkflow_DrafterIsLastLevel_TerminalDaDuyet` (:580, nay phải giữ `ChoDuyet` chờ creator ký), assertion `HaveCount(2)`/`HaveCount(1)` bypass-count (:505/:540), test resubmit-terminal (:645-675). +- Nếu chọn guard finalize (2c): thêm test creator-finalize-slot bị chặn/cảnh báo. + +--- + +### 6. Rủi ro DEPLOY (prod ERP live) + +- **A1 + EDGE-5 = code-only, không migration** ⇒ rủi ro thấp. Decision 6 (không migrate phiếu cũ) an toàn: phiếu cũ giữ comment auto-bypass, không đụng schema. +- **Decision 5 (opinion history) MỚI cần migration** — đây là mảnh rủi ro cao hơn, thuộc lăng-kính schema/history (đã có lane riêng: xem Area-memory `project_pe_signoff_history_schema_review.md`). Không thuộc phán quyết của tôi. +- **Cảnh báo:** nếu deploy mà đặt guard EDGE-5 theo spec (chỉ reject-branch), Decision 3 KHÔNG thực sự đạt (còn hở Admin) → sẽ là một "done" giả. Phải dùng guard đỉnh-method mục 1. + +**Kết luận:** thiết kế đúng hướng và các adjustment đều nhỏ, khả thi ⇒ **GO-WITH-ADJUSTMENTS** (không NO-GO): (a) đổi vị trí guard EDGE-5 sang đỉnh-method SVC:53, (b) anh Kiệt chốt openQuestion 1 (CEO-skip) trước khi fan-out. + +## Open Questions +Các quyết định còn lại thuộc owner (anh Kiệt) — cần chốt TRƯỚC khi fan-out implement + deploy: + +1. **[PRIMARY — chặn implement câu 2] CEO-skip qua ô finalize của creator:** Decision 2 "tạo và duyệt luôn được" có bao gồm việc creator TỰ KẾT THÚC phiếu (bỏ CEO) khi ô Cấp của họ bật `AllowApproverFinalize` không, hay chỉ áp cho cấp KHÔNG-terminal? A1 mở đường này MỚI (trước không có với workflow đa-bước-có-CEO). Chọn: (a) chấp nhận như hiện tại; (b) cảnh báo mềm trên UI khi creator sắp tự-finalize; (c) guard hẹp chặn self-finalize (actor==Drafter) tại SVC:867 + SVC:805 — khác guard B2 mà anh đã bác vì chỉ chạm self-FINALIZE, không chạm self-approve. + +2. **[Phạm vi Decision 3]** "Đã duyệt rồi là cứng không thao tác gì được" có mở rộng tới việc khóa Admin sửa ngân sách / giá trên phiếu DaDuyet (FEAT:367 admin bỏ kiểm phase) không, hay Decision 3 chỉ giới hạn ở transition/reject/trả-lại? (mục phụ 4) + +3. **[Xác nhận hệ quả]** Guard hard-lock đỉnh-method gỡ luôn khả năng Admin-override lật phiếu terminal (SVC:290) — phiếu finalize nhầm sẽ chỉ sửa được bằng DB. Anh Kiệt xác nhận chấp nhận mất escape-hatch này? (đúng ý "cứng" nhưng cần biết rõ). + +Ghi chú: Decision 5 (cơ chế lưu history opinion + migration) đã có lane schema/history phụ trách — không nằm trong phán quyết security/governance của tôi. diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/run.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/run.md index 5d7393b..6b9409e 100644 --- a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/run.md +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-close-review/run.md @@ -16,5 +16,13 @@ ## deliverable Validated plan + điều-chỉnh + file:line + test-list + DEPLOY-risk + GO/NO-GO → em synthesize → fan-out implement → reviewer → deploy. -## synthesis -_(pending)_ +## synthesis — GO-WITH-ADJUSTMENTS (wf_7df56b89-7bb, 2/3 lane; L3 #53-garble, L1 phủ scope history) + +- **A1 (L1+L2 confirm correct+safe):** đổi `:592` k→`minOwn=drafterSlots.Min` · `:595` `Where o `/fable-clone review` đóng-spec (anh-directed): soi toàn feature BE+FE + chốt adjust + GO/NO-GO deploy. Opus 4.8 MAX. Feature = Supplier Phase B Approach A (upload Excel NCC). + +## Trạng thái feature (code-complete) +- **BE** (implementer-backend, build EXIT-0): entity +SourceUpdatedAt/By · Mig 63 `AddSupplierImportSourceFields` 3-file · `SupplierExcelImportService` (PreviewAsync/ConfirmAsync) · 2 CQRS command · 2 endpoint `import/preview`+`import/confirm` [Authorize Admin,CatalogManager] · DTO · DI. +- **Test** (test-specialist): `SupplierExcelImportServiceTests` 10 test → `dotnet test` **450 PASS** (440→450), 0 prod-bug. Case-collation test có RĂNG (SQLite BINARY → dedup ở service OrdinalIgnoreCase). +- **FE** (implementer-frontend, npm build ×2 PASS): `SupplierImportDialog.tsx` 2-app byte-identical SHA-verified + `SuppliersPage` nút "Import Excel NCC". upload→preview(layoutValid/counts/table)→confirm→toast+invalidate. +- **5 decision** đã áp: Code=col4 · +2 field · fill-nulls-safe · Type-lạ→NhaCungCap · A-scoped-layout-file-này. + +## 🔴 ADJUST đã biết (bake trong pha adjust sau review) +`ExpectedHeaderTokens` trong `SupplierExcelImportService.cs:35-67` = BEST-GUESS (file thật chưa ở repo lúc scaffold) → hiện **reject file thật**. Bake 30 token THẬT (đã extract từ Excel, `NormalizeHeader` upper+collapse nên tolerant): +``` +STT · GÓI THẦU · PHÂN LOẠI (NTP/NCC/Cả hai) · TÊN VIẾT TẮT (Dùng trong HĐ) · TÊN CÔNG TY (Đầy đủ, đúng pháp lý) · ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh) · ĐỊA CHỈ VĂN PHÒNG (nếu có) · SỐ ĐIỆN THOẠI CÔNG TY · FAX · SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG (Đầy đủ, đúng pháp lý) · SỐ TK PHỤ (nếu có) · MÃ SỐ THUẾ · NGƯỜI ĐẠI DIỆN PHÁP LUẬT · CHỨC VỤ ĐẠI DIỆN · GIẤY ỦY QUYỀN (số, ngày, người ủy quyền) · Link GUQ · Link GPKD · Link HSNL · NGƯỜI LIÊN HỆ CHÍNH · CHỨC VỤ NGƯỜI LH · SĐT CHÍNH · EMAIL · ĐỊA CHỈ GỬI THƯ · NGƯỜI NHẬN THƯ/ SDT · NGUỒN GIỚI THIỆU · NGƯỜI PHỤ TRÁCH (PMH) · TÌNH TRẠNG HIỆN TẠI · GHI CHÚ / LÝ DO BLACKLIST · NGÀY CẬP NHẬT CUỐI · NGƯỜI CẬP NHẬT +``` +Test Case-9 dùng reflection → tự lật `LayoutValid=true` khi bake, 0-test-edit. + +## Checklist review (2 lane đối kháng) +- **L1 (BE + adjust):** verify case-fix (OrdinalIgnoreCase preview+confirm) · fill-nulls không đè non-null · all-or-nothing · parser abs-index+#REF!+backslash-raw · Type/Status map · Mig 63 reversible. Confirm bake ExpectedHeaderTokens (30 real-token) đủ + đúng thứ tự cột→field. Bất kỳ BE gap trước deploy. +- **L2 (FE + e2e + deploy):** FE flow upload/preview/confirm khớp contract (enum-int, camelCase, rows round-trip verbatim) · 2-app mirror · error-handling (layoutValid=false, errorCount>0 disable) · e2e (upload file thật → preview → confirm sau khi bake header) · deploy-risk trên prod ERP live (4 NCC file này ĐÃ ở prod qua seed → confirm no-op an-toàn, không nhân đôi) · GO/NO-GO.