wal: flush 20260727T0909

This commit is contained in:
pqhuy1987
2026-07-27 09:09:59 +07:00
parent 0db0c6a3ee
commit 06ab87f67e
2 changed files with 256 additions and 1 deletions

View File

@ -83,4 +83,140 @@ foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
---
*(file đang được ghi tiếp — các mục Q2..Q7 bên dưới)*
## Q2 — State machine PE cho phép xoá ở trạng thái nào?
### F4. Enum ĐỦ 10 giá trị (5 sống + 5 legacy deprecated) `[CODE]`
`src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs:15-27`
| Giá trị | Số | Ghi chú trong mã |
|---|---|---|
| `DangSoanThao` | 1 | Nháp |
| `ChoPurchasing` | 2 | `[LEGACY] deprecated` |
| `ChoDuAn` | 3 | `[LEGACY] deprecated` |
| `ChoCCM` | 4 | `[LEGACY] deprecated` |
| `ChoCEODuyetPA` | 5 | `[LEGACY] deprecated` |
| `ChoCEODuyetNCC` | 6 | `[LEGACY] deprecated` |
| `DaDuyet` | 7 | Đã duyệt — terminal thành công |
| `ChoDuyet` | **10** | Đã gửi duyệt — generic intermediate |
| `TraLai` | 98 | Trả lại — Phase riêng |
| `TuChoi` | 99 | Từ chối — terminal khoá phiếu |
⚠️ Lưu ý cho spec: **5 phase legacy 2-6 vẫn nằm trong enum****vẫn lọt lưới các query lũy kế** (xem F9) — bất kỳ luật xóa nào viết theo dạng "≠ DaDuyet" hay "∈ {…}" phải nói rõ 2-6 rơi vào đâu.
### F5. Guard XÓA hiện hành: chặn ChoDuyet — **phiếu `PE/2026/A/046` KHÔNG xóa được** `[CODE]`
`PurchaseEvaluationFeatures.cs:1404-1406` — allow-list = `{DangSoanThao, TuChoi}`. Phiếu ở `ChoDuyet`**409 ConflictException "Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối."**
⇒ Đây chính xác là chỗ chặn cái Tra Sol muốn. **Đường ống đã có sẵn 100%, chỉ thiếu 1 nhánh cho ChoDuyet + gating quyền.** `[SUY LUẬN]` — mã chỉ nói guard, chuyện "chỉ thiếu 1 nhánh" là đánh giá của tôi.
### F6. Guard KHÔNG quan tâm "đã có ≥1 lượt duyệt" `[CODE]`
Không có điều kiện nào đọc `Approvals.Count` / `CurrentApprovalLevelOrder` / `CurrentWorkflowStepIndex` trong handler xóa (`:1399-1410` toàn văn ở F2). Luật hiện hành **chỉ nhìn Phase**, không nhìn tiến-độ-duyệt.
`Lịch sử duyệt (1)` của phiếu A/046 **không phải rào cản kỹ thuật**; nếu spec muốn cấm xóa sau khi đã có người duyệt thì đó là **luật MỚI**, không phải luật đang có.
### F7. Đối chiếu Contract (module anh em) — luật CHẶT HƠN `[CODE]`
`src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs:567-580` (trích nguyên văn):
```csharp
public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteContractCommand>
{
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
{
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase >= ContractPhase.DangInKy)
throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'.");
db.Contracts.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
```
⇒ HĐ dùng **ngưỡng `>=` phase**, PE dùng **allow-list**. Cả 2 đều **Remove() → soft qua interceptor**, đều **không ghi changelog**, đều **không check người**.
---
## Q3 — 🔴 LŨY KẾ (câu quan trọng nhất)
File trung tâm: `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeBudgetAccumulator.cs` (141 dòng, 2 hàm static).
### F8. (a) Điều kiện gom phiếu — có **HAI** phép cộng, không phải một `[CODE]`
**Window chung cả 2 hàm** (`:49-51``:100-102`) — y hệt nhau:
```csharp
var peers = db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.ProjectId == projectId && p.WorkItemId == workItemId
&& p.Id != peId && p.CreatedAt < peCreatedAt);
```
**(A) `ComputeAsync` — lũy kế "CHÍNH XÁC"** (`:40-82`):
- `PrevSubmitted` (`:53-59`): `Phase == ChoDuyet || Phase == DaDuyet``Count` + `SUM(BudgetPeriodAmount ?? 0)`.
🔴 **ChoDuyet ĐÃ được cộng ở đây** — tức phiếu bấm-sai-gói-thầu **đang ăn số ngay ở dòng "chính xác"**, không phải chỉ ở dòng tạm tính.
- `PrevSelected` (`:61-71`): `Phase == DaDuyet AND Suppliers.Any(IsWinner)` → SUM `ThanhTien` các quote `IsSelected`. (ChoDuyet **không** vào dòng này.)
**(B) `ComputePendingAsync` — lũy kế "TẠM TÍNH"** (`:92-139`):
- `PendingSubmitted` (`:104-112`): `Phase ∉ {DangSoanThao, DaDuyet, ChoDuyet, TuChoi}` → thực tế = `{TraLai}` + legacy 2-6. **Cố ý loại ChoDuyet** để không double-count với (A).
- `PendingSelected` (`:114-126`): `Phase ∉ {DangSoanThao, DaDuyet, TuChoi}` **AND** `Suppliers.Any(s => s.IsWinner)`**GỒM ChoDuyet-có-winner** (+ TraLai-có-winner + legacy 2-6) → SUM `ThanhTien` quote `IsSelected`.
- `PriorPes` (`:128-133`): `Phase ∉ {DaDuyet, TuChoi}` → danh sách phiếu "cần lưu ý" (gồm cả Nháp).
🔴 **Hệ quả trực tiếp cho đề bài:** phiếu ChoDuyet bấm sai gói thầu ăn số ở **2 chỗ**: `PrevSubmittedTotal` (dòng "chính xác") qua `BudgetPeriodAmount`, và `PendingSelectedTotal` (dòng "tạm tính") qua tổng báo giá được chọn.
🔴 **"Trả lại" KHÔNG triệt tiêu** — `TraLai` rơi khỏi (A) nhưng **rơi VÀO (B) cả 2 dòng** (`PendingSubmitted` + `PendingSelected` nếu còn winner). Đúng như run.md phán đoán, và đây là bằng chứng mã.
### F9. (b) **KHÔNG có dòng `IsDeleted == false` nào trong 2 query** — lọc đến từ global filter `[CODE]`
Grep toàn `PeBudgetAccumulator.cs`: **0 hit `IsDeleted`** trong thân query. Lọc đến từ EF global query filter:
`src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs:84`
```csharp
b.HasQueryFilter(x => !x.IsDeleted);
```
Và chính comment của accumulator đã khai điều này — `PeBudgetAccumulator.cs:15-16`:
```
// peers = PurchaseEvaluations cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this
// (HasQueryFilter !IsDeleted tự loại phiếu xoá mềm).
```
Củng cố: **`IgnoreQueryFilters` = 0 hit trên toàn `src/Backend`** (grep đã chạy) ⇒ không có đường nào lách filter.
### F10. (c) Set `IsDeleted=true` cho phiếu ChoDuyet → **TỰ RỚT khỏi cả 2 phép cộng, không cần sửa thêm** `[CODE] + [SUY LUẬN]`
`[CODE]`: cả 4 phép cộng đều bắt đầu từ `db.PurchaseEvaluations` (`:49`, `:100`) — kể cả 2 join tiền (`:64-71`, `:119-126`) vì chúng `from p in selectedPeers` / `pendingSelectedPeers`, tức đã bị filter chặn ở gốc. Join sang `PurchaseEvaluationSuppliers`/`Quotes` không cần filter riêng (2 bảng này là `BaseEntity`, **không có** `IsDeleted`, xác nhận `PurchaseEvaluationSupplier.cs:9` + `PurchaseEvaluationQuote.cs:8`).
`[SUY LUẬN]`: ⇒ chỉ cần **nới guard phase**`DeletePurchaseEvaluationCommandHandler` là số lũy kế tự đúng. **Không phải sửa accumulator.** (Chưa chạy runtime để chứng — xem "Chưa xác minh" cuối file.)
### F11. (e) Snapshot `budgetFrozen` (Mig 67) — **KHÔNG tự sửa, đây là nợ số liệu tiềm ẩn** `[CODE]`
- Phiếu `DaDuyet` đọc **11 cột snapshot**, không đọc live: `PurchaseEvaluationFeatures.cs:929` `var frozen = e.Phase == DaDuyet && e.ApprovedBudgetSnapshotAt != null;` → nhánh `:930-960` phục vụ từ `e.ApprovedBudget*`.
- Snapshot được chốt 1 lần tại finalize: `PurchaseEvaluationWorkflowService.cs:1018-1023` gọi `ComputeAsync` rồi gán `ApprovedBudgetPrevSubmittedTotal/Count/PrevSelectedTotal/Count`.
⇒ Nếu phiếu X (ChoDuyet, sai gói) **đã bị tính vào snapshot** của phiếu Y duyệt sau đó, rồi **sau này ta xóa X**, thì **số của Y KHÔNG đổi** (đúng ý đồ "đóng băng", nhưng số đó nay dựa trên 1 phiếu không còn tồn tại).
`[SUY LUẬN]` spec cần chốt: **chấp nhận** (freeze = record-of-decision) hay **phải re-compute snapshot phiếu sau**? Tôi nghiêng "chấp nhận + ghi vết", nhưng đây là quyết định của em main/anh, không phải của tôi.
⇒ Ngoài ra `:956-959`: nhánh frozen **cố tình để Pending* = 0/null** ⇒ phiếu DaDuyet không hiển thị tạm tính, nên xóa 1 phiếu ChoDuyet **không** làm đổi màn hình phiếu đã duyệt.
### F12. (d) Grep hết consumer — **chỉ có 2 hàm accumulator + 4 call-site, KHÔNG có read-site nào khác** `[CODE]`
Lệnh đã chạy (để có thể tái kiểm):
- `grep -rn "PrevSubmitted\|lũy kế" --include=*.cs src/Backend | grep -v Migrations/` → 23 hit, tất cả thuộc 4 file: `PeBudgetAccumulator.cs`, `PurchaseEvaluationFeatures.cs`, `PurchaseEvaluationWorkflowService.cs`, `PurchaseEvaluation.cs` (+ `PurchaseEvaluationConfiguration.cs` khai precision).
- `grep -rn "db\.PurchaseEvaluations" --include=*.cs src/Backend` → 31 hit; ngoài 2 dòng accumulator (`:49`, `:100`), **không hit nào là phép cộng tiền theo peers** (còn lại: FirstOrDefault theo Id, list/inbox join, seeder, usage-count workflow).
- `grep -rn "PurchaseEvaluation" src/Backend/SolutionErp.Application/Reports/ .../ReportsController.cs`**0 hit** ⇒ module Báo cáo KHÔNG gom PE.
- `PeWorkItemBudgetFeatures.cs` (trang ngân sách gói thầu): đọc/ghi bảng `PeWorkItemBudgets` thuần, **không** `Sum` nào trên PE (chỉ `FirstOrDefaultAsync` theo `PeId``:86`, `:157`, `:230`).
**4 call-site của accumulator:**
| # | File:line | Hàm | Dùng để |
|---|---|---|---|
| 1 | `PurchaseEvaluationFeatures.cs:966` | `ComputeAsync` | display live rows 1-2 |
| 2 | `PurchaseEvaluationFeatures.cs:972` | `ComputePendingAsync` | display live rows tạm tính |
| 3 | `PurchaseEvaluationWorkflowService.cs:1018` | `ComputeAsync` | **ghi snapshot** khi finalize |
| 4 | `PurchaseEvaluationWorkflowService.cs:1029` | `ComputePendingAsync` | ghi changelog cảnh báo D4 |
`[SUY LUẬN]` ⇒ Rủi ro "sót read-site" (bài học `cardinality_change_grep_consumers`) ở đây **THẤP**: toàn bộ đường tiền đi qua đúng 1 file 141 dòng. Rủi ro thật nằm ở **snapshot đã đóng băng** (F11), không nằm ở query.
---
## Q4 — Vết / audit
*(đang điều tra)*
*(file đang được ghi tiếp — Q4..Q7 bên dưới)*

