wal: flush 20260727T1350
This commit is contained in:
@ -63,7 +63,12 @@ chain:
|
||||
[x] ✅ **A-ĐỢT-2 FE XONG** (sau 1 lần resume) — `tsc -p tsconfig.app.json` **SẠCH cả 2 app** · **hợp-đồng NỐI ĐỦ 2 CHIỀU**: `by-approver` **6 hit** · `/deleted` **4 hit** · **md5 mirror KHỚP** `eb55781a…` ×2 · `Layout.tsx` ×2 map `Approved`→`?phase=7` / `Deleted`→`?deleted=1`; fe-admin CỐ Ý không có `WfView` (giữ quyết định (c)). ⇒ **KHÔNG lặp lại F3 đợt 1**
|
||||
[x] ✅ **A-ĐỢT-2 test XONG + lead vá T26** — **561 PASS / 0 FAIL** (45 Domain + 516 Infra, +10 test). `test-specialist` giữ T26 ĐỎ CÓ CHỦ Ý (đúng luật: bug production, không nới assert) + grep-cùng-lớp xác nhận site duy nhất + tự dọn file chẩn-đoán (khác agent đợt 1 bỏ quên `ZZ_DIAG`)
|
||||
[x] 🔴 **BUG T26 + cách vá (đáng ghi memory)**: `Add(changelog)` rồi `Remove(pe)` ⇒ EF chạy cascade client-side **NGAY tại `Remove()`** (`CascadeDeleteTiming.Immediate`) ⇒ **DETACH** changelog đang `Added` ⇒ SaveChanges ghi **0 row IM LẶNG**. Đo: `stateAfterAdd=Added → stateAfterRemove=Detached → rowsInDb=0`. Trớ trêu: phiếu KHÔNG bị xóa thật (interceptor đổi Deleted→Modified = xóa MỀM) ⇒ **cascade hành động theo một vụ xóa KHÔNG BAO GIỜ xảy ra**. **Lead vá = ĐẢO THỨ TỰ** (`Remove` TRƯỚC, `Add` SAU) — rẻ hơn cả 3 hướng sub đề (2×SaveChanges+transaction · `CascadeDeleteTiming.Never` · set tay IsDeleted)
|
||||
[!] wf: A-ĐỢT-2 reviewer — spawn TRỰC TIẾP `a64f...` soi diff đợt 2 trước deploy; ưu tiên: vá-đảo-thứ-tự của lead (chưa ai soi, hỏi có đúng trên SQL Server không chứ không chỉ SQLite) · 3 bẫy `IgnoreQueryFilters`/IDOR/D3 · guard per-NGƯỜI · labelBackfill
|
||||
[x] 🔴 **A-ĐỢT-2 reviewer bắt HIGH `policy-blocks-approver`** — ĐO trên DB: approver thật `binh.le@…` vai `CostControl` `CanDelete=0` ⇒ **403 ngay tầng policy**, chưa chạm 3 rào handler; FE gate CÙNG quyền ⇒ **không thấy nút, không thấy lỗi** (gotcha #44). 2 seeder KHÔNG BAO GIỜ nâng `CanDelete` (`:2139-2140` chỉ Read/Create · `:2538` skip-if-exists) ⇒ restart bao nhiêu lần cũng vô ích. **Đây là LẦN 3 cùng lớp lỗi "cơ chế đúng, dữ liệu không có"** — lead chữa triệu-chứng H1 đợt 1 (tách 2 đường) mà KHÔNG hỏi lại "ai đang có quyền trên đường MỚI"
|
||||
[x] **A-owner chốt (14)**: phương án **(b) BỎ policy theo vai, giữ 3 rào nghiệp vụ**. Lý: cờ F6 = admin tick ĐÍCH DANH từng người ⇒ chặt hơn quyền theo vai; handler có `ICurrentUser` + ném Forbidden ⇒ đúng khuôn controller ("class any-auth, handler fine-grained")
|
||||
[x] A-lead land 3 sửa (2 ĐẦU, chống sót read-site): (1) gỡ `[Authorize(Policy)]` khỏi `DeleteByApprover` + chú thích dài · (2) gỡ `can(...,'Delete')` khỏi FE gate + dọn 3 khai thừa, **re-mirror md5 `7cd6bbfe…` ×2** · (3) T25 viết lại ĐẢO CHIỀU (`Should().BeNull()`) + **đổi tên** `RequiresDeletePolicy`→`MustStayPolicyFree` (tên cũ nói dối sau khi đổi spec). Đo: **561 PASS/0 FAIL** · tsc 0 lỗi ×2
|
||||
[x] A-lead vá chú-thích LOW-1 reviewer bắt đúng: bản đầu viết "thêm changelog SAU khi state đã đổi" — SAI một nhịp (lúc `Add()` phiếu VẪN `Deleted`); cơ-chế thật = **cascade tức thời đã xong tại `Remove()` khi chưa có con nào để gỡ**
|
||||
[!] wf: A-ĐỢT-2 reviewer RESUME lần 2 — đã báo nó 3 thay đổi giữa lượt (mục-tiêu-di-động), xin verdict trên trạng-thái HIỆN TẠI
|
||||
[~] wf: A-ĐỢT-2 reviewer — spawn TRỰC TIẾP `a64f...`; ưu tiên: vá-đảo-thứ-tự của lead (chưa ai soi, hỏi có đúng trên SQL Server không chứ không chỉ SQLite) · 3 bẫy `IgnoreQueryFilters`/IDOR/D3 · guard per-NGƯỜI · labelBackfill
|
||||
[~] A-ĐỢT-2 test cũ: 516 total, 1 FAIL = `T26_Delete_WritesChangelogWithDeleteAction_AndCarriesReasonWhenProvided`. Handler CÓ ghi `ChangelogAction.Delete` (`PeSoftDeleteFeatures.cs:126-131`) ⇒ đỏ ở chi-tiết khác (nghi phần mang LÝ DO). `test-specialist` còn chạy (testhost lock ⇒ build báo MSB3027 = **KHÔNG phải lỗi mã**), chờ nó khai
|
||||
[~] wf: A-ĐỢT-2 W1-bis — 2 spawn TRỰC TIẾP song song (KHÔNG qua hmw): `implementer-frontend` làm phần chính FE (`sub-d2-fe-3.md`) · `test-specialist` viết 10 test T21-T26 (`sub-d2-test-4.md`)
|
||||
[~] wf: A-ĐỢT-2 W1 hmw — run-id `wf_55f6201f-a52` · anh lệnh "làm tiếp luôn" 2026-07-27 (KHÔNG chờ UAT đợt 1). 2 task SONG SONG: BE (xóa-by-approver + list /deleted + menu seed + changelog + 10 test) · FE (nút xóa panel + 3 mục menu + màn Đã xóa + md5-mirror)
|
||||
|
||||
@ -0,0 +1,293 @@
|
||||
# Reviewer — SOI ĐỐI-KHÁNG DIFF ĐỢT 2 (cửa cuối trước deploy prod UAT)
|
||||
|
||||
- **Run:** `2026-07-27-S155-pe-delete-approver` · lượt: `reviewer-diff-dot2`
|
||||
- **Ngày:** 2026-07-27
|
||||
- **Nền:** đợt 1 đã deploy + verify PASS 6/6 trên prod (`b1bae77`) — cờ F6 `AllowApproverDelete` + `PUT /api/approval-workflows-v2/{id}`.
|
||||
- **Đợt 2 (lượt này):** nút XÓA phiếu ở màn duyệt + menu 3 mục + màn "Đã xóa".
|
||||
- **Trạng thái file:** ĐANG GHI (append từng finding — file này hợp-lệ ở mọi thời điểm; dòng cuối cùng có marker END + TOTAL khi hoàn tất).
|
||||
|
||||
## Phạm vi vật lý đã chốt từ `git status` (13:2x, 2026-07-27)
|
||||
|
||||
**Untracked (2):**
|
||||
- `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs` (264 dòng)
|
||||
- `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs`
|
||||
|
||||
**Modified (11 file code + 4 file governance/memory ngoài phạm vi soi):**
|
||||
```
|
||||
fe-admin/src/components/Layout.tsx | 15 ++-
|
||||
fe-admin/src/components/pe/PeWorkflowPanel.tsx | 123 ++++++-
|
||||
fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx | 103 ++++--
|
||||
fe-admin/src/types/purchaseEvaluation.ts | 4 +
|
||||
fe-user/... (mirror 4 file y hệt)
|
||||
src/Backend/.../PurchaseEvaluationsController.cs | 31 ++
|
||||
src/Backend/.../Identity/MenuKeys.cs | 9 ++
|
||||
src/Backend/.../Persistence/DbInitializer.cs | 21 +-
|
||||
11 files changed, 517 insertions(+), 31 deletions(-)
|
||||
```
|
||||
|
||||
🔴 **XÁC NHẬN NGAY: KHÔNG có file nào dưới `Migrations/`** trong cả tracked lẫn untracked ⇒ khớp lời khai của lead ("đợt 2 không có migration"). Rủi ro F8 của đợt 1 (migration untracked bị `commit -a` bỏ lại) **KHÔNG tái diễn theo đúng hình dạng cũ** — nhưng xem mục THAO-TÁC-COMMIT ở cuối: hình dạng ĐÃ ĐỔI, không phải đã biến mất.
|
||||
|
||||
---
|
||||
|
||||
(đang soi — các mục dưới đây append dần)
|
||||
|
||||
---
|
||||
|
||||
# 🔴 H1 (HIGH) — Nút "Xóa phiếu" KHÔNG BAO GIỜ HIỆN: FE đọc field mà BE không hề trả
|
||||
|
||||
**File:**
|
||||
- FE gate: `fe-admin/src/components/pe/PeWorkflowPanel.tsx:136` (mirror `fe-user/src/components/pe/PeWorkflowPanel.tsx:136`)
|
||||
- FE khai type: `fe-admin/src/types/purchaseEvaluation.ts:431` (mirror `fe-user/...:431`)
|
||||
- BE DTO: `src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs:123-131`
|
||||
- BE site dựng DTO (DUY NHẤT): `src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs:1088-1097`
|
||||
|
||||
**Điều sai.** FE gate nút xóa bằng `levelOptions?.allowApproverDelete === true`. `levelOptions` = `evaluation.currentLevelOptions`, ánh xạ đúng `ApprovalWorkflowOptionsDto`. Record đó có **đúng 8 thành viên**, KHÔNG có `AllowApproverDelete`; site dựng duy nhất `:1088` truyền **đúng 8 đối số**, cũng không có cờ F6. Vậy JSON của `GET /api/purchase-evaluations/{id}` **không chứa khoá `allowApproverDelete`** ⇒ ở runtime giá trị là `undefined` ⇒ `=== true` luôn false ⇒ **nút không render cho bất kỳ ai, kể cả admin đã bật cờ F6 trong Designer**.
|
||||
|
||||
Toàn bộ phần owner thật sự cần (nút XÓA ở màn duyệt) **không tới được tay người dùng**. BE handler + endpoint vẫn đúng, nhưng không có đường FE nào gọi tới nó.
|
||||
|
||||
**Vì sao mọi cổng xanh mà vẫn lọt.** `tsc --noEmit` xanh vì type FE là bản chép tay, khai `allowApproverDelete: boolean` — trình biên dịch tin lời khai, không đối chiếu BE. `dotnet build` xanh vì record positional 8 tham số nhận đúng 8 đối số. 561 test xanh vì test gọi thẳng handler qua MediatR, không đi qua DTO detail. Đây đúng lớp **xanh-giả**: mọi thước đo đều đo chỗ khác.
|
||||
|
||||
**Phép thử phá (làm rồi).** (i) `grep -rn "ApprovalWorkflowOptionsDto" src/Backend --include=*.cs` → chỉ 4 hit: 1 định nghĩa, 1 khai báo trong `PeDetailBundleDto:301`, 1 khai báo biến `:1045`, 1 site `new` `:1088`. Không có site thứ hai nào có thể bơm cờ. (ii) `grep -rn "AllowApproverDelete" src/Backend` → cờ chỉ sống ở entity, config, migration, `ApprovalWorkflowV2AdminFeatures` (màn Designer) và `PeSoftDeleteFeatures:114`; **không xuất hiện lần nào** trong `PurchaseEvaluationFeatures.cs`. (iii) Nếu ai đó đã truyền 9 đối số vào record 8 tham số thì `dotnet build` phải đỏ — build 0 Error nên loại trừ.
|
||||
|
||||
**Đề xuất sửa:** thêm `bool AllowApproverDelete` vào `ApprovalWorkflowOptionsDto` (cuối record) và truyền `curLevel.AllowApproverDelete` tại `PurchaseEvaluationFeatures.cs:1097`. Kèm 1 test khoá chiều: dựng bundle detail cho approver có `AllowApproverDelete=true` rồi assert `bundle.CurrentLevelOptions!.AllowApproverDelete == true` — test này phải ĐỎ trên mã hiện tại, nếu nó xanh ngay thì test sai.
|
||||
|
||||
---
|
||||
|
||||
# 🔴 H2 (HIGH) — Policy `PurchaseEvaluations.Delete` chặn đúng nhóm người mà tính năng nhắm tới
|
||||
|
||||
**File:** `src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:164` · `src/Backend/SolutionErp.Api/Authorization/MenuPermissionHandler.cs:40-50` · seed `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs:2534`
|
||||
|
||||
**Điều sai.** Endpoint mới gắn `[Authorize(Policy = "PurchaseEvaluations.Delete")]`. `MenuPermissionHandler` so **khớp khoá chính xác** (`p.MenuKey == req.MenuKey`, không đi cây cha-con) nên policy này đọc cờ `CanDelete` của hàng `PurchaseEvaluations` (khoá gốc). Đo thật trên DB Dev:
|
||||
|
||||
```
|
||||
sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q
|
||||
"SELECT r.Name,p.CanDelete FROM Permissions p JOIN Roles r ON r.Id=p.RoleId
|
||||
WHERE p.MenuKey='PurchaseEvaluations'"
|
||||
→ 13 hàng · CanDelete=1 CHỈ ở: Admin, DeptManager
|
||||
→ CanDelete=0: Accounting, AuthorizedSigner, CatalogManager, CostControl, Director,
|
||||
Drafter, Equipment, Finance, HrAdmin, Procurement, ProjectManager (11/13)
|
||||
```
|
||||
|
||||
Người duyệt trong quy trình V2 là **NV cụ thể**, vai thường là Procurement / CostControl / Director / ProjectManager — tất cả `CanDelete=0`. Đo tiếp trên Dev: người duy nhất đang là approver (`ApprovalWorkflowLevels.ApproverUserId`) là `binh.le@solutions.com.vn`, vai `CostControl` ⇒ **403 ngay ở tầng policy**, chưa kịp chạm ba rào trong handler.
|
||||
|
||||
Tệ hơn: FE cũng gate bằng chính quyền đó (`PeWorkflowPanel.tsx:141` `can(MenuKeys.PurchaseEvaluations,'Delete')`), nên người dùng **không thấy nút, không thấy lỗi** — đúng hình dạng gotcha #44 (403 im lặng) mà dự án đã vấp.
|
||||
|
||||
Và không có đường tự chữa: `SeedPurchaseEvaluationPermissionDefaultsAsync` **bỏ qua hàng đã tồn tại** (`DbInitializer.cs:2538`), `SeedAllRolesReviewReadPermissionsAsync` chỉ nâng `CanRead`/`CanCreate` (`:2139-2140`). Prod đã có sẵn hàng ⇒ restart API bao nhiêu lần cũng không cấp thêm `CanDelete`.
|
||||
|
||||
**Phép thử phá (làm rồi).** Tìm xem có tầng nào nới ra không: `grep -rn 'PurchaseEvaluations\.Delete' src/Backend` → chỉ đúng 1 nơi dùng thật (`Controller:164`), phần còn lại là chú thích. `MenuPermissionHandler` không có nhánh kế thừa cha-con (đã đọc trọn 54 dòng). Kết luận đứng.
|
||||
|
||||
**Đề xuất sửa (chọn 1, ghi rõ vào spec):**
|
||||
(a) Đổi policy sang một khoá mà nhóm approver thật sự có `CanDelete`, hoặc
|
||||
(b) Bỏ `[Authorize(Policy=...)]` và giữ ba rào nghiệp vụ trong handler làm nguồn quyền duy nhất — cờ F6 vốn đã là "admin chỉ đích danh từng người", chặt hơn permission theo vai, hoặc
|
||||
(c) Giữ policy nhưng thêm bước backfill nâng `CanDelete=1` trên khoá `PurchaseEvaluations` cho các vai nằm trong quy trình duyệt, và khai rõ cho owner rằng phải bật thêm ô "Xóa" ở `/system/permissions`.
|
||||
Kèm điều kiện nghiệm thu: đăng nhập bằng chính tài khoản approver (không phải admin) và thấy nút, bấm được, trả 204.
|
||||
|
||||
---
|
||||
|
||||
# ✅ ƯU TIÊN 1 — bản vá thứ-tự `Remove` trước / `Add` sau: ĐỨNG VỮNG (3 câu hỏi, 3 câu trả lời)
|
||||
|
||||
**Vật:** `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs:139` (`Remove`) và `:141` (`Add`), một `SaveChangesAsync` ở `:158`.
|
||||
|
||||
### (a) Có chắc không, hay chỉ đúng trên SQLite còn hỏng trên SQL Server?
|
||||
|
||||
**Chắc — và không phải chuyện của provider.** Cả hai nửa của cơ chế nằm trong change-tracker, chỗ chưa hề sinh một câu SQL nào:
|
||||
- Cascade lúc `Remove()` là `ChangeTracker.CascadeDeleteTiming` — thuộc tính của `ChangeTracker`, không phải tuỳ chọn của provider. Quan hệ đang xét được cấu hình `OnDelete(DeleteBehavior.Cascade)` tại `src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs:78`, nên cascade phía client có hiệu lực ở mọi provider như nhau.
|
||||
- Phần "cứu" nằm ở `AuditingInterceptor` đổi `Deleted` → `Modified` (`src/Backend/SolutionErp.Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs:56-62`). Đó là `ISaveChangesInterceptor.SavingChangesAsync`, chạy trước khi `StateManager` bắt đầu lượt lưu.
|
||||
- Vì phiếu chỉ bị **cập nhật** (xoá mềm) chứ không bị `DELETE` thật, **không có** cascade tầng cơ sở dữ liệu nào kích hoạt trên SQL Server. Câu lệnh gửi đi chỉ là một `INSERT` (changelog) và một `UPDATE` (phiếu), theo thứ tự hợp lệ về khoá ngoại.
|
||||
|
||||
**Phép thử phá (đã chạy thật, không suy luận suông):**
|
||||
```
|
||||
dotnet test tests/SolutionErp.Infrastructure.Tests --filter FullyQualifiedName~PeDeleteByApproverTests
|
||||
→ Passed! Failed: 0, Passed: 10, Total: 10 (5s)
|
||||
```
|
||||
Trong đó T26 đọc **ngược từ cơ sở dữ liệu** (`AsNoTracking`) và đòi đúng 2 dòng changelog. Với thứ tự cũ (`Add` trước `Remove`) số đó là 0 ⇒ ca này phân biệt được hai thứ tự, không phải ca xanh-mọi-lúc.
|
||||
|
||||
### (b) Còn lượt cascade nào ở `SaveChanges` quét lại không?
|
||||
|
||||
**Về mặt hệ quả: không.** T26 đọc dòng changelog **từ đĩa sau khi `SaveChanges` đã xong**, nên nếu còn lượt quét thứ hai gỡ mất bản ghi thì con số phải là 0. Nó là 2.
|
||||
|
||||
⚠️ **Nhưng lời giải thích trong chú thích đang sai một nhịp** — xem LOW-1 bên dưới. Chú thích `:135-136` viết "Thêm changelog SAU khi state đã đổi thì cascade không còn quét tới nó nữa". Tại thời điểm `Add()` chạy, trạng thái của phiếu **vẫn đang là `Deleted`** — interceptor mãi tới `SaveChanges` mới đổi. Thứ thật sự cứu bản ghi là: lượt cascade tức thời đã xảy ra xong tại `Remove()` khi chưa có đứa con nào để gỡ. Người sau đọc chú thích rồi suy theo mô hình sai có thể vô tình dời `Add()` lên trên.
|
||||
|
||||
### (c) Còn site nào khác mang cùng hình dạng "Add con → Remove cha → 1 SaveChanges"?
|
||||
|
||||
**Không. Kiểm lại độc lập, không tin lời test-specialist.** Liệt kê vét cạn 34 lời gọi `db.*.Remove(...)`/`RemoveRange(...)` trong `SolutionErp.Application` + `SolutionErp.Infrastructure` (trừ `Migrations/`), rồi lọc những chỗ có `db.*.Add(...)` đứng trước trong phạm vi 40 dòng → còn **8 ứng viên**. Xét từng cái theo quan hệ cha-con thật:
|
||||
|
||||
| Site | Vật bị `Remove` | Vật được `Add` trước đó | Có phải cha-con? |
|
||||
|---|---|---|---|
|
||||
| `ApprovalWorkflowV2AdminFeatures.cs:828` | `ApprovalWorkflowLevel` (dòng cũ) | `ApprovalWorkflowLevel` (dòng mới) | **Không** — hai anh em cùng một Bước |
|
||||
| `CatalogsFeatures.cs:93 / :173 / :253 / :331` | thực thể danh mục | chính thực thể đó, nhưng ở **handler Create khác** | Không — cửa sổ 40 dòng bắc qua ranh giới hàm, dương-giả |
|
||||
| `PurchaseEvaluationDetailFeatures.cs:262` | `PurchaseEvaluationDetail` | `PurchaseEvaluationChangelog` | Không — changelog treo vào **phiếu**, không vào dòng hạng mục |
|
||||
| `PurchaseEvaluationDetailFeatures.cs:399` | `PurchaseEvaluationQuote` | `PurchaseEvaluationChangelog` | Không — như trên |
|
||||
| `PurchaseEvaluationSupplierFeatures.cs:170` | `PurchaseEvaluationSupplier` | `PurchaseEvaluationChangelog` | Không — như trên |
|
||||
|
||||
Hai đường xoá phiếu còn lại (`PurchaseEvaluationFeatures.cs:1408` xoá nháp, `ContractFeatures.cs:577` xoá hợp đồng) đều nạp thực thể bằng `FirstOrDefaultAsync` **không `Include`**, nên lúc `Remove()` không có đứa con nào đang được theo dõi để cascade đụng tới, và cũng không ghi changelog. ⇒ **Xác nhận: `PeSoftDeleteFeatures.cs` là site duy nhất.** Lời khai của test-specialist đúng.
|
||||
|
||||
**Điểm đáng giữ:** T26 là chốt chặn thật cho lớp lỗi này. Nếu sau này ai gom phần ghi vết vào một hàm trợ giúp và gọi nó trước `Remove()`, T26 sẽ đỏ. Đừng nới assert của nó.
|
||||
|
||||
---
|
||||
|
||||
# ƯU TIÊN 2 — ba cái bẫy: **2 được thi hành đúng, 1 lời-khai-về-bẫy thì sai**
|
||||
|
||||
## 2.1 `IgnoreQueryFilters()` GỠ chứ không ĐẢO — ✅ ĐÚNG
|
||||
|
||||
`PeSoftDeleteFeatures.cs:182-183` viết liền một mạch:
|
||||
|
||||
```csharp
|
||||
from e in db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(x => x.IsDeleted)
|
||||
```
|
||||
|
||||
Mệnh đề `.Where(x => x.IsDeleted)` **có mặt**, đặt ngay trên nguồn `e` trước mọi phép nối. Không có nhánh nào bỏ qua nó.
|
||||
|
||||
**Chứng-nhân T22b có răng thật hay không — kiểm bằng số học, không bằng cảm giác.** `PeDeleteByApproverTests.cs:449-458` khẳng định trước rằng bảng có **3** dòng (`IgnoreQueryFilters().CountAsync() == 3`), trong đó **2** dòng đi qua bộ lọc thường, rồi mới đòi `result.Total == 1`. Nếu ai gỡ `.Where(x => x.IsDeleted)`, truy vấn trả cả 3 nên `3 != 1` và ca đỏ. Người gọi là admin nên khối IDOR bị bỏ qua, tức con số 1 không thể do rào IDOR tạo ra. Ba phiếu đều gắn `Project` thật (`:436-437` nói rõ lý do: phép nối `Projects` là INNER, phiếu không có dự án sẽ tự rơi và làm phép đo mất răng). Kết luận: **T22b phân biệt được hai thế giới.**
|
||||
|
||||
## 2.2 Rào IDOR — ✅ TÁI LẬP ĐÚNG NGUYÊN VĂN
|
||||
|
||||
So từng dòng `PeSoftDeleteFeatures.cs:200-217` với khuôn `PurchaseEvaluationFeatures.cs:596-617`: cùng cổng `!currentUser.Roles.Contains(AppRoles.Admin)`, cùng `GetEligiblePhases(currentUser.Roles)` (gọi qua `ListPurchaseEvaluationsQueryHandler.GetEligiblePhases`, khai báo `internal static` nên gọi được), cùng cách dựng `userApprovalWfIds` từ `ApprovalWorkflowLevels` theo `ApproverUserId`, và cùng biểu thức ba nhánh: là người soạn, hoặc phase nằm trong nhóm vai được xem, hoặc là người duyệt của quy trình đã ghim và phiếu đã rời trạng thái nháp. Không thiếu nhánh, không nới nhánh.
|
||||
|
||||
**T22c dùng mấy đối chứng — đếm thật: HAI.** Tại `:499-509`: (b) người soạn phiếu và (c) người duyệt của quy trình đã ghim, cả hai đều phải thấy đúng 1 phiếu; còn (a) người ngoài thấy 0. Cùng dữ liệu, cùng truy vấn, chỉ đổi người gọi, nên số 0 là do rào chứ không do bảng rỗng. Đây là kiểu chứng đúng.
|
||||
|
||||
## 2.3 "`IgnoreQueryFilters` chỉ ở ĐÚNG 1 handler" — ⚠️ **M1 (MED): bất-biến được thi hành ĐÚNG, nhưng lời khai trong mã thì SAI**
|
||||
|
||||
Ba điều **cần** thì đều đạt, đã kiểm từng cái:
|
||||
|
||||
- Không có hàm trợ giúp dùng chung trả `IQueryable`: truy vấn là biến cục bộ `var q = from e in ...` nằm gọn trong thân handler (`:182-193`), không thoát ra ngoài.
|
||||
- `PurchaseEvaluationConfiguration.cs:84` (`b.HasQueryFilter(x => !x.IsDeleted)`) **không bị đụng** — tệp không có mặt trong `git status`.
|
||||
- **Không rò sang `PeBudgetAccumulator`**: tệp không bị sửa, và quét toàn kho không có lần dùng `IgnoreQueryFilters` nào trong nó. T21 và T22 đo trực tiếp bốn đại lượng sau khi màn "Đã xóa" đã chạy, nên khoá luôn chiều này.
|
||||
|
||||
**Nhưng câu chữ trong mã thì sai sự thật.** Hai chỗ khẳng định đây là nơi **duy nhất** trong toàn `src/Backend` dùng `IgnoreQueryFilters`:
|
||||
|
||||
- `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs:178`
|
||||
- `src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:173`
|
||||
|
||||
Quét thật bằng `grep -rn "IgnoreQueryFilters" src/Backend --include=*.cs` cho **17 lần dùng ở 2 tệp**: 15 lần nằm trong `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` (`:957-999` và `:1038-1062`), trong đó `:1038` là **`db.PurchaseEvaluations.IgnoreQueryFilters()`** — cùng một `DbSet`, không phải bảng khác. Vậy mệnh đề "duy nhất" sai cả ở mức toàn kho lẫn ở mức thu hẹp về riêng `PurchaseEvaluations`.
|
||||
|
||||
Vì sao đáng ghi thành FLAG chứ không bỏ qua: một bất-biến ghi sai sẽ dẫn người sau đi sai đường — hoặc tưởng có thể "gom về một mối" mà thật ra đang phá `ApprovalWorkflowV2AdminFeatures`, hoặc dựa vào nó để bỏ bớt một phép kiểm.
|
||||
|
||||
**Đề xuất sửa:** đổi câu chữ thành mệnh đề đúng mà vẫn đủ răng, đại ý: "đây là nơi duy nhất dùng `IgnoreQueryFilters` cho **luồng liệt kê phiếu**; chỗ còn lại là `ApprovalWorkflowV2AdminFeatures` dùng để dò chữ ký mồ côi — cấm gộp hai đường."
|
||||
|
||||
---
|
||||
|
||||
# ƯU TIÊN 3 — các trục còn lại
|
||||
|
||||
## 3.4 Guard xóa: PER-NGƯỜI, không bypass admin — ✅ ĐÚNG
|
||||
|
||||
Ba rào nằm đúng thứ tự tại `PeSoftDeleteFeatures.cs:65` (phase `ChoDuyet`), `:104` (đúng lượt) và `:114` (cờ F6). Rào thứ hai dùng `pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)` rồi **ném `ForbiddenException`** khi không khớp; **không** có `?? pendingLevelGroup.First()`, tức không mượn khuôn admin-ký-thay ở `PurchaseEvaluationWorkflowService.cs:744-745`. Rào thứ ba đọc `matchingLevel.AllowApproverDelete` trên **đúng dòng vừa khớp người**, không phải `g.Any(l => ...)`.
|
||||
|
||||
**Thử phá:** tìm mọi đường mà một tài khoản Admin có thể luồn qua. `grep "Admin" PeSoftDeleteFeatures.cs` chỉ ra **một** lần dùng `AppRoles.Admin`, và nó nằm ở handler **danh sách** (`:200`), không nằm ở handler xóa. Nghĩa là một Admin không phải người duyệt hiện tại vẫn bị chặn ở `:104`, đúng như chủ ý. Chứng-nhân là T24b: cùng một Cấp, cùng một phiếu, bob (không được tick) bị chặn còn alice (được tick) xóa được. Với cách đọc `Any` thì bob sẽ lọt và ca đỏ ngay. Thứ tự bob-trước-alice trong ca là bắt buộc, và test đã nêu đúng lý do.
|
||||
|
||||
## 3.5 Đường xóa nháp cũ — ✅ NGUYÊN VẸN
|
||||
|
||||
`PurchaseEvaluationsController.cs:148-154`: phần thêm vào chỉ là **chú thích**; `[HttpDelete("{id:guid}")]` vẫn không mang `[Authorize(Policy = ...)]`. Lớp chỉ có `[Authorize]` trần (`:15`), nên mọi tài khoản đăng nhập vẫn gọi được như trước, và rào vẫn là phase `{DangSoanThao, TuChoi}` ở `PurchaseEvaluationFeatures.cs:1404-1406`.
|
||||
|
||||
**Thử phá:** kiểm thuộc tính ở **cấp lớp** (`:13-16`) — nếu lớp mang policy thì mọi endpoint thừa hưởng và `Drafter` mất quyền dù action để trống. Không có. T25 (`:648-650`) khoá chiều này bằng phản chứng `GetActionAuthorize(..., "Delete") == null`.
|
||||
|
||||
## 3.6 Menu seed — ✅ ĐÚNG CẢ BA Ý
|
||||
|
||||
- **Đổi nhãn "Duyệt" thành "Đang duyệt":** đúng, và đúng chỗ. Vòng upsert `:1897-1911` khi gặp khoá đã tồn tại thì chỉ chỉnh `Order` rồi `continue`, **không** đụng `Label`, nên sửa chữ trong `tree.Add` là vô ích trên cơ sở dữ liệu đang chạy. Việc đổi tên nằm ở `labelBackfill` `:1933-1934`, chạy sau và có ghi đè `item.Label`. Hai khoá liệt kê là `DuyetNcc` và `DuyetNccPhuongAn`, mà `MenuKeys.PurchaseEvaluationTypeCodes` (`MenuKeys.cs:131-132`) đúng bằng hai mã đó, nên phủ hết, không sót loại nào.
|
||||
- **Hai khoá mới có mặt ở CẢ HAI danh sách quyền:** có — `DbInitializer.cs:2110-2111` (trong `SeedAllRolesReviewReadPermissionsAsync`) và `:2514-2515` (trong `SeedPurchaseEvaluationPermissionDefaultsAsync`). Không lệch bên nào.
|
||||
- **`MenuKeys.All` không bị thêm nhầm:** đếm tay từng phần tử trong khối `MenuKeys.cs:157-174` ra **54**, và không phần tử nào chứa `Approved` hay `Deleted`. Phần thêm của diff chỉ gồm 2 hàm sinh khoá và 7 dòng chú thích. Vậy số policy dẫn xuất giữ nguyên **54 × 4 = 216**, đúng như yêu cầu giữ 54/216.
|
||||
- Phụ: `peOrder` khởi tạo bằng 1 (`:1866`) và tăng dần cho cả hai loại; thêm 2 lá làm dịch số thứ tự của loại thứ hai, nhưng vòng upsert có nhánh cập nhật `Order` cho khoá cũ nên tự chỉnh lại, và `Order` chỉ có nghĩa trong phạm vi cùng một cha, nên không va chạm.
|
||||
|
||||
## 3.7 FE gate so với rào BE — ba trên bốn khớp, cái thứ tư chính là H1
|
||||
|
||||
| Rào BE | Đối ứng FE | Khớp? |
|
||||
|---|---|---|
|
||||
| phase `ChoDuyet` (`:65`) | `evaluation.phase === PurchaseEvaluationPhase.ChoDuyet` (`PeWorkflowPanel.tsx:134`) | ✅ |
|
||||
| đúng lượt (`:104`) | `actorIsCurrentApprover` dựng từ `currentApproval.approvers` (`:128`) — cố ý **không** dùng `actorInV2Level` vì biến đó có `isAdmin ||` | ✅ chọn đúng, lý do ghi rõ trong mã |
|
||||
| cờ F6 per-người (`:114`) | `levelOptions?.allowApproverDelete === true` (`:136`) | ❌ **H1** — trường này không bao giờ có trong payload |
|
||||
| policy `PurchaseEvaluations.Delete` (`Controller:164`) | `can(MenuKeys.PurchaseEvaluations, 'Delete')` (`:141`) | ✅ về hình thức, nhưng xem **H2** về mặt dữ liệu |
|
||||
|
||||
**Nút có lọt ra ngoài màn duyệt không? Không, ở hai chỗ dùng chính.** Đếm hết chỗ gắn thẻ `PeWorkflowPanel`: đúng **3** mỗi ứng dụng (`PurchaseEvaluationsListPage.tsx:662`, `:749`, `:788`). Hai chỗ đầu truyền `readOnly={!pendingMe}`, nên ở "Danh sách" và ở view "Đã xóa" (`pendingMe` bằng false) thì `readOnly` bằng true và mệnh đề `!readOnly` ở `:133` cắt nút ngay. Chỗ thứ ba là trang chi tiết toàn màn — xem **L4**.
|
||||
|
||||
**md5 gương hai ứng dụng — đo lại độc lập, không dùng số của lead:**
|
||||
|
||||
| Tệp | admin | user | Kết luận |
|
||||
|---|---|---|---|
|
||||
| `components/pe/PeWorkflowPanel.tsx` | `eb55781a...` | `eb55781a...` | **KHỚP** |
|
||||
| `pages/pe/PurchaseEvaluationsListPage.tsx` | `d96e56bb...` | `d96e56bb...` | **KHỚP** |
|
||||
| `components/Layout.tsx` | `50a9d9b7...` | `3e9ac7c3...` | LỆCH **có chủ ý**, lý do ghi tại `Layout.tsx:108-112`: fe-user có `WfView` trong biểu thức, fe-admin cố ý không vì không có trang tương ứng. Kiểm thật: fe-user `:125` chứa `WfView`, fe-admin `:107` không. Cả hai đều đã thêm `Approved` và `Deleted`. |
|
||||
| `types/purchaseEvaluation.ts` | `cb7d1635...` | `ff9c028e...` | LỆCH, nhưng **khối S155 giống hệt từng chữ** ở cả hai (`:428-431`); chênh lệch đến từ chú thích cũ vốn đã khác nhau từ trước diff này. |
|
||||
|
||||
## 3.8 Test mới có ca nào xanh giả không — ✅ KHÔNG, nhưng có MỘT ca gây yên tâm sai chỗ
|
||||
|
||||
Chạy thật: `dotnet test --filter FullyQualifiedName~PeDeleteByApproverTests` cho **10/10 PASS**. Soi từng ca theo tiêu chí "khẳng định này có đúng ở cả hai thế giới không":
|
||||
|
||||
- **T21** — hai đại lượng giảm, hai đại lượng bất động, và cả hai đại lượng bất động đều **khác 0** (90 triệu và 60 triệu). Nếu chúng bằng 0 thì "không đổi" là vô nghĩa; ca đã chủ động seed thêm phiếu `DaDuyet` và `TraLai` để tránh đúng cái bẫy đó. Có răng.
|
||||
- **T22, T22b, T22c** — đã phân tích ở mục 2.1 và 2.2. Có răng.
|
||||
- **T23, T24, T24b** — ba ca phủ định, và chúng **không** đứng một mình: **T25b** là ca chiều thuận, nên một handler ném vô điều kiện sẽ làm T25b đỏ. Đây là cách bố trí đúng.
|
||||
- **T26** — đòi đúng 2 dòng changelog đọc ngược từ cơ sở dữ liệu, kèm đối chứng ngay trong cùng ca (`ContextNote` phải là null khi lý do toàn khoảng trắng), nên "có ContextNote" không thể là hằng số. Có răng.
|
||||
- ⚠️ **T25 — không xanh giả, nhưng đo nhầm tầng.** Nó chứng minh tên policy **có được đăng ký** (`MenuKeys.All` chứa `PurchaseEvaluations`, `Actions` chứa `Delete`). Nó **không** chứng minh có vai nào thật sự thoả được policy đó. Chính khoảng trống này khiến **H2** đi qua 561 ca xanh mà không ai thấy. Xem **M3**.
|
||||
|
||||
## 3.9 Regression — ✅ không thấy hồi quy
|
||||
|
||||
- **Đường dẫn không va nhau:** `[HttpGet("deleted")]` (`:174`) là đoạn chữ tĩnh, còn `[HttpGet("{id:guid}")]` (`:37`) có ràng buộc `guid` nên chuỗi `deleted` không lọt vào. Liệt kê toàn bộ `HttpGet` trong controller: không có mẫu `{id}` nào thiếu ràng buộc để tranh chấp. Tương tự, `DELETE {id:guid}/by-approver` khác hẳn `DELETE {id:guid}`.
|
||||
- **Khách cũ không bị đụng:** hai endpoint đều mới hoàn toàn; không chữ ký nào bị đổi, không tham số nào bị bỏ.
|
||||
- **Lũy kế sau khi xóa vẫn đúng:** T21 đo cả bốn đại lượng, còn T22 đo lại **ngay sau khi** truy vấn bỏ-lọc chạy, tức chứng luôn rằng màn "Đã xóa" không kéo phiếu đã chết quay lại phép cộng.
|
||||
|
||||
---
|
||||
|
||||
# Các FLAG còn lại
|
||||
|
||||
## M2 (MED) — chú thích T26 vẫn khai "ca này ĐANG ĐỎ, bug production", trong khi ca đã xanh
|
||||
|
||||
**Tệp:** `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs:712-724`
|
||||
|
||||
Khối chú thích viết nguyên văn "CA NÀY ĐANG ĐỎ — BUG PRODUCTION, CỐ Ý GIỮ ĐỎ", rồi mô tả cơ chế mất vết và kết luận "Vết KHÔNG BAO GIỜ được ghi". Đo thật thì ca **xanh** (10/10 PASS), vì bản vá đảo thứ tự của lead đã land. Các mốc dòng mà chú thích trích (`:126` Add, `:142` Remove, `:143` SaveChanges) cũng không còn khớp tệp hiện tại (`:139` Remove, `:141` Add, `:158` SaveChanges).
|
||||
|
||||
Vì sao là MED chứ không phải LOW: đây là văn bản **khẳng định có một lỗi production đang sống**. Người đọc kế tiếp sẽ hoặc đi vá một thứ đã vá rồi, hoặc mất lòng tin vào toàn bộ khối chú thích trong tệp. Với một tệp mà giá trị chính nằm ở phần giải thích "vì sao ca này có răng", mất lòng tin vào chú thích là mất luôn công dụng.
|
||||
|
||||
**Đề xuất sửa:** viết lại khối thành thì quá khứ, nêu rõ ca này từng đỏ vì thứ tự cũ, nay xanh nhờ bản vá tại `PeSoftDeleteFeatures.cs:139-141`, và giữ nguyên câu cảnh báo rằng dời lời gọi `Add` lên trước `Remove` sẽ làm ca đỏ trở lại. Cập nhật ba mốc dòng.
|
||||
|
||||
## M3 (MED) — thiếu hẳn lớp test buộc BE và FE nói cùng một hợp đồng
|
||||
|
||||
**Tệp liên quan:** `PurchaseEvaluationDtos.cs:123-131`, `PurchaseEvaluationFeatures.cs:1088-1097`, `fe-*/src/types/purchaseEvaluation.ts:428-431`
|
||||
|
||||
561 ca xanh mà **H1** vẫn lọt, vì không ca nào đi qua `ApprovalWorkflowOptionsDto`. Tương tự, T25 chứng minh policy tồn tại nhưng không ca nào chứng minh có vai thoả được nó, nên **H2** cũng vô hình. Hai lỗ hổng nghiêm trọng nhất của đợt này đều nằm đúng chỗ bộ test không nhìn tới.
|
||||
|
||||
**Đề xuất sửa (điều kiện nghiệm thu, không phải mã):**
|
||||
1. Một ca dựng bundle chi tiết cho người duyệt có `AllowApproverDelete = true` rồi đòi `CurrentLevelOptions!.AllowApproverDelete == true`. Ca này phải **đỏ trên mã hiện tại**; nếu nó xanh ngay thì ca sai.
|
||||
2. Một ca đối chiếu số thuộc tính của `ApprovalWorkflowOptionsDto` với số khoá trong khối `ApprovalWorkflowOptions` phía FE, hoặc rẻ hơn: một ca duyệt qua từng thuộc tính `Allow*` của `ApprovalWorkflowLevel` và đòi mỗi cái đều có mặt trong DTO. Cách này bắt được cả những lần thêm cờ về sau.
|
||||
3. Một ca theo trục quyền: với vai `CostControl` (hoặc bất cứ vai nào đang nằm trong quy trình duyệt), đòi tồn tại ít nhất một hàng `Permissions` có `CanDelete = true` trên khoá `PurchaseEvaluations`. Ca này bắt đúng **H2** và sẽ đỏ ngay hôm nay.
|
||||
|
||||
## M4 (MED) — hai chỗ trong mã khai sai vị trí dòng của chính nó, làm người sau tra nhầm
|
||||
|
||||
**Tệp:** `PeSoftDeleteFeatures.cs` — chú thích trong thân handler dẫn `:167-168` cho mệnh đề `.Where(x => x.IsDeleted)` (thật ra ở `:182-183`) và `:185-202` cho khối IDOR (thật ra ở `:195-217`); tệp test `PeDeleteByApproverTests.cs:27-28` dẫn handler `:50-145` và `:153-248` trong khi thực tế là `:50-160` và `:168-263`.
|
||||
|
||||
Đây là lệch chú thích, không phải lệch hành vi, nên không chặn deploy. Nhưng nó cùng một họ với **M1** và **M2**: tệp này chứa rất nhiều mốc dòng trỏ chéo, và chúng đang trôi. Khi số lượng mốc sai đủ nhiều thì người đọc sẽ ngừng tra, và giá trị của toàn bộ hệ chú thích rơi về 0.
|
||||
|
||||
**Đề xuất sửa:** rà lại một lượt các mốc `:NNN` trỏ vào chính hai tệp mới, hoặc đổi sang trỏ theo **tên ký hiệu** (tên handler, tên biến) thay vì số dòng — tên không trôi khi chèn thêm dòng.
|
||||
|
||||
## L1 (LOW) — chú thích giải thích bản vá bằng một mô hình sai (lead đã nhận, ghi lại cho đủ hồ sơ)
|
||||
|
||||
**Tệp:** `PeSoftDeleteFeatures.cs:135-136`. Câu "Thêm changelog SAU khi state đã đổi thì cascade không còn quét tới nó nữa" sai một nhịp: lúc `Add()` chạy, phiếu **vẫn đang ở trạng thái `Deleted`**, vì `AuditingInterceptor` mãi tới `SaveChanges` mới đổi. Thứ thật sự cứu bản ghi là lượt cascade tức thời đã xảy ra xong ngay tại `Remove()`, khi chưa có đứa con nào để gỡ.
|
||||
|
||||
## L2 (LOW) — `IgnoreQueryFilters()` có phạm vi cả truy vấn, nên nó cũng gỡ lọc của các bảng được nối vào
|
||||
|
||||
**Tệp:** `PeSoftDeleteFeatures.cs:182-193` và `:250-254`
|
||||
|
||||
Trong EF Core, `IgnoreQueryFilters()` là toán tử **cấp truy vấn**, không phải cấp `DbSet`. Vì vậy các phép nối tới `Projects`, `Suppliers`, `Users`, `Departments`, `WorkItems`, cùng truy vấn con lấy tên đơn vị trúng thầu ở `:250-254`, đều mất bộ lọc xóa mềm của chúng. Hệ quả: màn "Đã xóa" có thể hiển thị tên dự án hoặc tên nhà cung cấp đã bị xóa mềm, trong khi danh sách phiếu sống thì phép nối INNER tới `Projects` sẽ làm cả dòng biến mất.
|
||||
|
||||
Không phải lỗ rò dữ liệu phiếu, và với một màn đối chiếu thì hành vi này còn dễ chịu hơn. Nhưng nó là một khác biệt hành vi chưa ai ghi lại, nên ghi vào đây.
|
||||
|
||||
## L3 (LOW) — hai chỗ dùng chung một khiếm khuyết sẵn có: không phân trang, không giới hạn kích thước trang
|
||||
|
||||
**Tệp:** `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:70-76` (và bản gương fe-user)
|
||||
|
||||
Nhánh "Đã xóa" gọi với `page: 1, pageSize: 50` cố định, không có giao diện phân trang, nên nếu quá 50 phiếu đã xóa thì phần dư biến mất lặng lẽ trên một màn vốn dùng để đối chiếu. Phía máy chủ, `ListDeletedPurchaseEvaluationsQuery` cũng không có lớp `Validator` nào chặn `pageSize` lớn.
|
||||
|
||||
**Ghi cho công bằng:** nhánh danh sách sống ngay bên dưới (`:90-97`) cũng đúng `pageSize: 50` cố định, và `ListPurchaseEvaluationsQuery` cũng không có validator. Vậy đây là **kế thừa khuyết tật sẵn có**, không phải hồi quy do đợt 2 gây ra. Xếp LOW vì lý do đó.
|
||||
|
||||
## L4 (LOW) — nút xóa cũng xuất hiện ở trang chi tiết toàn màn, tức rộng hơn "màn duyệt" như phát biểu
|
||||
|
||||
**Tệp:** `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:788` (và bản gương fe-user)
|
||||
|
||||
`PurchaseEvaluationDetailPage` gắn `PeWorkflowPanel` mà **không** truyền `readOnly`, nên giá trị mặc định `false` có hiệu lực và nút xóa có thể hiện ở đó. Diff có nhận biết điều này và đã truyền `onDeleted` để điều hướng sau khi xóa, nên không có ngõ cụt.
|
||||
|
||||
Đây **không** phải lỗ hổng: ba rào phía máy chủ vẫn nguyên, và trang đó cũng đang hiện các nút Duyệt hay Trả lại theo cùng một logic, tức nó vốn là một mặt duyệt. Ghi lại chỉ để phát biểu phạm vi cho đúng: nút sống ở **hai** mặt, không phải một.
|
||||
|
||||
## L5 (LOW) — mốc "xóa ≈" là mốc cập nhật cuối, không phải mốc xóa
|
||||
|
||||
**Tệp:** `fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:557-574` (và bản gương fe-user)
|
||||
|
||||
DTO dùng chung với danh sách sống nên không có `deletedAt` và `deletedBy`; màn hình lấy `updatedAt` làm xấp xỉ, có ký hiệu "≈" và chú giải khi rê chuột. Cách xử lý trung thực, chấp nhận được. Rủi ro còn lại: nếu về sau có đường ghi nào chạm vào phiếu đã xóa (ví dụ một thao tác quản trị), mốc hiển thị sẽ nhảy mà không ai biết. Muốn chắc thì bổ sung `deletedAt` và `deletedBy` vào DTO riêng của màn này.
|
||||
Reference in New Issue
Block a user