wal: flush 20260727T0903

This commit is contained in:
pqhuy1987
2026-07-27 09:03:41 +07:00
parent a3a6d66cff
commit 0db0c6a3ee
3 changed files with 135 additions and 2 deletions

View File

@ -0,0 +1,44 @@
# run — PE: nút XÓA phiếu ở màn DUYỆT (approver-side)
- **run-id:** `2026-07-27-S155-pe-delete-approver`
- **phiên:** S155 (phiên-LOGIC L7, window 2)
- **pipeline anh lệnh:** 2 Invest → file-chi-tiết + checklist → review spec → hmw Opus 5 MAX
- **mode:** RUN-TRACE (≥3 task) — sub ghi full-detail vào CHỈ `sub-<role>-<i>.md` của mình
## Nguồn yêu cầu (ảnh chat anh gửi — UAT thật, không phải giả định)
Phiếu mẫu: `PE/2026/A/046` · Duyệt NCC · dự án **FLOCK03** · hạng mục **MAT-16 "16 Mat Khác"** · gói thầu **"16 Mat - Băng cản nước"** · trạng thái **Đã gửi duyệt** · có cờ **GẤP (PRO)**.
Workflow đang chạy: Bước 1 (Phòng Cung ứng) — Cấp 2, NV duyệt **Bùi Lê Thủy Trà**, "Đến lượt bạn duyệt". Bước 2 có Cấp 2 "Duyệt thay CEO". Bước 3 Ban Giám đốc, kết thúc tại Cấp 2. `Lịch sử duyệt (1)`.
Khối **HÀNH ĐỘNG** hiện chỉ có: `✓ Duyệt` · `← Trả lại`**thiếu Xóa**.
Banner đang hiện: *"Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit)"*.
**Lời user verbatim (Tra Sol):**
- "@Kenny Chỗ duyệt - tạo giúp em nút xóa với"
- "Vì mấy bạn bắt sai cái gói thầu" → "nên phải cho quyền xóa cái phiếu đó luôn"
- "chứ lỡ bấm sai cái gói thầu / quay lại không được / **phải xóa thì nó mới ko có lũy kế lên**"
**Lời anh (owner) verbatim:** "Hic hôm trước có cái chỗ cho huy / mọi người nói là ko cần cái đó / giờ lại cần"
⇒ 🔴 giả-thuyết cần KIỂM: đường xóa/thu-hồi **đã từng tồn tại rồi bị gỡ**. Nếu đúng → khôi phục rẻ hơn dựng mới, và phải tìm ra **lý do gỡ** để không tái phạm.
## Vì sao đây là lỗi SỐ LIỆU, không phải UX
CLAUDE.md S134: `ComputePendingAsync` complementary — **phiếu ChoDuyet có winner được tính vào lũy kế TẠM TÍNH**. Phiếu bấm sai gói thầu đang treo ở bước duyệt ⇒ **đang ăn ngân sách hạng mục sai**. "Trả lại" đẩy về `TraLai=98` chứ không triệt tiêu ⇒ số tạm tính vẫn lệch. Đó chính là điều Tra Sol mô tả bằng chữ "lũy kế lên".
## Stages
- [ ] **S1 — 2 Invest song song** (BE-slice + FE/authz-slice) → `sub-invest-be-1.md` · `sub-invest-fe-2.md`
- [ ] **S2 — file chi tiết + checklist** (lead tổng hợp) → `spec-pe-delete-approver.md`
- [ ] **S3 — review spec** (reviewer, adversarial)
- [ ] **S4 — hmw Opus 5 MAX** thực thi
## Ràng buộc mang theo mọi stage
- Governance L7 **vẫn treo** 22 FLAG chờ (42)(43)(44) — việc này **song song**, KHÔNG đóng thay.
- Soft delete là quy ước sẵn có (`AuditableEntity`: `IsDeleted`/`DeletedAt`/`DeletedBy`) — spec phải nói rõ dùng cái này hay thu-hồi-về-Nháp.
- Quyền = **2 tầng độc lập** (bài học S118 / gotcha #82): display-layer menu `CanRead` ⟂ API-authz `[Authorize(Policy)]`. Ẩn nút ≠ đóng API.
- Xóa phải có **vết** (changelog/audit) — phiếu đã có `Lịch sử duyệt (1)`.
## taskList snapshot
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ỡ

View File

@ -0,0 +1,86 @@
# sub-invest-be-1 — ĐIỀU TRA BACKEND: nút XÓA phiếu PE ở màn DUYỆT
- **run-id:** `2026-07-27-S155-pe-delete-approver`
- **vai:** investigator-codebase (INTERNAL, read-only)
- **quy ước:** mọi dòng `[CODE]` = đọc thẳng từ file trên đĩa · `[SUY LUẬN]` = tôi suy ra, chưa có dòng mã nào nói · `[CHƯA XÁC MINH]` = không chứng được
- **trạng thái file:** ĐANG GHI DẦN (ghi ngay khi tìm ra từng mục — chống #53)
---
## Q1 — Đường XÓA hiện có tới đâu?
### F1. CÓ endpoint DELETE PE — nhưng **không có policy authz** `[CODE]`
`src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:146-151`
```csharp
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeletePurchaseEvaluationCommand(id), ct);
return NoContent();
}
```
- Route đầy đủ: `DELETE /api/purchase-evaluations/{id}` (`:14` `[Route("api/purchase-evaluations")]`).
- Authz = **CHỈ class-level `[Authorize]` TRẦN** (`:15`) — **KHÔNG có `[Authorize(Policy = "Pe_*.Delete")]`** ở action.
⇒ Bất kỳ user **đã đăng nhập nào** cũng gọi được endpoint này (chỉ bị chặn bởi guard phase ở handler, xem F3).
⇒ Trùng khớp gotcha #82 / bài học S118: menu-ẩn ≠ API-đóng. Đây là **2 tầng độc lập** và tầng API hiện **mở**.
- Tôi đã grep toàn controller: **không một action nào** trong `PurchaseEvaluationsController.cs``[Authorize(Policy=...)]` (0 hit) — cả file dựa hoàn toàn vào guard trong handler. Đây là **thiết kế cố ý đã có từ trước** (comment `:69-71`, `:88-90`, `:120` nói rõ "Class [Authorize] any-auth; handler fine-grained Forbidden").
### F2. CÓ Command/Handler `DeletePurchaseEvaluationCommand` `[CODE]`
`src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs:1392-1411`
```csharp
// ========== DELETE ==========
public record DeletePurchaseEvaluationCommand(Guid Id) : IRequest;
public class DeletePurchaseEvaluationCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationCommand>
{
public async Task Handle(DeletePurchaseEvaluationCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao
&& entity.Phase != PurchaseEvaluationPhase.TuChoi)
throw new ConflictException("Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối.");
db.PurchaseEvaluations.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
```
Ghi chú quan trọng về handler này:
- Handler **KHÔNG inject `ICurrentUser`****không check ai là người xóa**: không check Drafter-owner, không check role, không check "đến lượt bạn". Guard duy nhất là **phase**.
- Handler **KHÔNG ghi changelog** (đối chiếu Q4 — các thao tác khác đều ghi).
- Handler **KHÔNG đụng bảng con** (xem Q5).
### F3. XÓA hiện tại là **SOFT delete** — nhưng gián tiếp qua interceptor `[CODE]`
`db.PurchaseEvaluations.Remove(entity)` **trông như** hard-delete, nhưng bị `AuditingInterceptor` chặn lại và đổi thành soft:
`src/Backend/SolutionErp.Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs:54-63`
```csharp
foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = now;
entry.Entity.DeletedBy = userId;
}
}
```
⇒ Vì `PurchaseEvaluation : AuditableEntity`, mọi `.Remove()` trên PE = **UPDATE IsDeleted=1 + DeletedAt + DeletedBy** (DeletedBy lấy từ `ICurrentUser.UserId` — nên **vết "ai xóa" CÓ**, dù handler không tự ghi).
🔴 Đây là **indirect-assignment** giống hệt lớp bẫy gotcha #81-EXT: đọc handler thì tưởng hard-delete, sự thật là soft.
---
*(file đang được ghi tiếp — các mục Q2..Q7 bên dưới)*