View File

@ -0,0 +1,119 @@
# sub-invest-fe-2 — FE + PHÂN QUYỀN: nút XÓA phiếu PE ở màn DUYỆT
> run: `2026-07-27-S155-pe-delete-approver` · vai: investigator-codebase (READ-only)
> Quy ước: **[CODE]** = đọc thẳng từ file · **[SUY LUẬN]** = diễn giải của tôi · **[CHƯA XÁC MINH]** = không chứng được.
> Ghi TRONG LÚC LÀM (bài học #53).
---
## Q1 — Định vị component khối HÀNH ĐỘNG (`✓ Duyệt` / `← Trả lại`)
### PH-01 [CODE] Component = `PeWorkflowPanel.tsx`, tồn tại ở **CẢ 2 APP**, **byte-identical**
| App | Đường dẫn | md5 |
|---|---|---|
| fe-user | `D:\...\SOLUTION_ERP\fe-user\src\components\pe\PeWorkflowPanel.tsx` (942 dòng) | `02671be6334110028c55fe82f5e70dee` |
| fe-admin | `D:\...\SOLUTION_ERP\fe-admin\src\components\pe\PeWorkflowPanel.tsx` (942 dòng) | `02671be6334110028c55fe82f5e70dee` |
**SỬA 1 BÊN LÀ SÓT.** Quy ước dự án (CLAUDE.md): duplicate giữa 2 app là CÓ CHỦ ĐÍCH.
Các file PE khác cũng byte-identical (cùng md5 2 app):
- `components/pe/PeListPanel.tsx` (264 dòng) — `b372d5e7f6c97b8cbe79ae7c2f1ccef7`
- `components/pe/PeDetailTabs.tsx` (3610 dòng) — `27ec74d8cd75024a4472304018e5e05f`
- `pages/pe/PurchaseEvaluationsListPage.tsx` (712 dòng) — `99e38e167d2f745ea7628174f1d2d6bf`
### PH-02 [CODE] Vị trí render 2 nút — `PeWorkflowPanel.tsx:458-506` (cùng số dòng ở CẢ 2 app)
```
:458 {next.length > 0 && !readOnly && (
:460 <Label className="text-xs">Hành động:</Label> ← nhãn khối "HÀNH ĐỘNG"
:462 {next.map(p => { ← render 1 button / phase kế tiếp
:467 const isSendBack = (p === Phase.DangSoanThao || p === Phase.TraLai) && ...
:470 const isCancel = p === Phase.TuChoi
:480 const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
:487-501 <button onClick={() => !isDisabled && setTarget(p)} disabled={isDisabled} .../>
:506 )}
```
Nút KHÔNG hardcode — sinh động từ mảng `next` (danh sách phase kế tiếp BE trả về). Nút Xóa mới sẽ **KHÔNG lọt** vào vòng `next.map` này (Xóa không phải 1 phase) ⇒ phải render **cạnh** khối, không nhét vào map. [SUY LUẬN]
### PH-03 [CODE] "Từ chối" đã bị gỡ khỏi UI (nhưng logic còn) — `PeWorkflowPanel.tsx:254`
```
:254 // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi".
```
⇒ giải thích vì sao ảnh prod chỉ thấy 2 nút. Tiền lệ **gỡ-nút-theo-UAT** đã xảy ra ở đúng khối này.
### PH-04 🔴 [CODE] Bằng chứng "phiếu Nháp có nút Xóa riêng" — `PeWorkflowPanel.tsx:472-476`
```
:472 // S59 anh chốt (UAT: "nhân viên tạo phiếu thì trả lại và từ chối cho ai?"):
:473 // người duyệt CHÍNH LÀ người soạn phiếu → ẩn cả Trả lại + Từ chối
:474 // (trả cho chính mình vô nghĩa — đang sửa inline được; hủy phiếu sai
:475 // = nhờ cấp khác Từ chối, phiếu Nháp có nút Xóa riêng).
:476 if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null
```
⇒ comment tự khai: đường "hủy phiếu sai" hiện tại = **nhờ cấp khác Từ chối** (mà nút Từ chối đã bị ẩn @S60!) + **Xóa chỉ có ở phiếu Nháp**. Đây chính là lỗ hổng UAT anh Tra đang gặp. [SUY LUẬN]
---
## Q2 — Điều kiện hiện 2 nút hiện tại + nút Xóa mới phải gate bằng gì
### PH-05 [CODE] Chuỗi gate 3 tầng (fe-user & fe-admin giống hệt)
**Tầng 1 — màn hình:** `PurchaseEvaluationsListPage.tsx:37` `const pendingMe = sp.get('pendingMe') === '1'`
→ truyền `readOnly={!pendingMe}` vào `PeWorkflowPanel` tại **2 call-site**: `:588-592` (inline panel) + `:674-678` (focus overlay).
⇒ khối HÀNH ĐỘNG **chỉ sống ở URL `?pendingMe=1`** (= menu "Duyệt"). Ở "Danh sách" → `readOnly=true` → thay bằng dòng chữ `:507-511`.
**Tầng 2 — khối:** `PeWorkflowPanel.tsx:458` `{next.length > 0 && !readOnly && (`
- `next` = `PeWorkflowPanel.tsx:257` `evaluation.workflow.nextPhases.filter(p => p !== Phase.TuChoi)`**BE là single source of truth** (`:255` ghi rõ "BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE").
**Tầng 3 — từng nút:**
- ẩn hẳn Trả lại/Từ chối khi actor == drafter: `:476` `if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null`
- disable khi không đến lượt: `:479` `const isDisabled = blockedByV2Level`
- `:103` `const blockedByV2Level = isV2Pending && !actorInV2Level`
- `:102` `const isV2Pending = !!evaluation.currentApproval`
- `:99-100` `const actorInV2Level = isAdmin || (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))`
- `:61` `const isAdmin = currentUser?.roles?.includes('Admin') ?? false`
### PH-06 🔴 [CODE] KHÔNG có `usePermission` / `PermissionGuard` nào trong PeWorkflowPanel
Grep `usePermission|PermissionGuard` trong `fe-{user,admin}/src/components/pe/*` + `pages/pe/*`**0 hit** (xem Q3 PH-11).
⇒ gate hiện tại **thuần role + workflow-position**, KHÔNG đụng ma trận quyền menu. [CODE]
### PH-07 [SUY LUẬN] Gate đề xuất cho nút Xóa mới (nhất quán với 2 nút kia)
Cùng vị trí `PeWorkflowPanel.tsx:458-506` (đặt CẠNH map, không nhét vào map vì Xóa ≠ phase):
`!readOnly` (chỉ màn Duyệt) **∧** `actorInV2Level` (đúng lượt duyệt hoặc Admin) **∧** `evaluation.phase === ChoDuyet`.
⚠️ Đây là gate **display-layer**; tầng API độc lập — xem Q3.
---
## Q4 — PE đang xóa được ở đâu (khuôn tái dùng)
### PH-08 [CODE] Nút "Xóa phiếu" hiện có — `PeDetailTabs.tsx:457-472` (cả 2 app, byte-identical)
```
:445 {mode === 'workspace' && canEditPhase && !readOnly && ( ← gate KHỐI action-bar
:457 /* Xóa phiếu — CHỉ DangSoanThao (bản nháp). TraLai không cho xóa
:458 (đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
:459 (AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */
:460 {evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && ( ← gate NÚT
:463 onClick={() => { if (confirm(`Xóa phiếu "…"? … soft-delete …`)) onDelete() }}
:470 <Trash2 …/> Xóa phiếu
```
- `canEditPhase` = `:124` `isEditablePhase(evaluation.phase)``fe-user/src/types/purchaseEvaluation.ts:65` (DangSoanThao ∥ TraLai).
- Nút thu hẹp hơn nữa: **CHỈ `DangSoanThao`** (Nháp). `TraLai` bị loại có chủ đích (`:457-458`).
### PH-09 [CODE] API + gate quyền của đường xóa hiện tại
`PurchaseEvaluationsListPage.tsx:89-90`
```
const del = useMutation({ mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`) …
```
+ bản mobile fullpage `:697-698` `api.delete(`/purchase-evaluations/${id}`)`.
FE **không** gate bằng permission nào cả — chỉ gate bằng phase + mode. [CODE]
### PH-10 🔴🔴 [CODE] Plumbing xóa ĐÃ ĐƯỢC NỐI SẴN vào màn DUYỆT — chỉ bị điều-kiện-render chặn
**cả 2 call-site của màn Duyệt**, prop `onDelete` đã truyền mutation xóa thật:
- `PurchaseEvaluationsListPage.tsx:570-575` (inline): `onDelete={() => del.mutate(detail.data!.id)}` + `readOnly={true}`
- `PurchaseEvaluationsListPage.tsx:665-670` (focus overlay = ảnh prod): `onDelete={() => del.mutate(detail.data!.id)}` + `readOnly={true}`
Nút không hiện vì `:445` yêu cầu `mode === 'workspace' && canEditPhase && !readOnly` mà ở đây `mode` = default `'detail'` (`:107`), `readOnly=true`, `canEditPhase=false` (phase ChoDuyet).
⇒ [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).
---