wal: flush 20260727T0909
This commit is contained in:
@ -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à **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` và `: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)*
|
||||
|
||||
@ -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).
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user