From d25e21ba7bf9a7e99f34ffe375704f9f62070a5a Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 27 Jul 2026 09:14:17 +0700 Subject: [PATCH] wal: flush 20260727T0914 --- .claude/WAL.md | 7 +- .../investigator-codebase/MEMORY.md | 2 + .../2026-07-27-S155-pe-delete-approver/run.md | 37 ++++ .../sub-invest-be-1.md | 192 +++++++++++++++++- .../sub-invest-fe-2.md | 134 ++++++++++++ .../sub-invest-menu-3.md | 61 ++++++ 6 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 .claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md diff --git a/.claude/WAL.md b/.claude/WAL.md index 089bea7..8c018c8 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -22,8 +22,11 @@ chain: [ ] @MIND-2 (block kế) mục C — đính chính 2 số bất-biến trong `_context-s-7.md` (FLOW CẤM sửa tại chỗ): `:62` "counter 27→28" nay là **29** · `:67` "12 file" nay là **13** (máy-derive + đĩa đều 13) [ ] @MIND-2 mục D — xử 3 nhãn ctx-verifier nêu: "Phép thử tự-soi H24" đã định-đoạt ⇒ RỜI D sang con-trỏ · "Guard post-wave assertion" {gần-chốt} nhưng VÔ-GIA-CƯ (0 dòng bản-đồ) ⇒ +1 dòng chain · "Bắt con-đo khai mẫu-số" có ca độc-lập thứ 4 (10→9 của chính lead @S155), 0 ca ngược ⇒ đề {gần-chốt} -[!] wf: A-product PE-delete-approver — run `.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/` · S1 2 Invest ĐANG CHẠY nền (sub-invest-be-1 · sub-invest-fe-2) → S2 spec+checklist → S3 reviewer → S4 hmw -[ ] A-S2: sau khi 2 Invest về → tổng hợp `spec-pe-delete-approver.md` + checklist; HỎI anh các điểm quyết (ai được xóa · soft-delete vs thu-hồi-về-Nháp · phiếu đã có lượt duyệt có xóa được không) +[x] A-owner-chốt 4 ý: (1) trưởng phòng xóa khi đến lượt (2) xóa MỀM (3) phiếu đang-duyệt VẪN xóa được, xóa→mục "Đã xóa"+hết ăn lũy kế (4) 🆕 MENU: Duyệt→"Đang duyệt" +"Đã duyệt" +"Đã xóa" — verbatim ở `run.md` §OWNER CHỐT +[!] wf: A-product PE-delete-approver — run `.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/` · 3 Invest chạy nền: be-1 (Q4-Q7 resume) · fe-2 (Q3/Q5/Q6 resume) · menu-3 (MỚI: menu+Đã-xóa+trưởng-phòng) +[!] 🔴 A-#53 ×2: be-1 + fe-2 CẢ HAI garble return (trả lát-cắt transcript). Đĩa cứu TRỌN: be-1 14.528B Q1-Q3 · fe-2 8.029B Q1/Q2/Q4 ⇒ ghi-đĩa-trong-lúc-làm CHỨNG MINH lần nữa. Đã SendMessage-resume cả 2 +[ ] A-S2: 3 Invest về → `spec-pe-delete-approver.md` + checklist → S3 reviewer → S4 hmw +[ ] 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/investigator-codebase/MEMORY.md b/.claude/agent-memory/investigator-codebase/MEMORY.md index 9e2770d..867a71d 100644 --- a/.claude/agent-memory/investigator-codebase/MEMORY.md +++ b/.claude/agent-memory/investigator-codebase/MEMORY.md @@ -8,6 +8,8 @@ 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 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]` - **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]` - **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]` 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 21db1ed..7d3d9ce 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 @@ -42,3 +42,40 @@ CLAUDE.md S134: `ComputePendingAsync` complementary — **phiếu ChoDuyet có w 1. invest BE: entity/state-machine/endpoint-xóa/authz/lũy-kế/changelog + git-khảo-cổ "chỗ cho huy" 2. invest FE: màn duyệt (component khối HÀNH ĐỘNG) + permission matrix `Pe_*` + nơi PE đang xóa được (nếu có) + dấu vết UI đã gỡ + +--- + +## 🔴 OWNER CHỐT (2026-07-27, anh trả lời 3 câu + ảnh menu) + +**(1) AI được xóa:** *"Người đứng đầu phòng đc xóa khi đến lượt họ."* → 2 điều kiện **AND**: là người đứng đầu phòng ∧ đang tới lượt duyệt của họ. CHẶT HƠN "ai tới lượt cũng xóa được". +**(2) Kiểu xóa:** *"Xóa mềm."* → dùng đúng cơ chế `AuditableEntity.IsDeleted` sẵn có (qua `AuditingInterceptor`), KHÔNG dựng mới. +**(3) Ngữ nghĩa + phiếu đã có lượt duyệt:** *"Tính năng xóa mềm này sinh ra để phục vụ chuyện đó mà, vì nó đang trong quá trình duyệt và đang ăn lũy kế nên phải có nút xóa -> nhưng xóa là chuyển trạng thái đưa xuống mục đã xóa và ko ăn lũy kế nữa."* + ⇒ **KHÔNG thêm rào cho phiếu đã có `Lịch sử duyệt (n)`** — đang-duyệt CHÍNH LÀ ca cần cứu. Câu hỏi "đã duyệt rồi có xóa được không" = **ĐÃ TRẢ LỜI: CÓ**. + ⇒ Xóa = chuyển trạng thái + xuất hiện ở **mục "Đã xóa"** + **rớt khỏi lũy kế**. + +**(4) 🆕 RESTRUCTURE MENU (ảnh, ghi chú đỏ chỉ vào mục menu "Duyệt"):** +``` +Chỗ này: +Duyệt -> Đang duyệt. +Thêm : +Đã duyệt. +Đã xóa. +``` +Menu hiện tại dưới `1. Duyệt Nhà Cung Cấp - Thầu phụ (NCC-TP)`: `Luồng duyệt` · `Danh sách` · `Thao tác` · `Duyệt`. +Menu sau khi sửa: `Luồng duyệt` · `Danh sách` · `Thao tác` · **`Đang duyệt`** (đổi tên từ `Duyệt`) · **`Đã duyệt`** (MỚI) · **`Đã xóa`** (MỚI). +URL màn hiện tại: `/purchase-evaluations?type=1&pendingMe=1` · tiêu đề "Duyệt NCC — Chờ duyệt (8)" · lọc cố định `Đã gửi duyệt`. + +### 🔴 Nghịch lý phải xử ở (4) — nối thẳng vào F9/F10 của sub BE +Global filter `HasQueryFilter(x => !x.IsDeleted)` (`PurchaseEvaluationConfiguration.cs:84`) + `IgnoreQueryFilters` **0 hit toàn backend** là thứ khiến phiếu xóa **tự rớt khỏi 4 phép cộng** ⇒ cho không ý (3). +NHƯNG cùng filter đó làm phiếu xóa **vô hình với MỌI query** ⇒ mục "Đã xóa" **không thể** dựng nếu không mở `IgnoreQueryFilters()`. +⇒ Ràng buộc spec: mở `IgnoreQueryFilters()` cho **ĐÚNG 1 endpoint list "Đã xóa"**, KHÔNG đụng accumulator, KHÔNG mở diện rộng. Đây sẽ là **chỗ đầu tiên trong toàn backend** dùng `IgnoreQueryFilters` ⇒ đáng 1 dòng checklist riêng + test. + +### ❓ Chưa giải: "người đứng đầu phòng" map vào đâu trong schema? +Ảnh cho thấy 1 Cấp có NHIỀU người (Phòng Cung ứng — Cấp 2: Bùi Lê Thủy Trà, Trần Xuân Lưu, Lê Trần Đăng Trường). 3 cách hiểu khả dĩ, ra 3 đoạn code KHÁC nhau: +(a) Cấp cao nhất trong Bước (Level order) · (b) field trưởng phòng trên `Department` (Mig 51 có `ParentId` org-tree) · (c) role/permission riêng. +⇒ giao sub invest #3 tìm, CẤM đoán. + +## Stages (cập nhật) +- [~] S1 — 2 Invest BE/FE: đã cứu từ đĩa Q1-Q3 (BE) + Q1,Q2,Q4 (FE); **cả 2 dính #53**, đã SendMessage-resume làm nốt +- [ ] S1-bis — Invest #3 lát cắt MỚI: menu restructure + "Đã xóa" list + "người đứng đầu phòng" +- [ ] S2 — spec + checklist · [ ] S3 — reviewer · [ ] S4 — hmw Opus 5 MAX diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md index 1fabeb7..e662ced 100644 --- a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-be-1.md @@ -217,6 +217,194 @@ Lệnh đã chạy (để có thể tái kiểm): ## Q4 — Vết / audit -*(đang điều tra)* +### F13. PE có **2 bảng lịch sử TÁCH VAI** (không phải 1) `[CODE]` + +| Bảng | Entity | Vai | +|---|---|---| +| `PurchaseEvaluationApprovals` | `PurchaseEvaluationApproval : BaseEntity` (`PurchaseEvaluationApproval.cs:7`) | **per-approver record** — ai duyệt, FromPhase/ToPhase/Decision/Comment. Config `PurchaseEvaluationConfiguration.cs:156-170` | +| `PurchaseEvaluationChangelogs` | `PurchaseEvaluationChangelog : BaseEntity` (`PurchaseEvaluationChangelog.cs:9`) | **nhật ký thao tác** — EntityType × Action × PhaseAtChange × Summary × ContextNote × FieldChangesJson. Config `:172-190` | + +(Phân vai này chính là bài học gotcha #79 — Mig 60 backfill soi nhầm `Approvals` thay vì `Changelogs`.) + +**Enum sẵn có:** +- `ChangelogAction` (`Domain/Contracts/ContractChangelog.cs:38-44`): `Insert=1, Update=2, **Delete=3**, Transition=4` → **đã có sẵn `Delete`, không cần migration.** +- `PurchaseEvaluationEntityType` (`PurchaseEvaluationChangelog.cs:25-33`): `Header=1, Supplier=2, Detail=3, Quote=4, Workflow=5, Attachment=6`. + +### F14. Duyệt / Trả lại / Từ chối ghi vết ở đâu `[CODE]` + +Tất cả đi qua **1 helper duy nhất**: `PurchaseEvaluationWorkflowService.LogTransitionAsync` (`:1158-1185`), được gọi **13 lần** (`:160, :269, :315, :644, :828, :870, :909, :925, :942, :950, :1146, :1153` + `:1027` chú thích). Nội dung ghi (`:1175-1185`): + +```csharp +db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog +{ + PurchaseEvaluationId = evaluation.Id, + EntityType = PurchaseEvaluationEntityType.Workflow, + Action = ChangelogAction.Transition, + PhaseAtChange = toPhase, + UserId = actorUserId, + UserName = actorName ?? "Hệ thống", + Summary = $"Chuyển phase {fromPhase} → {toPhase}", + ContextNote = comment, // ← lý do do người duyệt gõ +}); +``` +Kèm notify Drafter ở `:1188-1197` (DaDuyet / TuChoi / TraLai). + +### F15. Handler xóa **KHÔNG ghi changelog** — vết duy nhất là 3 cột của interceptor, và nó THIẾU 2 thứ `[CODE] + [SUY LUẬN]` + +`[CODE]` `PurchaseEvaluationFeatures.cs:1399-1410`: 0 dòng `Changelogs.Add`, 0 dòng `Approvals.Add`. Vết duy nhất = `IsDeleted / DeletedAt / DeletedBy` do `AuditingInterceptor.cs:56-62` tự set. + +`[SUY LUẬN]` Vết đó **có ai** + **lúc nào**, nhưng **thiếu**: +1. **Lý do xóa** — không có chỗ chứa (không cột nào ngoài 3 cột trên). Đây là điều bắt buộc phải có cho ca "bắt sai gói thầu" (người khác cần biết vì sao phiếu biến mất). +2. **Không ai đọc được** — 3 cột đó chỉ soi bằng SQL. UI đọc lịch sử qua `ListPurchaseEvaluationChangelogsQuery` (`PurchaseEvaluationFeatures.cs:1415-1434`) mà query đó lọc `WHERE PurchaseEvaluationId == id` → **phiếu đã xóa không còn mở được** ⇒ vết chôn theo phiếu. Người cùng gói thầu (đang nhìn số lũy kế đột nhiên tụt) **không có cách nào biết** phiếu nào vừa bị rút ra. + +### F16. Chèn vết ở đâu cho khớp pattern đang có `[CODE]` (tiền lệ trong repo) + +`ChangelogAction.Delete` **đã được dùng ở 5 site PE/HĐ** — đây là khuôn có sẵn, chỉ việc mirror: +- `PurchaseEvaluationSupplierFeatures.cs:164` (xóa NCC khỏi phiếu) +- `PurchaseEvaluationDetailFeatures.cs:255` (xóa hạng mục) và `:392` (xóa báo giá) +- `PurchaseEvaluationAttachmentFeatures.cs:173` (xóa đính kèm) +- `PeDepartmentOpinionFeatures.cs:143` (xóa ý kiến phòng ban) +- (HĐ: `ContractAttachmentFeatures.cs:144`, `ContractDetailsFeatures.cs:467` qua `ChangelogService`) + +`[SUY LUẬN]` ⇒ khuôn tự nhiên: trong `DeletePurchaseEvaluationCommandHandler`, **TRƯỚC** `Remove()`, `Add` 1 row `{EntityType=Header (hoặc Workflow), Action=Delete, PhaseAtChange=, UserId=currentUser, Summary="Xóa phiếu ...", ContextNote=}`. Handler hiện **chưa inject `ICurrentUser`** nên phải thêm (mọi handler PE khác đều đã inject — vd `PeWorkItemBudgetFeatures.cs:83`). +⚠️ Nhưng row changelog đó **cũng chôn theo phiếu** (F15 mục 2). Nếu muốn người khác thấy, phải có nơi hiển thị NGOÀI phiếu — `[CHƯA XÁC MINH]` repo hiện **không có** bảng audit toàn cục (`AuditLogs` vẫn nằm ở mục "future" trong skill `contract-workflow`, tôi đã grep `class AuditLog` → 0 hit trong `src/Backend/SolutionErp.Domain`). + +--- + +## Q5 — Quan hệ dữ liệu (câu quan trọng thứ 2) + +### F17. Bản đồ FK — **6 collection Cascade + 1 Cascade riêng, 0 Restrict phía con** `[CODE]` + +`PurchaseEvaluationConfiguration.cs:75-80`: +```csharp +b.HasMany(x => x.Suppliers)...OnDelete(DeleteBehavior.Cascade); // :75 +b.HasMany(x => x.Details)...OnDelete(DeleteBehavior.Cascade); // :76 +b.HasMany(x => x.Approvals)...OnDelete(DeleteBehavior.Cascade); // :77 +b.HasMany(x => x.Changelogs)...OnDelete(DeleteBehavior.Cascade); // :78 +b.HasMany(x => x.Attachments)...OnDelete(DeleteBehavior.Cascade); // :79 +b.HasMany(x => x.DepartmentOpinions)...OnDelete(DeleteBehavior.Cascade); // :80 +``` ++ `PurchaseEvaluationLevelOpinionConfiguration.cs:20-28` — **Cascade** về PE, **Restrict** về `ApprovalWorkflowLevel`. ++ `Quotes` không FK thẳng lên PE mà qua `Detail` (`PurchaseEvaluationDetailConfiguration.cs:130` Cascade) — và `Quote → Supplier` là **Restrict** (`:149-152`). ++ FK ra ngoài: `PE → ApprovalWorkflow` **Restrict** (`:70-73`). + +### F18. 🔴 Soft-delete cha ⇒ **cascade KHÔNG BAO GIỜ chạy** ⇒ con ở lại nguyên vẹn `[CODE] + [SUY LUẬN]` + +`[CODE]` `AuditingInterceptor.cs:58` đổi `entry.State = EntityState.Deleted → Modified`. EF chỉ phát `DELETE` (và cascade) cho state `Deleted`. Với `Modified` nó phát `UPDATE`. +`[SUY LUẬN]` ⇒ Sau khi xóa 1 PE: **toàn bộ** `PurchaseEvaluationSuppliers`, `Details`, `Quotes`, `Approvals`, `Changelogs`, `Attachments`, `DepartmentOpinions`, `LevelOpinions` **vẫn nằm trong DB, `IsDeleted` không tồn tại/không đổi**, trỏ về 1 phiếu cha vô hình. + +**Xác nhận không có cột `IsDeleted` ở con `[CODE]`:** 7/8 bảng con là `BaseEntity` (không có IsDeleted): `PurchaseEvaluationSupplier.cs:9`, `Detail.cs:7`, `Quote.cs:8`, `Approval.cs:7`, `Changelog.cs:9`, `Attachment.cs:15`. Chỉ 2 bảng là `AuditableEntity`: `PurchaseEvaluationDepartmentOpinion.cs:24` + `PurchaseEvaluationLevelOpinion.cs:24` — nhưng cả 2 **không có `HasQueryFilter`** (grep `HasQueryFilter` chỉ ra 1 hit duy nhất trong file config PE là dòng `:84` của bảng cha). + +**Có lộ số ở query nào khác không? — KHÔNG, nhưng nhờ MAY hơn nhờ thiết kế `[CODE]`:** +Tôi grep toàn bộ read-site đứng thẳng trên bảng con (`db.PurchaseEvaluationSuppliers|Quotes|LevelOpinions`, 38 hit): **mọi hit đều bị chặn bởi 1 trong 2 điều kiện** — (i) join/`from p in peers` bắt nguồn từ `db.PurchaseEvaluations` (nên dính filter cha) — `PeBudgetAccumulator.cs:66/68/121/123`, `PurchaseEvaluationFeatures.cs:656/783`, `CreateContractFromEvaluationFeatures.cs:185`; hoặc (ii) lọc theo `PurchaseEvaluationId == ` / `supplierRowIds` lấy từ phiếu đó. **Không có 1 read-site nào quét ngang bảng con toàn hệ thống.** +`[SUY LUẬN]` ⇒ rác **tồn tại thật** nhưng **hiện không lộ số**. Rủi ro là tương lai: ai viết 1 report kiểu `SUM(Quotes) GROUP BY project` mà không join PE sẽ ăn phải rác này. Đáng ghi thành ràng buộc trong spec, không phải blocker. + +### F19. `PeWorkItemBudgets` **KHÔNG phải bảng con của PE** — xóa phiếu không đụng tới `[CODE]` + +`PeWorkItemBudget` khoá theo cặp `(ProjectId, WorkItemId)` (`PeWorkItemBudgetConfiguration.cs:27` UNIQUE filtered `[IsDeleted]=0`), **dùng chung cho mọi phiếu cùng gói**. Không có FK PE→PeWorkItemBudget (0 hit `HasMany`/`HasOne` giữa 2 entity). Handler chỉ resolve nó **qua** PE (`PeWorkItemBudgetFeatures.cs:86/157`). +⇒ Xóa phiếu **không** được xóa/giảm record ngân sách gói thầu. Đúng ý đồ (bảng ngân sách là "tài liệu sống" — comment `PeWorkItemBudgetFeatures.cs:19-21`). + +### F20. Đối chiếu: xóa NCC-trong-phiếu là **HARD delete** `[CODE]` + +`PurchaseEvaluationSupplierFeatures.cs:170` `db.PurchaseEvaluationSuppliers.Remove(row)` — vì `PurchaseEvaluationSupplier : BaseEntity` (không `AuditableEntity`) nên interceptor **không** bắt → xóa cứng thật. Có guard `:154` chặn khi còn quote. +⇒ Trong cùng module, `Remove()` cho ra 2 hành vi khác nhau tuỳ base class. Ai đọc spec phải nói rõ đang nói bảng nào (bẫy #81-EXT lớp indirect-assignment). + +--- + +## Q6 — 🔴 KHẢO CỔ GIT: "chỗ cho huy" + +### F21. **TÌM THẤY.** "chỗ cho huy" = nút **"Từ chối"** ở màn duyệt, bị gỡ 2026-06-12 `[CODE]` + +**Commit:** `6db195dd4270a8c463305d5051c901e05748c403` — **Fri Jun 12 2026 14:30:38 +0700** — phiên **S60**. +**Subject:** `[CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14)` + +**Lý do gỡ (verbatim từ mã, `PurchaseEvaluationWorkflowService.cs:94-100`):** +``` +// ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" ===== +// "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi." Mọi policy đã bỏ +// TuChoi khỏi NextPhases (FE hết nút); guard này chặn caller direct +// (API forge / client cũ cache) — đứng TRƯỚC mọi branch nên chặn CẢ +// Admin manual override (spec = bỏ hẳn hành động, không escape hatch). +// Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter. Flip lại nếu cần: +// xóa guard này + restore transitions trong PurchaseEvaluationPolicy. +``` +⇒ Khớp **từng chữ** lời anh: *"hôm trước có cái chỗ cho **hủy** / mọi người nói là ko cần cái đó / giờ lại cần"*. Người yêu cầu gỡ = **anh Kiệt (FDC)**, ngày 12/06/2026 lúc 14:14, lý do = "Duyệt hoặc Trả về thôi" (giản lược UX), **KHÔNG phải vì lỗi kỹ thuật**. + +Session log: `docs/changelog/sessions/2026-06-12-S60-S62-pe-budget-workitem-softwarning.md:23-28`. +Diff (6 file): 2 FE `PeWorkflowPanel.tsx` (filter `next.filter(p != TuChoi)`), Domain `PurchaseEvaluationPolicy.cs` (−42 dòng transition), Service (+guard 14 dòng), +2 test spec-change. **Test 254 → 256 PASS.** + +### F22. 🔴 Guard S60 **VẪN SỐNG** và chính nó trỏ người dùng sang "Xóa phiếu" `[CODE]` + +`PurchaseEvaluationWorkflowService.cs:101-106`: +```csharp +if (targetPhase == PurchaseEvaluationPhase.TuChoi) +{ + throw new ConflictException( + "Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " + + "Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp."); +} +``` +🔴 **Đây là mắt xích logic của cả đề bài:** S60 gỡ "Từ chối" và **chuyển hướng sang "Xóa phiếu"** — nhưng "Xóa phiếu" lại **chỉ chạy được ở Bản nháp** (F5). Vậy phiếu đã gửi duyệt rơi vào **lỗ**: không Từ chối được (gỡ rồi), không Xóa được (guard phase), chỉ Trả lại được — mà Trả lại **không triệt tiêu lũy kế** (F8). Đúng y điều Tra Sol mô tả: *"quay lại không được, phải xóa thì nó mới ko có lũy kế lên"*. +`[SUY LUẬN]` ⇒ Yêu cầu S155 **không phải feature mới**, nó là **đóng cái lỗ do S60 mở ra**. Ghi chú S60 còn để sẵn đường lui: *"Flip lại nếu cần: xóa guard này + restore transitions"*. + +### F23. Nguồn gốc nút "Xóa phiếu" hiện tại `[CODE]` + +- `4678d19` `[CLAUDE] App+Api: PurchaseEvaluation CQRS + Controller + WorkflowService` — commit **sinh ra** `DeletePurchaseEvaluationCommand` (git log -S). +- `378c993` `[CLAUDE] FE-Admin+FE-User: PE detail polish B12 — Lưu (no close), **Xóa phiếu**, header bar simplify, NCC name col, **no-delete có quotes**` — commit đưa nút Xóa lên UI (phía người soạn). +- **Không có** commit nào GỠ endpoint/handler xóa PE (git log -S `DeletePurchaseEvaluationCommand` chỉ ra 2 commit: 1 tạo + 1 `wal: flush` hôm nay). + +**Lệnh đã chạy đầy đủ (để tái kiểm):** +``` +git log --oneline -S "DeletePurchaseEvaluationCommand" -- . +git log --oneline -S "Xóa phiếu" +git log --oneline -S "handleDelete" -- fe-admin fe-user → RỖNG +git log --oneline -i --grep="thu h" +git log --oneline -i --grep="xóa phiếu" +git show --stat 6db195d +grep -rni "thu hồi|thuhoi|recall|withdraw" --include=*.cs --include=*.tsx --include=*.ts src fe-admin/src fe-user/src +``` +`[CHƯA XÁC MINH]` Tôi **không** chạy `git log --diff-filter=D` (xóa nguyên file) vì hướng "Từ chối bị gỡ" đã cho kết quả khớp verbatim lời anh; nếu reviewer muốn loại trừ khả năng có file bị xóa hẳn thì cần chạy thêm. + +--- + +## Q7 — Pattern tham chiếu + +### F24. **KHÔNG có tiền lệ** thu-hồi/hủy một chứng-từ ĐANG CHẠY workflow `[CODE]` + +Grep `public record Delete.*Command` toàn `src/Backend/SolutionErp.Application` (lọc các module chứng từ) → chỉ **2** lệnh xóa cấp-chứng-từ tồn tại trong repo: +- `DeletePurchaseEvaluationCommand` (`PurchaseEvaluationFeatures.cs:1394`) — allow-list `{Nháp, Từ chối}` +- `DeleteContractCommand` (`ContractFeatures.cs:565`) — ngưỡng `< DangInKy` + +**0 lệnh** xóa/hủy cho `Proposal`, `LeaveRequest`, `OtRequest`, `TravelRequest`, `VehicleBooking`, `ItTicket` — các module này **không có đường rút đơn nào cả**. + +**Thứ gần nhất (nhưng KHÔNG phải workflow):** `CancelMeetingBookingHandler` (`src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs:457-479`) — đáng đọc vì nó là **khuôn duy nhất trong repo cho "hủy có vết"**: +```csharp +var isOwner = entity.BookedByUserId == userId; +var isAdmin = currentUser.Roles.Contains("Admin"); +if (!isOwner && !isAdmin) + throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép huỷ booking."); + +// Status=Cancelled (NOT IsDeleted=true) — preserve history + audit trail. +entity.Status = MeetingBookingStatus.Cancelled; +``` +3 điểm đáng mượn: (1) **check người** trong handler (owner-or-admin) — thứ handler xóa PE đang thiếu hoàn toàn (F2); (2) **chuyển TRẠNG THÁI thay vì IsDeleted**, chú thích rõ lý do "preserve history"; (3) DELETE verb ở API nhưng semantics là cancel (`MeetingFeatures.cs:25`). + +`[SUY LUẬN]` ⇒ **Spec S155 phải dựng luật mới**, không có khuôn PE/HĐ để copy. Hai hướng khả dĩ, cả hai đều có tiền lệ trong repo: +- **(A) Nới guard xóa** (rẻ nhất — đường ống đã có, lũy kế tự đúng theo F10) + thêm authz + changelog. +- **(B) Khôi phục "Từ chối"** (S60 để sẵn đường lui, F22) — nhưng `TuChoi` **KHÔNG** triệt tiêu `PrevSubmitted` ở dòng chính-xác? — **có triệt tiêu**: `ComputeAsync` chỉ nhận `{ChoDuyet, DaDuyet}` (`PeBudgetAccumulator.cs:54-55`) và `ComputePendingAsync` loại `TuChoi` ở cả 3 phép (`:108`, `:116`, `:130`) ⇒ **`TuChoi` rơi khỏi 100% các phép lũy kế**. Tức (B) cũng giải quyết được bài toán số liệu, mà **giữ được phiếu để tra cứu** — hợp ý F15 (vết không bị chôn). +Quyết định A/B **không thuộc thẩm quyền tôi** — nêu để em main/anh chọn. + +--- + +## Tổng kết + những gì CHƯA xác minh + +**Chưa xác minh (khai rõ, không lấp bằng suy đoán):** +1. **Chưa chạm DB nào** (Dev lẫn prod) — mọi kết luận đến từ đọc mã. Cụ thể **chưa** chứng bằng runtime rằng `IsDeleted=true` làm số lũy kế tụt (F10 là suy luận từ query filter, độ tin cao nhưng chưa có phép đo). +2. **Chưa đếm** hiện prod có bao nhiêu phiếu ChoDuyet-có-winner đang ăn lũy kế sai. Lệnh gợi ý cho implementer (Dev): + `sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q "SELECT p.MaPhieu,p.Phase,p.ProjectId,p.WorkItemId FROM PurchaseEvaluations p WHERE p.IsDeleted=0 AND p.Phase=10 AND EXISTS(SELECT 1 FROM PurchaseEvaluationSuppliers s WHERE s.PurchaseEvaluationId=p.Id AND s.IsWinner=1)"` + (prod qua SSH thường chết khi nạp SQL-client — S134/S148, cần implementer làm trực tiếp.) +3. **Chưa chạy** `git log --diff-filter=D` (F23). +4. **Chưa điều tra FE** — thuộc phần `sub-invest-fe-2.md`. + + -*(file đang được ghi tiếp — Q4..Q7 bên dưới)* diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md index f84353d..0c3fd5b 100644 --- a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-fe-2.md @@ -117,3 +117,137 @@ Nút không hiện vì `:445` yêu cầu `mode === 'workspace' && canEditPhase & ⇒ [SUY LUẬN] chi phí wire FE rất thấp — **hàm xóa + invalidate + đóng-detail đã sẵn**, phần thiếu là (a) nút ở đúng chỗ approver + (b) **cửa BE/authz** (Q3). --- + +## Q3 — PHÂN QUYỀN 2 TẦNG (gotcha #82 / S118) + +### (a) PH-11 [CODE] Menu key PE **thật** — `menuKeys.ts` chỉ có ROOT, `Pe_*` sinh ở BE + +`fe-user/src/lib/menuKeys.ts` (md5 `4da4405…`) ⟂ `fe-admin/src/lib/menuKeys.ts` (md5 `81fe5ad…`) — **2 file KHÁC nhau** (không như file PE vốn byte-identical). Cả 2 đều có: +| key | fe-user line | fe-admin line | +|---|---|---| +| `PurchaseEvaluations: 'PurchaseEvaluations'` | `:23` | `:23` | +| `PeWorkflows: 'PeWorkflows'` | `:24` | `:24` | +| `ApprovalWorkflowsV2` / `AwV2_DuyetNcc` / `AwV2_DuyetNccPhuongAn` | `:26-28` | `:26-28` | + +🔴 **KHÔNG có const `Pe_*` / `PeWf_*` nào trong menuKeys.ts của cả 2 app.** Chúng được **sinh động ở BE**: +`src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs:131-145` +``` +:131 PurchaseEvaluationTypeCodes = ["DuyetNcc", "DuyetNccPhuongAn"]; +:134 PurchaseEvaluationGroup(code) => $"Pe_{code}" +:135 PurchaseEvaluationList(code) => $"Pe_{code}_List" +:136 PurchaseEvaluationCreate(code) => $"Pe_{code}_Create" +:137 PurchaseEvaluationPending(code) => $"Pe_{code}_Pending" ← MÀN DUYỆT +:141 PurchaseEvaluationWorkflowView(code) => $"Pe_{code}_WfView" +:145 PeWorkflowTypeLeaf(code) => $"PeWf_{code}" +``` +Seed vào bảng `MenuItems`: `DbInitializer.cs:1870-1877` (+`:2092-2096`, `:2492-2496`). +⇒ **10 key `Pe_*` thật** (2 typeCode × {group, _WfView, _List, _Create, _Pending}) + **2 key `PeWf_*`**. FE chỉ khớp chúng bằng **regex**, không bằng const: `fe-user/src/components/Layout.tsx:120` `/^Pe_([^_]+)_(List|Create|Pending|WfView)$/` · `fe-admin/src/components/Layout.tsx:107` `/^Pe_([^_]+)_(List|Create|Pending)$/` ⚠️ **2 app LỆCH nhau** (admin thiếu `WfView`). + +### (b) PH-12 🔴 [CODE] Ma trận CRUD **ĐÃ CÓ `Delete`** — và đang **không ai dùng** cho PE +- `MenuKeys.cs:167` `Actions = ["Read", "Create", "Update", "Delete"]` +- `Permission.cs:8-11` — 4 cột `CanRead/CanCreate/CanUpdate/CanDelete` +- `menuKeys.ts:72` (fe-user) `export type CrudAction = 'Read' | 'Create' | 'Update' | 'Delete'` +- Ma trận admin enumerate **bảng `MenuItems`** (`PermissionFeatures.cs:20 db.MenuItems`) ⇒ ô tick `Delete` cho `Pe_DuyetNcc_Pending` **đã hiện sẵn trong UI** và ghi được row `Permissions`. + +🔴 **BẪY 2-TẦNG chính xác nằm ở đây:** `Program.cs:82-89` chỉ đăng-ký policy `"{menu}.{action}"` cho `menu ∈ MenuKeys.All`. `MenuKeys.All` (`:147-165`) **CHỈ có root `PurchaseEvaluations` (:153) + `PeWorkflows` (:163)** — **KHÔNG có `Pe_*`/`PeWf_*`**. Không có `IAuthorizationPolicyProvider` động (grep = 0 hit). +⇒ Viết `[Authorize(Policy = "Pe_DuyetNcc_Pending.Delete")]` sẽ trỏ vào policy **CHƯA ĐĂNG KÝ** (ASP.NET ném `InvalidOperationException` → 500, **không phải 403**). [CODE + SUY LUẬN về hệ quả runtime — chưa chạy thử] +⇒ **Policy DÙNG ĐƯỢC NGAY hôm nay = `"PurchaseEvaluations.Delete"`** (root ∈ `All` ⇒ đã đăng ký, 0 code mới). + +**Quà cho spec:** tầng display cũng đã sẵn — `GetMyMenuTreeQuery.cs:51-84` cho `Pe_*` **kế thừa** cờ CRUD từ root `PurchaseEvaluations` khi leaf không có row riêng (`:66 if (inheritFromKey is not null && !resolved.ContainsKey(m.Key))`, `:72`). Nghĩa là tick `Delete` **1 lần ở root** là mọi `Pe_*` có `canDelete=true`. Không cần key mới, không cần migration. + +### (c) PH-13 [CODE] Khuôn `PermissionGuard` đúng chuẩn ở module KHÁC (để bắt chước) +- `fe-admin/src/pages/master/DepartmentsPage.tsx:101` `` +- `fe-admin/src/pages/master/ProjectsPage.tsx:130` · `SuppliersPage.tsx:257` — cùng khuôn `action="Delete"` +- `fe-user/src/pages/master/DepartmentsPage.tsx:76` — mirror phía user +- Cơ chế: `usePermission.ts:15-26` `can(menuKey, action)` → `findNode(menu, key)` trên cây menu từ `AuthContext` → đọc `node.canDelete`. `PermissionGuard.tsx:12-15` = wrapper `if (!can(...)) return fallback`. +🔴 Vùng PE (`components/pe/*`, `pages/pe/*`) — **0 hit** `usePermission|PermissionGuard` ở CẢ 2 app (xác nhận lại PH-06). Toàn bộ 5 file dùng guard này là Master + Users, KHÔNG có PE. + +### (d) PH-14 🔴 CHECKLIST "phải chạm chỗ nào" để đủ CẢ HAI tầng +**Tầng DISPLAY (FE) — 4 mục:** +1. `fe-user/src/components/pe/PeWorkflowPanel.tsx` ~`:458-506` — thêm nút Xóa cạnh khối `next.map` (KHÔNG nhét vào map). +2. `fe-admin/src/components/pe/PeWorkflowPanel.tsx` — **y hệt** (byte-identical, sửa 1 bên là sót — PH-01). +3. Bọc `` theo khuôn PH-13 (import `usePermission`/`PermissionGuard` — hiện PE chưa import). +4. Prop `onDelete` **không cần thêm** — đã truyền sẵn ở `PurchaseEvaluationsListPage.tsx:573` + `:668` (PH-10); nhưng nó đang gắn vào `PeDetailTabs`, **KHÔNG** vào `PeWorkflowPanel` ⇒ phải (a) nâng mutation `del` lên rồi truyền prop mới cho `PeWorkflowPanel`, hoặc (b) tạo mutation riêng trong panel. [SUY LUẬN] + +**Tầng API-AUTHZ (BE) — 3 mục:** +5. `PurchaseEvaluationsController.cs:146` `[HttpDelete("{id:guid}")]` — hiện **chỉ có `[Authorize]` trần ở class `:15`**, 0 policy (grep `Policy` trong file = **0 hit**). Thêm `[Authorize(Policy = "PurchaseEvaluations.Delete")]` **trên chính action Delete** (policy này đã đăng ký sẵn — PH-12). +6. Nới guard phase trong handler: `PurchaseEvaluationFeatures.cs:1404-1406` hiện allow-list `{DangSoanThao, TuChoi}` ⇒ **ChoDuyet đang bị chặn 409** `"Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối."`. Không nới thì nút FE bấm ra lỗi. (thuộc slice BE) +7. Guard "đúng lượt duyệt" ở BE (mirror `actorInV2Level`) — hiện **KHÔNG có**; nếu chỉ dựa `PurchaseEvaluations.Delete` thì **bất kỳ ai có quyền Delete PE đều xóa được phiếu người khác đang duyệt**. [SUY LUẬN — rủi ro cần spec chốt] + +**Seed / migration / role — 2 mục:** +8. **KHÔNG cần key mới, KHÔNG cần migration** nếu dùng root `PurchaseEvaluations` (PH-12). Chỉ cần admin tick ô `Delete` ở `/system/permissions` cho role đích → kế thừa xuống `Pe_*`. +9. Nếu spec muốn **key riêng** (vd `Pe_{code}_Pending` có Delete độc lập) ⇒ phải thêm key vào `MenuKeys.All` (`:147-165`) để Program.cs đăng ký policy, + seed `MenuItems`, + tính lại row `Policies` trong `docs/STATUS.md` (derived `|All| × |Actions|`). + +**Phụ (từ run.md "xóa phải có vết"):** +10. `DeletePurchaseEvaluationCommandHandler` (`:1396-1411`) **KHÔNG ghi changelog** — chỉ `db.PurchaseEvaluations.Remove(entity)`. Soft-delete là thật: `AuditingInterceptor.cs:56-59` bắt `EntityState.Deleted` → `entry.Entity.IsDeleted = true`. [CODE] + +--- + +## Q5 — DẤU VẾT UI ĐÃ GỠ ("chỗ cho huy") + +### PH-15 🔴🔴 [CODE] **TÌM THẤY** — đường "khai tử phiếu" phía approver ĐÃ TỪNG TỒN TẠI và bị gỡ + +**Commit `6db195d`** · **2026-06-12 14:30:38 +0700** · S60 +> `[CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14)` + +**Lý do gỡ — nguyên văn UAT** (`PurchaseEvaluationWorkflowService.cs:94-96`): +``` +// ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" ===== +// "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi." +``` +Khớp 1-1 lời anh: *"mọi người nói là ko cần cái đó"*. + +**Gỡ tới đâu (6 file, `git show --stat 6db195d`):** +- Domain: xoá MỌI transition → `TuChoi` ở **cả 4 policy** (NccOnly + NccWithPlan + ForV2Schema + FromDefinition) ⇒ `nextPhases` hết trả TuChoi ⇒ **nút FE tự biến mất** (đúng cơ chế PH-05 tầng-2). +- Service: guard chặn `targetPhase=TuChoi` **đứng TRƯỚC mọi branch, chặn CẢ Admin** — commit message ghi *"spec bỏ hẳn, không escape hatch"*. +- FE ×2 app: `next.filter(p !== TuChoi)` (= `PeWorkflowPanel.tsx:257` hiện tại), `dialog/isCancel` **giữ dead-safe để flip lại dễ**. + +### PH-16 🔴🔴 [CODE] Chính message của guard S60 trỏ vào **ngõ cụt** — đây là gốc UAT hôm nay +`PurchaseEvaluationWorkflowService.cs:101-106`: +``` +"Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " + +"Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp." +``` +⇒ Hệ thống **tự khai** lối thoát duy nhất = *"Xóa phiếu khi còn Bản nháp"*. Nhưng phiếu `PE/2026/A/046` đang ở **ChoDuyet**, không còn Bản nháp ⇒ chỉ dẫn này **không thực hiện được**. [SUY LUẬN — nhưng dựa trên 2 mảnh CODE khớp nhau: guard-message + `PeDetailTabs.tsx:460` gate `phase === DangSoanThao`] + +⚠️ **Hệ quả kèm theo (surprise):** allow-list xoá ở BE là `{DangSoanThao, TuChoi}` (`PurchaseEvaluationFeatures.cs:1404-1405`) — mà `TuChoi` **không còn tới được** từ S60 ⇒ **nửa allow-list đã chết**. [CODE] +🎁 S60 để sẵn đường lùi, ghi trong chính comment (`Service:99-100`): *"Flip lại nếu cần: xóa guard này + restore transitions trong PurchaseEvaluationPolicy."* + +### PH-17 [CODE] **KHÔNG tìm thấy** nút Xóa/Thu-hồi từng nằm trong `PeWorkflowPanel` (màn duyệt) +`git log -S` trên **cả 2 app** cho `Trash2` · `onDelete` · `api.delete` giới hạn file `PeWorkflowPanel.tsx` → **0 commit cả 3**. Nút "Xóa phiếu" chỉ từng sống ở `PeDetailTabs` (workspace), sinh ra ở `378c993` (2026-05-07 16:37) với ràng buộc ghi rõ trong commit body: *"CHỈ Bản nháp (DangSoanThao), KHÔNG xóa Trả lại (đã có lịch sử workflow)"*. + +**Lệnh đã chạy (liệt kê đủ để tái lập):** +``` +git log --oneline -S "Xóa phiếu" -- fe-user/src/components/pe fe-user/src/pages/pe fe-admin/src/components/pe fe-admin/src/pages/pe → 1 hit (378c993) +git log --oneline -S "Thu hồi" -- fe-user/src fe-admin/src → 0 hit +git log --oneline -S "Hủy phiếu" -- fe-user/src fe-admin/src → 0 hit +git log --oneline -S "Huỷ phiếu" -- fe-user/src fe-admin/src → 0 hit +git log --oneline -S "Từ chối" -- fe-{user,admin}/src/components/pe/PeWorkflowPanel.tsx → 7 hit (6db195d = gỡ) +git log --oneline -S "Trash2"|"onDelete"|"api.delete" -- fe-{user,admin}/src/components/pe/PeWorkflowPanel.tsx → 0 hit ×3 +git log --oneline --format="%h %ad %s" -- fe-user/src/components/pe/PeWorkflowPanel.tsx → 30 commit (đọc hết) +grep -i "từ chối|xóa" docs/changelog/sessions/2026-06-12-S60-S62-*.md → xác nhận §S60 dòng 23-27 +``` +**Kết luận Q5:** *"chỗ cho huy"* = **nút Từ chối** (`✗ Từ chối`, màu đỏ, cùng khối HÀNH ĐỘNG), gỡ `6db195d` 2026-06-12, lý do = UAT anh Kiệt 14:14. **Không phải** nút Xóa — nút Xóa chưa bao giờ có ở màn duyệt. Ngoài ra `9c330d2` (2026-06-11, S59) trước đó đã ẩn Trả lại+Từ chối khi approver == drafter (`PeWorkflowPanel.tsx:476`). +🔴 **Spec phải trả lời:** khôi phục `TuChoi` (rẻ, có đường lùi sẵn) **hay** dựng hành-động Xóa mới? Điểm khác biệt nghiệp vụ: `TuChoi` = phiếu còn tồn tại (vẫn hiện ở tab "Từ chối") — **chưa chắc triệt tiêu lũy kế**; Xóa = `IsDeleted=1` khuất khỏi mọi query. Anh Tra cần **"ko có lũy kế lên"** ⇒ nghiêng về Xóa. [SUY LUẬN] + +--- + +## Q6 — Pattern 16-bis 4-place mirror + +### PH-18 [CODE] Trả lời DỨT KHOÁT + +**Nhánh 1 — chỉ thêm nút vào màn có sẵn (kịch bản mặc định): KHÔNG kích hoạt luật 4-place.** +Luật 16-bis chỉ áp khi có **page/route mới**, đúng như comment tại `fe-user/src/components/Layout.tsx:86` (*"4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap"*) và `:73-74` (thiếu staticMap → `MenuLeaf … if (!path) return null` → sidebar **drop im lặng**). Việc này: 0 page mới, 0 route `App.tsx`, 0 key `menuKeys.ts`, 0 entry `staticMap` (`Layout.tsx:56-106`). Màn đích đã có route `/purchase-evaluations?pendingMe=1` (`Layout.tsx:134`). +⇒ **Luật thay thế phải tuân = "2-app mirror"** (PH-01): `PeWorkflowPanel.tsx` byte-identical ở fe-user + fe-admin. Sửa 1 bên = sót — nguy hiểm ngang mức quên staticMap. + +**Nhánh 2 — nếu Q3(b) chốt phải thêm MENU KEY MỚI: vẫn KHÔNG phải 4-place, mà là bộ mirror KHÁC.** +`Pe_*` **không đi qua `staticMap`** — chúng resolve bằng nhánh regex động `Layout.tsx:119-134` (`peMatch`), nên "chỗ thứ 4" của 16-bis không áp dụng. Bộ phải chạm khi thêm key PE mới là: +1. `MenuKeys.cs` — factory + **`All` (`:147-165`)** ⇐ nếu quên, policy không được đăng ký (PH-12). +2. `DbInitializer.SeedMenuTreeAsync` (`:1870-1877`) + 2 chỗ liệt kê key (`:2092-2096`, `:2492-2496`). +3. `Layout.tsx` regex `peMatch` **cả 2 app** — hiện đã LỆCH (fe-user có `WfView`, fe-admin không) ⇒ thêm suffix mới phải sửa cả 2. +4. `docs/STATUS.md` row `Menu keys` + row `Policies` (derived `|All| × |Actions|`, B1 canonical). +⇒ [SUY LUẬN] Đường **rẻ nhất & ít rủi ro nhất vẫn là dùng key `PurchaseEvaluations` sẵn có + action `Delete`** (PH-12) — 0 key mới ⇒ nhánh 2 không phải mở. + +--- + + diff --git a/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md new file mode 100644 index 0000000..48149b6 --- /dev/null +++ b/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-invest-menu-3.md @@ -0,0 +1,61 @@ +# sub-invest-menu-3 — lát cắt 3: MENU restructure + màn "Đã xóa" + "người đứng đầu phòng" + +> run: `2026-07-27-S155-pe-delete-approver` · vai: investigator-codebase · ghi-đĩa-trong-lúc-làm (chống #53) +> Nhãn: `[CODE]` = đọc thẳng file có file:line · `[SUY LUẬN]` = diễn giải · `[CHƯA XÁC MINH]` = chưa chứng được + +--- + +## Q-A — Menu định nghĩa ở ĐÂU, thêm mục mới chạm những gì + +### A-1 `[CODE]` 🔴 MENU LÀ **SEED DB**, KHÔNG phải hard-code FE — dứt khoát + +Bằng chứng 3 tầng: + +1. **Entity + bảng thật:** `src/Backend/SolutionErp.Domain/Identity/MenuItem.cs` — bảng `MenuItems` (Key PK, Label, ParentKey, Order, Icon, +`IsVisible`/`DisplayLabel` từ Mig 27). +2. **Nơi khai 4 mục con của `Pe_`:** `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:1867-1878` + +```csharp +foreach (var code in MenuKeys.PurchaseEvaluationTypeCodes) +{ + tree.Add((MenuKeys.PurchaseEvaluationGroup(code), label, MenuKeys.PurchaseEvaluations, peOrder++, "FileCheck")); // :1870 + tree.Add((MenuKeys.PurchaseEvaluationWorkflowView(code), "Luồng duyệt", ...group..., peOrder++, "Network")); // :1874 + tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", ...group..., peOrder++, "List")); // :1875 + tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", ...group..., peOrder++, "Plus")); // :1876 + tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Duyệt", ...group..., peOrder++, "CheckCircle2")); // :1877 +} +``` +⇒ **Đúng 4 mục owner thấy trên prod = 4 dòng `:1874-1877`.** Nhãn `"Duyệt"` nằm ở **`:1877`**, key = `Pe__Pending`. + +3. **FE render từ API, KHÔNG hard-code danh sách:** `fe-user/src/components/Layout.tsx` render `AuthContext.menu` (fetch `/api/menus/me` → `GetMyMenuTreeQuery`). FE chỉ có **map key → route** (`resolvePath`, `Layout.tsx:55-137`), không có danh sách mục. + +### A-2 `[CODE]` KHÔNG cần migration — nhưng CẦN chạy seed (restart API) + +`DbInitializer.cs:1888-1914` = upsert idempotent: `existingItems` theo Key → **thiếu thì INSERT**, có rồi thì **chỉ sửa `Order`** (`:1899-1903`), **KHÔNG đụng Label/ParentKey/Icon** (`:1897-1898` comment nói rõ). +⇒ Thêm 2 mục mới `Đã duyệt` + `Đã xóa` = thêm 2 dòng `tree.Add(...)` + 2 const trong `MenuKeys.cs` → **API restart là có** (DbInitializer chạy lúc startup). **KHÔNG cần EF migration** (không đổi schema). `[SUY LUẬN]` nhưng dựa thẳng vào cơ chế upsert ở `:1891-1908`. + +### A-3 `[CODE]` Không phải 4-place mirror — là **6 chỗ** cho mục PE mới + +| # | Chỗ | File:line | Ghi chú | +|---|---|---|---| +| 1 | Hằng key BE | `MenuKeys.cs:134-141` (thêm `PurchaseEvaluationApproved/Deleted(typeCode)`) | factory `$"Pe_{typeCode}_"` | +| 2 | **Seed tree DB** | `DbInitializer.cs:1874-1877` (+2 dòng) | 🔴 chỗ FE-only-mindset hay quên | +| 3 | Seed permission admin | `DbInitializer.cs:2092-2096` + `:2492-2496` | **2 nơi**, xem A-3bis | +| 4 | FE `resolvePath` | `fe-user/Layout.tsx:120` regex + `:128-134` branch · `fe-admin/Layout.tsx:107` regex + `:110-118` | regex `(List\|Create\|Pending\|WfView)` phải nới | +| 5 | FE route `App.tsx` | (chỉ nếu dùng URL MỚI; nếu tái dùng `/purchase-evaluations?...` thì **0 chỗ**) | xem Q-B | +| 6 | FE `menuKeys.ts` | `fe-{admin,user}/src/lib/menuKeys.ts` | 🔴 xem A-3ter: **hiện KHÔNG có `Pe_*`** ⇒ có thể là 0 chỗ | + +### A-3bis `[CODE]` Chỗ thứ 5 & 6 THẬT: 2 seed permission + +- `DbInitializer.cs:2092-2096` — gom `peKeys` (Group/WfView/List/Create/Pending) +- `DbInitializer.cs:2492-2496` — gom `menuKeys` (cùng 5 key) + +⇒ **2 danh sách song song liệt kê ĐÍCH DANH 5 key PE.** Thêm key mới mà quên 1 trong 2 = permission seed lệch. (Đã đọc ngữ cảnh 2 hàm ở A-6.) + +### A-3ter `[CODE]` `menuKeys.ts` FE **không chứa `Pe_*`** — lệnh đã chạy + +``` +grep -rn "Pe_DuyetNcc|Pe_\$\{" fe-user/src/lib/menuKeys.ts fe-admin/src/lib/menuKeys.ts → 0 dòng output +``` +⇒ FE mirror const chỉ có key top-level; `Pe_*` sinh động qua regex trong `Layout.tsx`. **Không phải chỗ phải sửa.** + +