wal: flush 20260727T0903
This commit is contained in:
@ -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ỡ
|
||||
@ -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` có `[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)*
|
||||
Reference in New Issue
Block a user