diff --git a/.claude/agent-memory/investigator-codebase/MEMORY.md b/.claude/agent-memory/investigator-codebase/MEMORY.md index eef4a5d..0e9ad9c 100644 --- a/.claude/agent-memory/investigator-codebase/MEMORY.md +++ b/.claude/agent-memory/investigator-codebase/MEMORY.md @@ -8,19 +8,14 @@ Read-only INTERNAL audit SOLUTION_ERP. Tools: Read/Grep/Glob/Bash + 5 RAG MCP. O - ❌ NOT: external docs/CVE/lib → investigator-api · write → implementer · test → test-specialist · architecture decision → em main ## 📌 Recent invest (1-line digest; full detail → archive/runs/git) -- **S155 (07-27) PE xóa-phiếu màn DUYỆT [24 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md`]:** DELETE `/api/purchase-evaluations/{id}` ĐÃ CÓ (`PurchaseEvaluationsController.cs:146`, authz = class `[Authorize]` TRẦN, 0 policy action-level toàn file) nhưng guard allow-list `{DangSoanThao,TuChoi}` (`PurchaseEvaluationFeatures.cs:1404`) chặn ChoDuyet. `Remove()`=**SOFT** gián tiếp qua `AuditingInterceptor.cs:56-62` (Deleted→Modified+IsDeleted/DeletedAt/DeletedBy) — lớp #81-EXT indirect. Lũy kế: `PeBudgetAccumulator.cs` 2 hàm/4 call-site DUY NHẤT (0 read-site khác, Reports 0-hit PE); ChoDuyet ăn số ở **2 chỗ** — `ComputeAsync:54` PrevSubmitted(ChoDuyet+DaDuyet) + `ComputePendingAsync:114` PendingSelected(∉{Nháp,DaDuyet,TuChoi}+winner); `TraLai` KHÔNG triệt tiêu; `TuChoi` rơi khỏi 100% phép cộng. Không dòng `IsDeleted` nào trong query → dựa global `HasQueryFilter` (`PurchaseEvaluationConfiguration.cs:84`), `IgnoreQueryFilters`=0-hit ⇒ soft-del TỰ rớt, không sửa accumulator. Snapshot Mig 67 KHÔNG re-compute. 🔴 Cascade 6 FK (`:75-80`) **không bao giờ chạy** vì soft ⇒ con `BaseEntity` (no IsDeleted) ở lại — rác ẩn, chưa lộ vì mọi read-site join từ PE root. **Git khảo cổ TRÚNG:** "chỗ cho hủy" = nút Từ chối, gỡ `6db195d` 12-06-2026 S60 (anh Kiệt "Duyệt hoặc Trả về thôi"); guard `PurchaseEvaluationWorkflowService.cs:101-106` còn sống + tự trỏ "Xóa phiếu khi còn Bản nháp" = **chính cái lỗ** S155 phải vá. Q7: 0 tiền lệ hủy chứng-từ in-flight; khuôn gần nhất `CancelMeetingBookingHandler` (`MeetingFeatures.cs:457`, Status=Cancelled NOT IsDeleted + owner-or-admin check). Tag `[s155, pe-delete-approver, s60-tuchoi-removal-hole, soft-delete-no-cascade]` +- **S155 (07-27) PE xóa-phiếu màn DUYỆT [24 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md`]:** DELETE `/api/purchase-evaluations/{id}` ĐÃ CÓ (`PurchaseEvaluationsController.cs:146`, authz = class `[Authorize]` TRẦN) nhưng allow-list `{DangSoanThao,TuChoi}` (`PurchaseEvaluationFeatures.cs:1404`) chặn ChoDuyet. `Remove()`=**SOFT** gián tiếp qua `AuditingInterceptor.cs:56-62` — lớp #81-EXT indirect. Lũy kế: `PeBudgetAccumulator.cs` 2 hàm/4 call-site DUY NHẤT; ChoDuyet ăn số **2 chỗ** (`ComputeAsync:54` PrevSubmitted + `ComputePendingAsync:114` PendingSelected); `TraLai` KHÔNG triệt tiêu; `TuChoi` rơi khỏi 100% phép cộng. Query 0 dòng `IsDeleted` → dựa global `HasQueryFilter` (`PurchaseEvaluationConfiguration.cs:84`) ⇒ soft-del TỰ rớt, không sửa accumulator. Snapshot Mig67 KHÔNG re-compute. 🔴 Cascade 6 FK (`:75-80`) **không bao giờ chạy** vì soft ⇒ con `BaseEntity` ở lại = rác ẩn. **Git khảo cổ TRÚNG:** "chỗ cho hủy" = nút Từ chối, gỡ `6db195d` 12-06 S60; guard `PurchaseEvaluationWorkflowService.cs:101-106` còn sống tự trỏ "Xóa phiếu khi còn Bản nháp" = chính cái lỗ. 0 tiền lệ hủy chứng-từ in-flight; khuôn gần nhất `CancelMeetingBookingHandler` (`MeetingFeatures.cs:457`, Status=Cancelled NOT IsDeleted). Tag `[s155, pe-delete-approver, s60-tuchoi-removal-hole, soft-delete-no-cascade]` - **S155 (07-27) PE xóa-phiếu FE/authz-slice [18 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md`]:** Khối HÀNH ĐỘNG = `components/pe/PeWorkflowPanel.tsx:458-506` **byte-identical 2 app** (md5 `02671be6…`, cùng PeDetailTabs/PeListPanel/ListPage) ⇒ sửa-1-bên-là-sót. Gate 3 tầng: `?pendingMe=1`→`readOnly={!pendingMe}` (`ListPage:590`/`:676`) · `next=workflow.nextPhases` BE-source (`:257`) · `blockedByV2Level` (`:99-103` isAdmin ∥ actor∈currentApproval.approvers). 🔴 Plumbing xóa ĐÃ NỐI SẴN vào màn duyệt: `onDelete={del.mutate}` truyền ở `ListPage:573`+`:668` (`api.delete('/purchase-evaluations/:id')` `:90`) nhưng nút chết vì `PeDetailTabs:445` đòi `mode==='workspace' && canEditPhase && !readOnly`; nút hiện chỉ ở `:460` `phase===DangSoanThao`. **Authz 2 tầng:** PE vùng FE = **0-hit** `usePermission|PermissionGuard` (guard chỉ sống ở Master+Users, khuôn `DepartmentsPage:101 action="Delete"`); `menuKeys.ts` KHÔNG có const `Pe_*` (chỉ root `:23`) — `Pe_*` sinh BE `MenuKeys.cs:134-141`, seed `DbInitializer:1870-1877`, FE khớp bằng regex `Layout.tsx:120` (fe-admin `:107` **LỆCH**, thiếu WfView). 🔴 `Pe_*` **KHÔNG ∈ `MenuKeys.All`** ⇒ `Program.cs:82-89` không đăng-ký policy `Pe_*.{action}` (0 dynamic provider) ⇒ dùng `[Authorize(Policy="Pe_…")]` = policy-chưa-tồn-tại; **dùng được ngay = `PurchaseEvaluations.Delete`** (root ∈ All, + `GetMyMenuTreeQuery:66-72` cho `Pe_*` KẾ THỪA cờ CRUD từ root ⇒ 0 key mới, 0 migration). Ma trận enumerate `db.MenuItems` (`PermissionFeatures.cs:20`) nên ô Delete đã hiện sẵn. Q6: thêm-nút KHÔNG kích 4-place (0 route/page/staticMap `Layout.tsx:56-106`); `Pe_*` resolve nhánh regex `:119-134` KHÔNG qua staticMap. Tag `[s155, pe-delete-fe-authz, two-app-byte-identical, pe-keys-not-in-All]` -- **S155 (07-27) PE menu/trash/dept-head [21 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md`]:** 🔴 Menu = **SEED DB** không hard-code FE — 4 mục con `Pe_` khai `DbInitializer.cs:1874-1877` (`"Duyệt"`=`:1877`); thêm mục = restart API, **0 migration** (upsert `:1888-1914` chỉ sửa `Order`, **KHÔNG đụng Label** ⇒ rename phải qua `labelBackfill:1918-1924` HOẶC `DisplayLabel` admin-UI mà `MenuDtos.cs:14` ghi rõ **CHỈ ăn fe-user**, fe-admin luôn Label gốc). 6-chỗ-mirror (không phải 4): +2 seed permission SONG SONG `:2092-2096`/`:2492-2496`; `menuKeys.ts` FE **0-hit `Pe_*`** (sinh regex `Layout.tsx:120`, fe-admin `:107` thiếu `WfView` = lệch sẵn). Đổi KEY = mồ côi **13 row/key × 10 key** (Permissions Dev) ⇒ giữ key chỉ đổi label. **`Đã duyệt` chạy được HÔM NAY**: `phase=` đã đủ 3 tầng (`Controller:23`→`Features:565`→`:620`), DaDuyet=7, FE `:39/:75/:395` sẵn ⇒ `?type=1&phase=7`, 0 route mới. `pendingMe` KHÔNG phải param BE — FE rẽ `/inbox` (`ListPage:61-68`) rồi lọc DaGuiDuyet client-side `:182`. Tự verify: `IgnoreQueryFilters` **0** src/Backend nhưng **15 hit tests** (khuôn `Suppliers.IgnoreQueryFilters().CountAsync()` `SupplierImportV2DedupTests`); `b.HasQueryFilter(`=12. An-toàn accumulator = **cấu trúc**: `PeBudgetAccumulator.cs:42`/`:100` dựng `peers` FRESH mỗi lần ⇒ chỉ rò nếu helper-dùng-chung/sửa `:84`/tái-dùng-đường-snapshot. 🎁 Changelog `: BaseEntity` (no IsDeleted) + query lọc CHỈ `PurchaseEvaluationId` (`:1424`) ⇒ lịch sử phiếu-xóa đọc được MIỄN PHÍ; cái chôn là DETAIL bundle (chỗ `IgnoreQueryFilters` thứ 2). 0 tiền lệ thùng-rác/restore (`IsDeleted=false` 0-hit; hit "khôi phục" duy nhất = STRING `EmployeeFeatures.cs:162` = ghost-wire). 🔴 **"Trưởng phòng" CÓ TÊN, CHƯA CÓ ĐỜI**: `Department.ManagerUserId` (`Department.cs:9` cmt "TPB") = **0/10 Dev** + **2 FE hardcode `managerUserId: null` ở payload SỬA** (`DepartmentsPage.tsx:43`/`:57`) ⇒ tự-xoá sau mỗi lần sửa phòng; 5 hit BE toàn DTO-passthrough. `Level.Order`=trình-tự KHÔNG thứ-bậc (`ApprovalWorkflow.cs:86`); "Kết thúc tại Cấp 2"=`AllowApproverFinalize` F5. `PositionLevel.TruongPhong` **33/33 NULL** (V1-legacy, V2 không đọc); role `DeptManager` 8 user nhưng không nói PHÒNG. 3 lối: ManagerUserId(0-mig,vá-2-FE+nhập-tay) · cờ `AllowApproverDelete` per-Level (1 AddColumn, **khớp khuôn F2/F3/F4/F5 4× cùng bảng**) · role∧DeptId(có-data,nghĩa-lỏng). Tag `[s155, menu-seed-db, phase-param-exists, changelog-survives-softdelete, dept-head-name-only]` -- **S153 (07-26) ctx soft-memory spec [fable-real]:** thư `0df10df4` sàn-7 → spec 9-task/24-check (`runs/2026-07-26-S153-ctx-adopt/sub-fable-invest-S153.md`). Findings đắt: SE **0 content-ts-reader sống** (session_ctx.py = git-ts+mtime; C10 tự khai thiếu nguồn LATENESS) ⇒ rào-2 canonical `_TS_KEY_RE` đặt session_ctx.py, máy-7-phép = EXTEND python (tái dụng `_SECRET_RES` 1-nguồn) KHÔNG .ps1 mới (2-sự-thật); `secrets_sweep` rglob TRỌN session-dir ⇒ `_mind` tự vào lưới @snapshot §3; `_mind` mới-nhất-ở-TRÊN ⟂ `_context` FLOW ở-CUỐI (bẫy chép-khuôn); (36)(b) máy-$0/pause + vai @2-bookend ⇒ O-1 carve-out KHỎI CẦN (pause §1 NGỪNG-spawn giữ nguyên); C11(b) filter `sub-*.md`+`^sub-[-.]` ⇒ đặt tên `sub-ctx-audit-*` lọt lưới ngay (kẽ trio `harness-*-return` đã queue #8 fix); roster 21 > hmw_width 20 = owner-note; (35)-(38) vết OWNER-DELEGATED khuôn `hmw_width._ratified_by`. Tag `[s153, ctx-mind-spec, ts-reader-absent, one-source-regex]` +- **S155 (07-27) PE menu/trash/dept-head [21 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md`]:** 🔴 Menu = **SEED DB** không hard-code FE — `Pe_` khai `DbInitializer.cs:1874-1877`; thêm mục = restart API, 0 migration (upsert `:1888-1914` chỉ sửa `Order`, **KHÔNG đụng Label** ⇒ rename qua `labelBackfill:1918-1924`; `DisplayLabel` **CHỈ ăn fe-user** `MenuDtos.cs:14`). 6-chỗ-mirror: +2 seed permission SONG SONG `:2092-2096`/`:2492-2496` + regex `Layout.tsx:120` (fe-admin `:107` thiếu `WfView`). Đổi KEY = mồ côi 13 row/key × 10 key ⇒ giữ key chỉ đổi label. **`Đã duyệt` chạy được HÔM NAY**: `phase=` đủ 3 tầng (`Controller:23`→`Features:565`→`:620`), DaDuyet=7 ⇒ `?type=1&phase=7`, 0 route mới. `pendingMe` KHÔNG phải param BE (FE rẽ `/inbox` `ListPage:61-68` + lọc client `:182`). `IgnoreQueryFilters` 0 src/Backend / 15 hit tests; `HasQueryFilter(`=12. An-toàn accumulator = **cấu trúc** (`PeBudgetAccumulator.cs:42`/`:100` dựng `peers` FRESH). 🎁 Changelog `: BaseEntity` + lọc CHỈ `PurchaseEvaluationId` (`:1424`) ⇒ lịch sử phiếu-xóa đọc MIỄN PHÍ; cái chôn = DETAIL bundle. 0 tiền lệ thùng-rác (hit "khôi phục" duy nhất = STRING `EmployeeFeatures.cs:162` ghost-wire). 🔴 **"Trưởng phòng" CÓ TÊN CHƯA CÓ ĐỜI**: `Department.ManagerUserId` **0/10 Dev** + 2 FE hardcode `managerUserId: null` payload SỬA (`DepartmentsPage.tsx:43`/`:57`) ⇒ tự-xoá mỗi lần sửa phòng. `PositionLevel.TruongPhong` 33/33 NULL (V1-legacy). Tag `[s155, menu-seed-db, phase-param-exists, changelog-survives-softdelete, dept-head-name-only]` +- **S155 (07-27) WF-V2 sửa-tại-chỗ vs version-mới [25 finding, `runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md`]:** 🔴 **0 lệnh Update** — `ApprovalWorkflowsV2Controller.cs` (54d TOÀN BỘ) chỉ GET/POST/PATCH-user-selectable/DELETE ⇒ sửa 1 cờ = buộc POST = `MAX(Version per Code)+1` (`ApprovalWorkflowV2AdminFeatures.cs:325-328`) + hạ MỌI active cùng type (`:330-334`). Chặn THẬT bằng mã. 🔴 **F17:** POST tạo entity mới ⇒ **Id mới** (`:336`+`BaseEntity.cs:5`) mà phiếu đọc workflow theo **Id đã pin** (`PurchaseEvaluationWorkflowService.cs:674`) ⇒ **cờ mới KHÔNG tới phiếu đang chạy**; re-pin chỉ `PurchaseEvaluationFeatures.cs:149`/`:291` mà `:249-251` chặn chỉ Nháp/TraLai ⇒ ChoDuyet kẹt bản cũ vĩnh viễn; 0 backfill re-pin. ⇒ cờ `AllowApproverDelete` **HỎNG nếu không có lệnh Update**. Pin = **tham chiếu SỐNG** (chỉ NS có snapshot Mig67). Con trỏ **LAI**: Bước=INDEX `steps[currentIdx]` (`:686` vỡ ÂM THẦM/`:683` kẹt) · Cấp=ORDER-VALUE (`:694`→`:695` kẹt) ⇒ ranh AN-TOÀN = **không đổi tập `{Step.Order}`∪`{Level.Order}`**. 13 thao tác: 3 an-toàn (thêm-người-cùng-Order · cờ `Allow*` · đổi tên) · 2 nửa (bớt/đổi `ApproverUserId` → 403 GUID-trần `:705`, FK Restrict `PeLevelOpinionConfiguration:25-28`) · 6 phá-vỡ · 2 hỏi-owner (`CeoApprovalThreshold`·`Step.DepartmentId`). OR-of-N XÁC NHẬN (`:689` GroupBy Order) ⇒ owner không tự mâu thuẫn; ⚠️ comment `ApprovalWorkflow.cs:81-82` SAI *"KHÔNG OR-of-many"*. Blast 6+ module (198 hit/23 file). Designer **fe-admin ONLY** (`ApprovalWorkflowsV2Page.tsx`), "Nhân bản"→*"Tạo quy trình mới"*; **0 cảnh báo đang-dùng/0 disable IsActive**. F6 tick `:999-1007` +dây `:54/:110/:159/:177/:605` + fe-user `WorkflowMatrixViewPage.tsx:282` + BE `:41/:230/:188/:368`. CẤM delete-then-insert Levels → diff theo `Level.Id`. Tag `[s155, wf-v2-no-update-cmd, new-version-new-id-unreachable, pointer-index-vs-order]` +- **S153 (07-26) ctx soft-memory spec [fable-real, detail → `runs/2026-07-26-S153-ctx-adopt/sub-fable-invest-S153.md`]:** SE **0 content-ts-reader sống** (session_ctx.py = git-ts+mtime) ⇒ rào-2 canonical `_TS_KEY_RE` đặt session_ctx.py, máy-7-phép = EXTEND python (tái dụng `_SECRET_RES` 1-nguồn) KHÔNG .ps1 mới; `secrets_sweep` rglob TRỌN session-dir ⇒ `_mind` tự vào lưới; `_mind` mới-nhất-ở-TRÊN ⟂ `_context` FLOW ở-CUỐI (bẫy chép-khuôn); C11(b) filter `^sub-[-.]` ⇒ tên `sub-ctx-audit-*` lọt lưới ngay. Tag `[s153, ctx-mind-spec, ts-reader-absent, one-source-regex]` - **S148 (07-23) PE tree DUP project by-space:** `FLOCK 01`/`FLOCK01` = **path-A TWO real Project rows** diff-by-space. Tree groups on `p.projectId` GUID (`PurchaseEvaluationsListPage.tsx:192-197`, display projectCode); PE→Project FK `ProjectId` (`PurchaseEvaluation.cs:15`) JOIN-resolved (`PeFeatures.cs:579/:647`) — NO denorm string. CRUD zero-trim (`ProjectFeatures.cs:80` validator, `:95/:138` exact `Code==` dupe-check). Both seeds exact-Code key emit ONLY no-space: `SeedRealMasterDataAsync:2776` (UNGATED :128) + `SeedDemoMasterDataAsync:2681` (gated :117 OFF-prod). Space=legacy/manual. Blast=3 tables ProjectId (Contracts/PeWorkItemBudgets/PurchaseEvaluations); only `PeWorkItemBudgets` UNIQUE(ProjectId,WorkItemId) filtered [IsDeleted]=0 (`PeWorkItemBudgetConfiguration.cs:27`). Direction: KEEP no-space (in seed→idempotent), RETIRE space (not-in-seed); soft-del survivor→seed RE-CREATES (#75/#76). Dev only FLOCK 01 pair (FLOCK03/CAL01 space-twins prod-only); prod UNVERIFIED (SSH dies on SQL-client, S134). Tag `[s148, pe-tree-dup-project-by-space, path-A-two-rows, no-normalize-crud]` - **S147 (07-23) PE attach TraLai edit-block:** FE-ONLY block, BE zero phase-guard (`PurchaseEvaluationsController.cs:15` class `[Authorize]` only; handlers check exists only `PurchaseEvaluationAttachmentFeatures.cs:66/:160`). Gate = raw `readOnly` PROP not phase (`GeneralAttachmentsSection` :3555/:3577 hide-when-readOnly); List detail HARDCODE `readOnly=true` (:574/:669) ⇒ TraLai loại khỏi SCREEN không khỏi editable-SET (workspace `PeListPanel.tsx:88` isEditablePhase GỒM TraLai). fe-admin MIRROR. BE no-guard CỐ Ý (S78). Fix FE: `attachEditable=isDrafter&&isEditablePhase`. Tag `[s147, pe-attach-tralai, fe-only-screen-gate]` -- **S141 (07-20) adap đợt-9/10 5-lane [fable-clone-ensemble, 5/5 clean]:** (ii)-nới-có-vết đủ đóng dangling-head (3/3 squash-benign, `.session-counter.json`); tick-head=wal-commit. byte-safe⟂tolerant-EXTRACTION 2-lỗ tách → pin regex `\"?([0-9a-fA-F]{8,64})\"?`. Roster-count 3-tầng (prose/glob/pointer). refine⟂lead-omission = discriminator WHOSE-memory. Detail → `runs/…S141…`. -- **S138 (07-17) adap 3-bản 17-07 [fable-real]:** 3/3 FIT; delta hẹp hub PHẢI grep landing-site trong luật SỐNG (SE chép 4-nhãn lỗi vào `reviewer.md:46` = adopt-verbatim kế-thừa lỗi nguồn). Detail → `runs/…S138…`. -- **S135/S134/S133 (07-17) PE budget invest [fable-real, #53→sub-file cứu trọn]:** S134 lũy-kế lệch-tiền-đề row1 (`PeBudgetAccumulator.cs:42-48` PrevSubmitted ĐÃ gồm ChoDuyet; DaDuyet-only chỉ row2 :50-60) predicate 1-bản helper Features:962+WorkflowService:1018. S133 freeze-at-DaDuyet 4-assignment (`PurchaseEvaluationWorkflowService.cs:861/:899/:931/:1053`, Mig67). S135 tick H24 single-entry `session-start:181-206`. Detail → archive + runs. -- **S128 (07-16) adap-16-07 [fable-real, garble→on-behalf]:** RAG config in-repo (`AI_INFRA\claude-rag\lib\projects.py:39` owner-final, `rag.json:26` override); nhãn-đo "calibrated" chỉ C1; ghi-đĩa-trong-lúc-làm cứu draft khi garble. Detail → runs. -- **S126 (07-16) worker-pin + all-inherit sweep [opus×2]:** 14/14 opus+max (no-tier lane resolve opus DÙ lead=Fable); B1 284→15 floor-sống; mega-line blow Read-cap → perl-window. `[s126, h23-precedence]` -- **S125 (07-16) adap-review [fable-real]:** auto-memory 50 tracked/0 porcelain; 14/14 opus+effort:max; residue all-inherit floor-sống 2 (`fable-real.md:37`+`fable-clone.md:43`) vá; verifier-suspect-first. -- **S122 (07-15) retro-harvest [6/6 clean]:** 🔴 0/7 mất 7/7 XONG synthesis-sai-chỗ (orphan đo TÊN không NỘI-DUNG); lane-RỖNG≠mất; lane tự truy git đừng tin brief. +- **S133-S141 (07-16→20) gộp [detail → archive/2026-07.md + `runs/…S1{28,33,34,35,38,41}…`]:** S141 adap 5-lane (byte-safe⟂tolerant-EXTRACTION → pin regex `\"?([0-9a-fA-F]{8,64})\"?`; roster-count 3-tầng) · S138 delta-hẹp PHẢI grep landing-site trong luật SỐNG · S134 lũy-kế lệch-tiền-đề row1 (`PeBudgetAccumulator.cs:42-48` PrevSubmitted ĐÃ gồm ChoDuyet) · S133 freeze-at-DaDuyet 4-assignment (`PurchaseEvaluationWorkflowService.cs:861/:899/:931/:1053` Mig67) · S128 RAG config in-repo (`projects.py:39` owner-final) · S126 worker-pin 14/14 opus+max, no-tier→opus DÙ lead=Fable · S125 verifier-suspect-first · S122 orphan đo TÊN không NỘI-DUNG, lane-RỖNG≠mất. - **S116/S117 (07-13) PE negative-quote + giá-chốt RANGE [fable]:** neg-quote FE-ONLY (BE/DB nhận âm; quote sống `details[].quotes` KHÔNG `suppliers[].quotes`). S117 ApprovedPrice WRITE 1-site `ApplyApprovedPriceOnFinalize`; HĐ-gen KHÔNG đọc ApprovedPrice (GiaTri=SUM IsSelected). endsBeforeCeo=config-lie #78 (detail :1152 / list :647). - **[archived → archive/2026-07.md + git]** S87-S114 PE recon (multi-winner IsSelected per-hạng-mục Mig65 · WinnerQuoteTotal DERIVED · budget gate PURE-ROLE · V2 routing=ApproverUserId LINEAR) · Supplier-import-v2 (ClosedXML, Mã NCC=Code, IsPublic fail-closed, import authz-mismatch) · S105 BVAAU auth RCA (relative-`/api` prod-404) · S104 30-field · S98-S101 harness · Office/HRM/Governance recon. Ctrl-F `_INDEX.md`. @@ -58,8 +53,9 @@ Prod: `ssh vietreport-vps` powershell-wrapper `sqlcmd -S '.\SQLEXPRESS' -d Solut - **Seeds gating:** `SeedDemoMasterDataAsync` gated `DemoSeed:Disabled` (OFF prod) · `SeedRealMasterDataAsync` UNGATED (62 dự án + 71 hạng mục Excel, per-code idempotent, exact-Code key #75/#76 resurrect risk). ## 🔄 Curate log -- **S148 (07-23):** hard-cap rewrite 28.2→~14KB — collapsed multi-line S133-S148 → 1-liner, dropped stale pointer-list (S87-S114 → grouped 1-line, verbatim in archive/git). Single-writer inv-cb (hook @28KB). -- **Prev:** S125 (23.9→~15KB, 14 lines→archive/2026-07.md) · S71/S70/S40. Verbatim byte-exact in `archive/`. +- **S155 (07-27):** hook @19.5KB → compact 20.7→~17.1KB: gộp S122-S141 thành 1 dòng, trim 3 entry S155 (detail nguyên vẹn ở `runs/2026-07-27-S155-pe-delete-approver/sub-*.md`) + S153. +- **S148 (07-23):** hard-cap rewrite 28.2→~14KB — collapsed multi-line S133-S148 → 1-liner, dropped stale pointer-list (S87-S114 → grouped 1-line, verbatim in archive/git). +- **Prev:** S125 (23.9→~15KB) · S71/S70/S40. Verbatim byte-exact in `archive/`. ## S149 (2026-07-24→25) — 10 invocation gộp 1 entry [LEAD GHI ON-BEHALF @S150 — M9/B3 theo H2-F3 Coverage-miss (10 lượt → 0 delta sổ, ngoài lưới C11(b) by-design); nguồn: sub-file trong `runs/2026-07-24-S149-{dong-du-5-vong,hoi-tu-bookend,owner-gated-3-khoan}/` + `runs/2026-07-25-S149-adap-backlog/sub-invest-backlog.md`] 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 0c1c15f..723afd9 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 @@ -176,3 +176,102 @@ Nhánh `DangSoanThao` giữ nguyên hành vi cũ (không siết, tránh vỡ lu ## 6. NGOÀI PHẠM VI Khôi phục phiếu (owner chốt (6)) · mở lại **chi tiết** phiếu đã xóa (D4 — cần `IgnoreQueryFilters` thứ 2) · khôi phục nút Từ chối · vá N1–N4. + +--- + +# PHẦN II — Hạng mục F: lệnh **UPDATE quy trình duyệt** (yêu cầu owner (7)) + +> Nguồn: `sub-invest-wfver-4.md` (25 phát hiện). Lead verify độc lập F1/F3/F7/F14/F17. + +## F.0 🔴 QUAN HỆ PHỤ THUỘC — đọc trước tiên + +**Hạng mục A-E (quyền xóa) KHÔNG CHẠY nếu thiếu F.** Không phải "làm cùng thì tiện" mà là **phụ thuộc cứng**. 4 mắt xích: + +1. POST tạo `new ApprovalWorkflow{...}` (`ApprovalWorkflowV2AdminFeatures.cs:336`) → `BaseEntity` sinh `Guid.NewGuid()` ⇒ **Id MỚI**; Steps/Levels cũng row mới. +2. Phiếu đọc workflow **theo Id đã pin**: `PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId` — **tham chiếu SỐNG, không snapshot**. +3. Bản cũ chỉ bị `IsActive=false` (`:334`) ⇒ **vẫn resolve được** ⇒ phiếu chạy tiếp trên cấu hình **CŨ**, im lặng, không lỗi. +4. **Không có đường re-pin:** `PE.ApprovalWorkflowId` chỉ ghi ở `PurchaseEvaluationFeatures.cs:149` (create) / `:291` (update), mà update chặn `:249-251` *"Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại"*. `UPDATE…ApprovalWorkflowId` trong Migrations = **0 hit**. + +⇒ Tick `AllowApproverDelete` hôm nay ⇒ sinh v(n+1) ⇒ **phiếu đang treo vẫn đọc v(n)** ⇒ **không có cờ** ⇒ quyền xóa **không tới đúng phiếu cần xóa**. +⇒ Đường vòng duy nhất (Trả lại → gửi lại) chạy quy trình **lại từ đầu** và **vẫn ăn lũy kế** ⇒ **không giải được ca UAT gốc**. + +## F.1 Hiện trạng: KHÔNG có lệnh Update nào + +Mặt admin V2 chỉ 4 việc; **không** Update (trừ bật/tắt `IsUserSelectable`). Mọi POST **tự tăng** `Version = MAX(Version per Code)+1` (`:325-328`), client không truyền được, **kèm tác dụng phụ hạ `IsActive`** mọi bản cùng `ApplicableType` (`:331-334`). +⇒ (7) = **XÂY MỚI một lệnh chưa từng tồn tại**, không phải nới rào. + +FE: Designer chỉ ở **`fe-admin/…/ApprovalWorkflowsV2Page.tsx`** (fe-user `WorkflowMatrixViewPage.tsx` chỉ-xem). **Không có nút "Sửa"** — chỉ **"Nhân bản"** (`:479`) mở dialog *"Tạo quy trình mới"* (`:638`), toast *"Version cũ đã archive"* (`:611`). Admin **có** thấy Version + trạng thái. +🔴 **0 cảnh báo "đang được dùng", 0 disable khi `IsActive`.** Designer HĐ **V1 CÓ** badge *"N HĐ còn chạy"* — **V2 không port**. + +## F.2 🔴 QUY TẮC PHÂN LOẠI (rút gọn, kiểm được bằng máy) + +**AN TOÀN ⟺ thoả CẢ HAI phép thử:** + +> **(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}`. + +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 đó. +⇒ **Bài học mang theo:** thêm trường mới vào workflow phải hỏi **cả hai** câu, không chỉ câu con trỏ. Trường nào lái đường đi của phiếu thì thuộc (T2) dù không đụng Order. + +### Bảng 13 thao tác + +| # | 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 | +| 3 | **Đổi tên hiển thị** | ✅ AN TOÀN | display-only | +| 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 | +| 7 | Xoá Cấp | ❌ PHÁ VỠ | `pendingLevelGroup` null → `ConflictException` `:695` ⇒ **phiếu KẸT** | +| 8 | Đổi `Order` Cấp | ❌ PHÁ VỠ | tương đương xoá+thêm với con trỏ | +| 9 | Thêm Bước | ❌ PHÁ VỠ | `steps[currentIdx]` trỏ **sang bước khác, ÂM THẦM** (`:686`) | +| 10 | Xoá Bước | ❌ PHÁ VỠ | số bước < `currentIdx` → `ConflictException` `:683` ⇒ **phiếu KẸT** | +| 11 | Đổi `Order` Bước | ❌ PHÁ VỠ | như #9 | +| 12 | Đổi `CeoApprovalThreshold` | ❌ **PHÁ VỠ** *(owner chốt)* | Qua **(T1)** nhưng **trượt (T2)** — đổ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". Người đã duyệt thì duyệt theo ngưỡng họ **nhìn thấy lúc đó** | +| 13 | Đổi `Step.DepartmentId` | ✅ **AN TOÀN** *(owner chốt)* | BE **không** dùng field này để khớp người duyệt (chỉ hint) ⇒ 0 phiếu kẹt. 🔸 Kèm việc: Designer FE **ép nhập** (`:573-575`) trong khi BE coi là optional — lệch sẵn có, xem F-17 | + +🔴 **4 read-site con trỏ, mỗi cái vỡ MỘT KIỂU:** + +| Read-site | Kiểu vỡ | +|---|---| +| `ResolveV2InboxIdsAsync` — hộp thư "Chờ duyệt" (`PurchaseEvaluationFeatures.cs:842-849`) | 🔴 **IM LẶNG**: `idx >= steps.Count → continue` ⇒ phiếu **biến mất khỏi màn Duyệt**, không lỗi | +| Flow-tree detail (`:1135-1191`) | tô sai Done/Current/Pending | +| Banner "Đến lượt bạn" (`:1223-1243`) | banner rỗng ⇒ FE mất gate `blockedByV2Level` | +| Badge "Kết thúc trước CEO" (`:672-677`) | giả định ẩn **`Step.Order - 1 == StepIndex`** — chỉ đúng khi Order liền mạch 1..N | + +## F.3 ⚠️ BLAST RADIUS — không phải việc riêng của PE + +`ApprovalWorkflow` V2 là schema **DÙNG CHUNG ≥6 module**: Proposal · Leave/OT · Travel/Vehicle · WorkflowApps · Contract (+PE). Hai con trỏ có **198 hit / 23 file**. +⇒ Test **bắt buộc phủ PE + ≥1 module Văn phòng số**. + +## 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-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-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 +- [ ] **F-9.** `DeleteAwDefinitionCommandHandler` — thêm **usage-check** (nợ tự khai `:400-402`); hiện xoá nhầm ⇒ **FK 547 → 500** +- [ ] **F-10.** Vá comment **SAI** `ApprovalWorkflow.cs:81-82` (*"KHÔNG OR-of-many"*) — mã + CLAUDE.md đều **OR-of-N** +- [ ] **F-11.** 🔴 Tick F6 ở Designer: `fe-admin:999-1007` (dưới F5) + **5 dây** `:54 / :110 / :159 / :177 / :605` — ⚠️ **thiếu `:605` = tick xong KHÔNG gửi lên BE** (tick vô hình) + `fe-user:282` + BE `:41 / :230 / :188 / :368` + +### Test hạng mục F +- [ ] **F-12.** Update hạng ✅ trên workflow **đang có phiếu chạy** ⇒ phiếu **nhận thay đổi ngay** (bài kiểm chứng F.0) +- [ ] **F-13.** Update đụng tập Order ⇒ **409**, workflow **không đổi** +- [ ] **F-14.** Bớt người mà row đã có `LevelOpinion` ⇒ chặn **lịch sự** (409), KHÔNG nổ FK 547 +- [ ] **F-15.** 🔴 Tick F6 bằng Update ⇒ phiếu `ChoDuyet` **đang treo từ trước** xoá được (nối F với A-E) +- [ ] **F-16.** Regression ≥1 module Văn phòng số (Leave/OT hoặc Proposal) + +## F.5 Nợ khai thêm +| # | Nợ | Ghi chú | +|---|---|---| +| N5 | Designer V2 **0 cảnh báo đang-dùng, 0 disable** khi `IsActive` — V1 có, V2 không port | F-8 vá | +| N6 | `DeleteAwDefinition` 0 usage-check ⇒ FK 547 → 500 | F-9 vá | +| N7 | Comment `ApprovalWorkflow.cs:81-82` sai (OR-of-N) | F-10 vá | diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md index 4e1662d..2788dfe 100644 --- a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-wfver-4.md @@ -162,6 +162,128 @@ UNIQUE `(PurchaseEvaluationId, ApprovalWorkflowLevelId)` (`PurchaseEvaluationLev trong khi mã (F14) và CLAUDE.md đều là OR-of-N. ⇒ Bẫy cho người đọc sau; nên vá comment khi động vào file này. - +--- + +## 🔴 F17 — GIẢ THUYẾT LEAD: **XÁC NHẬN ĐÚNG.** Cờ mới KHÔNG tới được phiếu đang chạy. + +Chuỗi bằng chứng, 4 mắt xích, mỗi mắt có `file:line`: + +| # | Mắt xích | Bằng chứng | +|---|---|---| +| 1 | POST tạo **entity MỚI** ⇒ **Id MỚI** | `ApprovalWorkflowV2AdminFeatures.cs:336` `var def = new ApprovalWorkflow {...}` + `:373` `db.ApprovalWorkflows.Add(def)`; `BaseEntity.cs:5` `public Guid Id { get; set; } = Guid.NewGuid();` ⇒ **không** UPDATE row cũ | +| 2 | Phiếu đọc workflow **theo Id đã pin** | `PurchaseEvaluationWorkflowService.cs:674` `FirstOrDefaultAsync(w => w.Id == awId)` — `awId` = `evaluation.ApprovalWorkflowId` (`:292`) | +| 3 | Row cũ **vẫn sống** sau khi tạo bản mới | POST chỉ set `old.IsActive = false` (`:334`) — **không xoá**, không cascade ⇒ phiếu cũ vẫn resolve được (không lỗi, chỉ là **đọc cấu hình CŨ**) | +| 4 | **KHÔNG có đường re-pin** cho phiếu đang chạy | `ApprovalWorkflowId` chỉ ghi ở **2 chỗ**: `PurchaseEvaluationFeatures.cs:149` (create) và `:291` (update) — mà `:249-251` chặn `throw ConflictException("Chỉ sửa được phiếu khi ở phase Nháp hoặc Trả lại.")` ⇒ **phiếu `ChoDuyet` KHÔNG re-pin được** | + +**Kiểm thêm — không có backfill nào cứu:** +``` +grep -rn "UPDATE.*ApprovalWorkflowId\|SET ApprovalWorkflowId" src/.../Migrations/*.cs → 0 hit +grep -rn "ApprovalWorkflowId = " src/Backend (trừ Migrations/Configurations) → PE chỉ 2 write-site nêu trên +``` + +### ⇒ Hệ quả cho việc đang làm (spec F6 `AllowApproverDelete`) +Admin tick F6 hôm nay ⇒ buộc POST ⇒ workflow **v(n+1)** Id mới ⇒ **đúng những phiếu đang treo cần xoá vẫn pin v(n)** ⇒ `ApproveV2Async`/handler xoá đọc Level của **v(n)** — nơi `AllowApproverDelete = false`. +🔴 **Yêu cầu (5) HỎNG nếu không có (7).** Hai yêu cầu không song song mà **phụ thuộc**: (7) là **điều kiện cần** để (5) chạm được ca UAT gốc (phiếu `PE/2026/A/046` đang treo). +Đường vòng duy nhất nếu KHÔNG làm (7): người soạn **Trả lại → sửa → gửi lại** để re-pin sang v(n+1) (`:249-251` cho `TraLai`) — nhưng gửi lại **chạy quy trình LẠI từ đầu** và **vẫn ăn lũy kế** trong lúc chờ ⇒ **không giải được bài toán gốc**. +[SUY LUẬN] Ngược lại, một lệnh Update **sửa tại chỗ row Level của v(n)** sẽ tới phiếu đang chạy **NGAY** (mắt xích 2 — tham chiếu sống, không cache) — đó chính là cái owner mô tả. + +--- + +## W3 — Bảng phân loại AN TOÀN / NỬA AN TOÀN / PHÁ VỠ + +**Quy ước đọc:** "Cấp" = **tập các row `ApprovalWorkflowLevel` cùng `Order`** (F14). Phân loại chỉ xét **sửa tại chỗ trên workflow đang có phiếu chạy**. + +### F18 [CODE] Bảng 13 thao tác + +| # | Thao tác | Hạng | Lý do kỹ thuật (mã) | +|---|---|---|---| +| 1 | **Thêm người vào Cấp có sẵn** (row mới, `Order` đã tồn tại) | ✅ **AN TOÀN** | Con trỏ Bước = index vào `steps` (không đổi); con trỏ Cấp khớp **giá trị** `g.Key` (`Service:689-695`) ⇒ 0 dịch. Người mới vào `allowedUserIds` ngay (`:702`). Không đụng FK/UNIQUE | +| 2 | **Bật/tắt cờ `Allow*`** (F1-F5, và F6 sắp thêm) | ✅ **AN TOÀN** | Cờ đọc từ `matchingLevel` **tại thời điểm duyệt** (`:799`, `:859`) ⇒ hiệu lực ngay, không đụng con trỏ/khoá. **Đây là ô owner cần cho F6** | +| 3 | **Đổi tên hiển thị** `Workflow.Name` / `Step.Name` / `Level.Name` | ✅ **AN TOÀN** | Chỉ display: `Step.Name` vào message + DTO (`:707`, `Features:1186`); `Level.Name` vào `levelName` (`Features:1176`). Không tham gia quyết định | +| 4 | **Đổi `CeoApprovalThreshold`** | ⚠️ **NỬA — hỏi owner** | Kỹ thuật an toàn (đọc live `aw.CeoApprovalThreshold` `Service:883`, không đụng con trỏ) **NHƯNG đổi nghĩa quyết định đang chờ**: cùng 1 phiếu, hôm nay CCM được duyệt-final, mai thì không. Không vỡ gì — **là câu hỏi chính sách, không phải kỹ thuật** | +| 5 | **Bớt người khỏi Cấp** (xoá 1 row) | ⚠️ **NỬA AN TOÀN** | Con trỏ **không dịch** (F15) NHƯNG: (a) người bị gỡ đang chờ → `ForbiddenException` `:703-708` với message **liệt kê GUID trần** (`:705`); (b) nếu row đó **đã ký** → FK `Restrict` chặn ở DB (`PeLevelOpinionConfiguration:25-28`) → lỗi hạ tầng, không phải 409; (c) bớt HẾT người của Cấp đang chờ ⇒ `allowedUserIds` rỗng ⇒ **phiếu KẸT** | +| 6 | **Đổi `ApproverUserId` của 1 row** | ⚠️ **NỬA AN TOÀN** | = bớt + thêm cùng lúc: người cũ mất quyền tức thì (`:702-708`); nếu row đã ký thì **chữ ký cũ vẫn trỏ row đó** (`LevelOpinion.ApprovalWorkflowLevelId`) ⇒ **lịch sử đổi nghĩa im lặng**: bản ghi "Cấp 2 do A ký" nay hiển thị dưới tên B. FK không chặn (chỉ chặn DELETE). 🔴 **Đây là ô nguy hiểm nhất trong nhóm "sửa người"** | +| 7 | **Thêm Cấp** (`Order` MỚI trong Bước) | ❌ **PHÁ VỠ** | Đổi `maxLevelOrder` (`Service:690`) ⇒ chèn chặng vào giữa luồng đang chạy: phiếu vừa qua Cấp 2 nay bị hỏi Cấp 3 (hoặc ngược lại) ⇒ **đổi nghĩa "đã duyệt xong Bước"**. Ngoài ra `ComputeLevelStatus` (`Features:1135-1148`) tô lại toàn bộ trạng thái đã hiển thị | +| 8 | **Xoá Cấp** | ❌ **PHÁ VỠ** | Nếu là Cấp đang chờ → `pendingLevelGroup` null → `ConflictException` `:695` *"Bước X không có cấp Y"* ⇒ **phiếu KẸT hoàn toàn**. Nếu Cấp đã ký → FK `Restrict` chặn (F10) | +| 9 | **Đổi `Order` của Cấp** | ❌ **PHÁ VỠ** | Con trỏ khoá theo giá-trị `Order` ⇒ đổi số = vừa "xoá" khoá cũ vừa "tạo" khoá mới ⇒ kẹt (`:694-695`) hoặc **nhảy sai cấp im lặng**. Còn vi phạm `HaveSequentialOrders` (`Admin:297-307`) nếu để hở | +| 10 | **Thêm Bước** | ❌ **PHÁ VỠ** | Thêm ở **cuối** thì kéo dài luồng phiếu đang chạy (đổi nghĩa "sắp xong"); thêm ở **giữa** thì `steps[currentIdx]` (`:686`) trỏ sang bước khác — **im lặng, không lỗi**. Phá thêm giả định `Step.Order-1 == index` (F12) nếu Order không re-index | +| 11 | **Xoá Bước** | ❌ **PHÁ VỠ** | `currentIdx >= steps.Count` → `ConflictException` `:683` (**phiếu KẸT**) hoặc trỏ nhầm bước. Inbox thì **im lặng bỏ phiếu** (`Features:844` `continue`) ⇒ phiếu **biến mất khỏi màn Duyệt mà không báo lỗi**. Level con có opinion → FK Restrict chặn cascade | +| 12 | **Đổi `Order` Bước** (reorder) | ❌ **PHÁ VỠ** | Cùng cơ chế #10/#11: index cố định + thứ tự đổi = trỏ sai người, **không có exception** ⇒ dạng hỏng tệ nhất (âm thầm) | +| 13 | **Đổi `DepartmentId` của Bước** | ⚠️ **NỬA — hỏi owner** | BE **không match theo phòng** (V2 match `ApproverUserId`, `:702`) ⇒ kỹ thuật **an toàn**; chỉ đổi nhãn hiển thị (`Features:1188`). **NHƯNG** Designer FE ép *"NV phải thuộc Phòng đã chọn"* (`ApprovalWorkflowsV2Page.tsx:573-575`) ⇒ đổi phòng làm cấu hình **tự mâu thuẫn với chính luật FE**, và [CHƯA XÁC MINH] tôi không tìm thấy nơi nào BE re-validate ⇒ dữ liệu "lệch phòng" sẽ tồn tại im lặng. **Phải hỏi owner** muốn coi đây là gì | + +### F19 [SUY LUẬN] Quy tắc rút gọn để spec dùng +> **AN TOÀN ⟺ thao tác KHÔNG làm đổi tập `{Step.Order}` và KHÔNG làm đổi tập `{Level.Order}` của Bước.** +> Mọi thao tác chỉ đụng **nội dung row** (người, cờ, tên) mà giữ nguyên **khung Order** = sửa tại chỗ được. Đụng vào **khung** = version mới. +Đây là phát biểu kiểm được bằng mã (con trỏ chỉ đọc index-of-steps + value-of-level-Order), không phải quy ước cảm tính. + +### F20 [SUY LUẬN] Cảnh báo cho người viết lệnh Update +- ❌ **CẤM khuôn "xoá hết Levels rồi insert lại"** (khuôn quen của replace-all): nổ FK `Restrict` trên đúng workflow đang chạy (F10), và **đổi Id Level** ⇒ mọi `LevelOpinion` cũ mồ côi ngữ nghĩa. +- ✅ Phải **diff theo `Level.Id`**: giữ Id cũ cho row còn lại, chỉ Add row mới / Update cờ. +- Nhóm ⚠️ NỬA (#5, #6) nên **chặn ở tầng lệnh khi Level đó đã có `LevelOpinion`** — biến lỗi FK 500 thành 409 có chữ. + +--- + +## W5 — Designer FE + +### F21 [CODE] Vị trí + phân bố 2 app (KHÔNG mirror byte-identical như `PeWorkflowPanel`) + +| App | File | Vai | +|---|---|---| +| **fe-admin** | `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` (**1033 dòng**) | Designer **DUY NHẤT** (đọc + tạo + ghim + xoá) | +| **fe-user** | `fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx` (**321 dòng**) | **CHỈ XEM** — ma trận read-only, header `:1-16` ghi rõ *"User read-only matrix view"*, `FlagRow` `:303-314` có `readOnly` | + +⇒ fe-user **không có** Designer. Nhưng nó **liệt kê 8 cờ** ⇒ thêm F6 **vẫn phải chạm 2 app** (xem F24). + +### F22 [CODE] Luồng "sửa" trên FE = **luôn là Tạo mới** — FE nói thật, không giấu + +- Nút trên card: **`Nhân bản`** (`:479` `onClick={() => onClone(def)}`) · **`Ghim/Bỏ ghim`** (`:484-491`) · **`Xoá version`** (`:493-495`). **KHÔNG có nút "Sửa".** +- `onClone` → `setCloneFrom(d); setDesignerOpen(true)` (`:302`, `:339`) → mở **cùng 1 dialog** với tiêu đề **`Tạo quy trình mới — {label}`** (`:638`), nút **`Lưu + kích hoạt`** (`:644`). +- Clone chép **đủ 8 cờ** từ bản cũ (`:141-160`) ⇒ trải nghiệm "giống như sửa", nhưng POST là `api.post('/approval-workflows-v2', …)` (`:578`) ⇒ **bản mới**. +- Toast sau khi lưu: **`"Đã lưu quy trình mới. Version cũ đã archive."`** (`:611`). +⇒ Trả lời câu *"có nút nào gợi ý sửa mà thực chất tạo mới không?"*: **CÓ — `Nhân bản`**, nhưng FE **có khai báo** (tiêu đề + toast + hint). Đây là chỗ sinh ra thói quen owner muốn bỏ. +🔸 `steps.map((s, i) => ({ order: i + 1, …}))` (`:585-586`) ⇒ FE **luôn re-index Order Bước liên tục 1..N** khi lưu ⇒ giả định F12 được giữ **nhờ FE**, không phải nhờ BE. + +### F23 [CODE] 🔴 **KHÔNG có cảnh báo "workflow đang được dùng", KHÔNG disable field khi `IsActive`.** +Lệnh chạy: +``` +grep -n "đang được dùng|đang dùng|còn chạy|usage|inUse|disabled=" ApprovalWorkflowsV2Page.tsx +→ 4 hit, TẤT CẢ vô can: :643 disabled={save.isPending} · :744/:753 disabled nút ↑↓ đầu/cuối list · :817 disabled={addDisabled} (sequential gating Cấp) +``` +- Xoá version chỉ có `confirm()` chữ trơn: `Xoá version đang áp dụng "{code} v{version}"?` (`:308`) / `Xoá version "{code} v{version}"?` (`:345`) — **không đếm phiếu đang dùng**. +- Đối chiếu: Designer **HĐ V1** (skill `contract-workflow`) có badge *"N HĐ còn chạy"*. **V2 không port tính năng đó** ⇒ [SUY LUẬN] admin xoá nhầm sẽ gặp FK 500 (F5) mà không được cảnh báo trước. +- Admin **CÓ** thấy Version: badge `{code} v{01}` (`:386`) + `Đang áp dụng`/`Archived` (`:388-398`) + hint `"Version auto-tăng mỗi lần lưu"` (`:654`) + khối `Lịch sử versions` (`:320`) và dòng *"Khi tạo version mới, version hiện tại tự động archive"* (`:329`). +⇒ **Admin BIẾT mình vừa tạo bản mới.** Vấn đề owner nêu không phải "không biết" mà là **"không muốn phải làm vậy"**. + +### F24 [CODE] 🎯 Chỗ tick F6 `AllowApproverDelete` — **4 neo, 2 app** (spec bám thẳng) + +| # | File:line | Việc | +|---|---|---| +| 1 | `fe-admin/.../ApprovalWorkflowsV2Page.tsx:999-1007` | Khối `