# 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.

---

## 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

### F13. PE có **2 bảng lịch sử TÁCH VAI** (không phải 1) `[CODE]`

| Bảng | Entity | Vai |
|---|---|---|
| `PurchaseEvaluationApprovals` | `PurchaseEvaluationApproval : BaseEntity` (`PurchaseEvaluationApproval.cs:7`) | **per-approver record** — ai duyệt, FromPhase/ToPhase/Decision/Comment. Config `PurchaseEvaluationConfiguration.cs:156-170` |
| `PurchaseEvaluationChangelogs` | `PurchaseEvaluationChangelog : BaseEntity` (`PurchaseEvaluationChangelog.cs:9`) | **nhật ký thao tác** — EntityType × Action × PhaseAtChange × Summary × ContextNote × FieldChangesJson. Config `:172-190` |

(Phân vai này chính là bài học gotcha #79 — Mig 60 backfill soi nhầm `Approvals` thay vì `Changelogs`.)

**Enum sẵn có:**
- `ChangelogAction` (`Domain/Contracts/ContractChangelog.cs:38-44`): `Insert=1, Update=2, **Delete=3**, Transition=4` → **đã có sẵn `Delete`, không cần migration.**
- `PurchaseEvaluationEntityType` (`PurchaseEvaluationChangelog.cs:25-33`): `Header=1, Supplier=2, Detail=3, Quote=4, Workflow=5, Attachment=6`.

### F14. Duyệt / Trả lại / Từ chối ghi vết ở đâu `[CODE]`

Tất cả đi qua **1 helper duy nhất**: `PurchaseEvaluationWorkflowService.LogTransitionAsync` (`:1158-1185`), được gọi **13 lần** (`:160, :269, :315, :644, :828, :870, :909, :925, :942, :950, :1146, :1153` + `:1027` chú thích). Nội dung ghi (`:1175-1185`):

```csharp
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
    PurchaseEvaluationId = evaluation.Id,
    EntityType = PurchaseEvaluationEntityType.Workflow,
    Action = ChangelogAction.Transition,
    PhaseAtChange = toPhase,
    UserId = actorUserId,
    UserName = actorName ?? "Hệ thống",
    Summary = $"Chuyển phase {fromPhase} → {toPhase}",
    ContextNote = comment,          // ← lý do do người duyệt gõ
});
```
Kèm notify Drafter ở `:1188-1197` (DaDuyet / TuChoi / TraLai).

### F15. Handler xóa **KHÔNG ghi changelog** — vết duy nhất là 3 cột của interceptor, và nó THIẾU 2 thứ `[CODE] + [SUY LUẬN]`

`[CODE]` `PurchaseEvaluationFeatures.cs:1399-1410`: 0 dòng `Changelogs.Add`, 0 dòng `Approvals.Add`. Vết duy nhất = `IsDeleted / DeletedAt / DeletedBy` do `AuditingInterceptor.cs:56-62` tự set.

`[SUY LUẬN]` Vết đó **có ai** + **lúc nào**, nhưng **thiếu**:
1. **Lý do xóa** — không có chỗ chứa (không cột nào ngoài 3 cột trên). Đây là điều bắt buộc phải có cho ca "bắt sai gói thầu" (người khác cần biết vì sao phiếu biến mất).
2. **Không ai đọc được** — 3 cột đó chỉ soi bằng SQL. UI đọc lịch sử qua `ListPurchaseEvaluationChangelogsQuery` (`PurchaseEvaluationFeatures.cs:1415-1434`) mà query đó lọc `WHERE PurchaseEvaluationId == id` → **phiếu đã xóa không còn mở được** ⇒ vết chôn theo phiếu. Người cùng gói thầu (đang nhìn số lũy kế đột nhiên tụt) **không có cách nào biết** phiếu nào vừa bị rút ra.

### F16. Chèn vết ở đâu cho khớp pattern đang có `[CODE]` (tiền lệ trong repo)

`ChangelogAction.Delete` **đã được dùng ở 5 site PE/HĐ** — đây là khuôn có sẵn, chỉ việc mirror:
- `PurchaseEvaluationSupplierFeatures.cs:164` (xóa NCC khỏi phiếu)
- `PurchaseEvaluationDetailFeatures.cs:255` (xóa hạng mục) và `:392` (xóa báo giá)
- `PurchaseEvaluationAttachmentFeatures.cs:173` (xóa đính kèm)
- `PeDepartmentOpinionFeatures.cs:143` (xóa ý kiến phòng ban)
- (HĐ: `ContractAttachmentFeatures.cs:144`, `ContractDetailsFeatures.cs:467` qua `ChangelogService`)

`[SUY LUẬN]` ⇒ khuôn tự nhiên: trong `DeletePurchaseEvaluationCommandHandler`, **TRƯỚC** `Remove()`, `Add` 1 row `{EntityType=Header (hoặc Workflow), Action=Delete, PhaseAtChange=<phase lúc xóa>, UserId=currentUser, Summary="Xóa phiếu ...", ContextNote=<lý do bắt buộc nhập>}`. Handler hiện **chưa inject `ICurrentUser`** nên phải thêm (mọi handler PE khác đều đã inject — vd `PeWorkItemBudgetFeatures.cs:83`).
⚠️ Nhưng row changelog đó **cũng chôn theo phiếu** (F15 mục 2). Nếu muốn người khác thấy, phải có nơi hiển thị NGOÀI phiếu — `[CHƯA XÁC MINH]` repo hiện **không có** bảng audit toàn cục (`AuditLogs` vẫn nằm ở mục "future" trong skill `contract-workflow`, tôi đã grep `class AuditLog` → 0 hit trong `src/Backend/SolutionErp.Domain`).

---

## Q5 — Quan hệ dữ liệu (câu quan trọng thứ 2)

### F17. Bản đồ FK — **6 collection Cascade + 1 Cascade riêng, 0 Restrict phía con** `[CODE]`

`PurchaseEvaluationConfiguration.cs:75-80`:
```csharp
b.HasMany(x => x.Suppliers)...OnDelete(DeleteBehavior.Cascade);           // :75
b.HasMany(x => x.Details)...OnDelete(DeleteBehavior.Cascade);             // :76
b.HasMany(x => x.Approvals)...OnDelete(DeleteBehavior.Cascade);           // :77
b.HasMany(x => x.Changelogs)...OnDelete(DeleteBehavior.Cascade);          // :78
b.HasMany(x => x.Attachments)...OnDelete(DeleteBehavior.Cascade);         // :79
b.HasMany(x => x.DepartmentOpinions)...OnDelete(DeleteBehavior.Cascade);  // :80
```
+ `PurchaseEvaluationLevelOpinionConfiguration.cs:20-28` — **Cascade** về PE, **Restrict** về `ApprovalWorkflowLevel`.
+ `Quotes` không FK thẳng lên PE mà qua `Detail` (`PurchaseEvaluationDetailConfiguration.cs:130` Cascade) — và `Quote → Supplier` là **Restrict** (`:149-152`).
+ FK ra ngoài: `PE → ApprovalWorkflow` **Restrict** (`:70-73`).

### F18. 🔴 Soft-delete cha ⇒ **cascade KHÔNG BAO GIỜ chạy** ⇒ con ở lại nguyên vẹn `[CODE] + [SUY LUẬN]`

`[CODE]` `AuditingInterceptor.cs:58` đổi `entry.State = EntityState.Deleted → Modified`. EF chỉ phát `DELETE` (và cascade) cho state `Deleted`. Với `Modified` nó phát `UPDATE`.
`[SUY LUẬN]` ⇒ Sau khi xóa 1 PE: **toàn bộ** `PurchaseEvaluationSuppliers`, `Details`, `Quotes`, `Approvals`, `Changelogs`, `Attachments`, `DepartmentOpinions`, `LevelOpinions` **vẫn nằm trong DB, `IsDeleted` không tồn tại/không đổi**, trỏ về 1 phiếu cha vô hình.

**Xác nhận không có cột `IsDeleted` ở con `[CODE]`:** 7/8 bảng con là `BaseEntity` (không có IsDeleted): `PurchaseEvaluationSupplier.cs:9`, `Detail.cs:7`, `Quote.cs:8`, `Approval.cs:7`, `Changelog.cs:9`, `Attachment.cs:15`. Chỉ 2 bảng là `AuditableEntity`: `PurchaseEvaluationDepartmentOpinion.cs:24` + `PurchaseEvaluationLevelOpinion.cs:24` — nhưng cả 2 **không có `HasQueryFilter`** (grep `HasQueryFilter` chỉ ra 1 hit duy nhất trong file config PE là dòng `:84` của bảng cha).

**Có lộ số ở query nào khác không? — KHÔNG, nhưng nhờ MAY hơn nhờ thiết kế `[CODE]`:**
Tôi grep toàn bộ read-site đứng thẳng trên bảng con (`db.PurchaseEvaluationSuppliers|Quotes|LevelOpinions`, 38 hit): **mọi hit đều bị chặn bởi 1 trong 2 điều kiện** — (i) join/`from p in peers` bắt nguồn từ `db.PurchaseEvaluations` (nên dính filter cha) — `PeBudgetAccumulator.cs:66/68/121/123`, `PurchaseEvaluationFeatures.cs:656/783`, `CreateContractFromEvaluationFeatures.cs:185`; hoặc (ii) lọc theo `PurchaseEvaluationId == <id phiếu đang mở>` / `supplierRowIds` lấy từ phiếu đó. **Không có 1 read-site nào quét ngang bảng con toàn hệ thống.**
`[SUY LUẬN]` ⇒ rác **tồn tại thật** nhưng **hiện không lộ số**. Rủi ro là tương lai: ai viết 1 report kiểu `SUM(Quotes) GROUP BY project` mà không join PE sẽ ăn phải rác này. Đáng ghi thành ràng buộc trong spec, không phải blocker.

### F19. `PeWorkItemBudgets` **KHÔNG phải bảng con của PE** — xóa phiếu không đụng tới `[CODE]`

`PeWorkItemBudget` khoá theo cặp `(ProjectId, WorkItemId)` (`PeWorkItemBudgetConfiguration.cs:27` UNIQUE filtered `[IsDeleted]=0`), **dùng chung cho mọi phiếu cùng gói**. Không có FK PE→PeWorkItemBudget (0 hit `HasMany`/`HasOne` giữa 2 entity). Handler chỉ resolve nó **qua** PE (`PeWorkItemBudgetFeatures.cs:86/157`).
⇒ Xóa phiếu **không** được xóa/giảm record ngân sách gói thầu. Đúng ý đồ (bảng ngân sách là "tài liệu sống" — comment `PeWorkItemBudgetFeatures.cs:19-21`).

### F20. Đối chiếu: xóa NCC-trong-phiếu là **HARD delete** `[CODE]`

`PurchaseEvaluationSupplierFeatures.cs:170` `db.PurchaseEvaluationSuppliers.Remove(row)` — vì `PurchaseEvaluationSupplier : BaseEntity` (không `AuditableEntity`) nên interceptor **không** bắt → xóa cứng thật. Có guard `:154` chặn khi còn quote.
⇒ Trong cùng module, `Remove()` cho ra 2 hành vi khác nhau tuỳ base class. Ai đọc spec phải nói rõ đang nói bảng nào (bẫy #81-EXT lớp indirect-assignment).

---

## Q6 — 🔴 KHẢO CỔ GIT: "chỗ cho huy"

### F21. **TÌM THẤY.** "chỗ cho huy" = nút **"Từ chối"** ở màn duyệt, bị gỡ 2026-06-12 `[CODE]`

**Commit:** `6db195dd4270a8c463305d5051c901e05748c403` — **Fri Jun 12 2026 14:30:38 +0700** — phiên **S60**.
**Subject:** `[CLAUDE] PurchaseEvaluation: go han hanh dong "Tu choi" - chi con Duyet hoac Tra lai (UAT anh Kiet S60 14:14)`

**Lý do gỡ (verbatim từ mã, `PurchaseEvaluationWorkflowService.cs:94-100`):**
```
// ===== UAT S60 (anh Kiệt 14:14) — GỠ hành động "Từ chối" =====
// "Bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi." Mọi policy đã bỏ
// TuChoi khỏi NextPhases (FE hết nút); guard này chặn caller direct
// (API forge / client cũ cache) — đứng TRƯỚC mọi branch nên chặn CẢ
// Admin manual override (spec = bỏ hẳn hành động, không escape hatch).
// Phase TuChoi + phiếu TuChoi cũ GIỮ display/filter. Flip lại nếu cần:
// xóa guard này + restore transitions trong PurchaseEvaluationPolicy.
```
⇒ Khớp **từng chữ** lời anh: *"hôm trước có cái chỗ cho **hủy** / mọi người nói là ko cần cái đó / giờ lại cần"*. Người yêu cầu gỡ = **anh Kiệt (FDC)**, ngày 12/06/2026 lúc 14:14, lý do = "Duyệt hoặc Trả về thôi" (giản lược UX), **KHÔNG phải vì lỗi kỹ thuật**.

Session log: `docs/changelog/sessions/2026-06-12-S60-S62-pe-budget-workitem-softwarning.md:23-28`.
Diff (6 file): 2 FE `PeWorkflowPanel.tsx` (filter `next.filter(p != TuChoi)`), Domain `PurchaseEvaluationPolicy.cs` (−42 dòng transition), Service (+guard 14 dòng), +2 test spec-change. **Test 254 → 256 PASS.**

### F22. 🔴 Guard S60 **VẪN SỐNG** và chính nó trỏ người dùng sang "Xóa phiếu" `[CODE]`

`PurchaseEvaluationWorkflowService.cs:101-106`:
```csharp
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{
    throw new ConflictException(
        "Hành động \"Từ chối\" đã được gỡ khỏi quy trình duyệt — chỉ còn Duyệt hoặc Trả lại. " +
        "Phiếu cần dừng: dùng Trả lại để người soạn sửa, hoặc Xóa phiếu khi còn Bản nháp.");
}
```
🔴 **Đây là mắt xích logic của cả đề bài:** S60 gỡ "Từ chối" và **chuyển hướng sang "Xóa phiếu"** — nhưng "Xóa phiếu" lại **chỉ chạy được ở Bản nháp** (F5). Vậy phiếu đã gửi duyệt rơi vào **lỗ**: không Từ chối được (gỡ rồi), không Xóa được (guard phase), chỉ Trả lại được — mà Trả lại **không triệt tiêu lũy kế** (F8). Đúng y điều Tra Sol mô tả: *"quay lại không được, phải xóa thì nó mới ko có lũy kế lên"*.
`[SUY LUẬN]` ⇒ Yêu cầu S155 **không phải feature mới**, nó là **đóng cái lỗ do S60 mở ra**. Ghi chú S60 còn để sẵn đường lui: *"Flip lại nếu cần: xóa guard này + restore transitions"*.

### F23. Nguồn gốc nút "Xóa phiếu" hiện tại `[CODE]`

- `4678d19` `[CLAUDE] App+Api: PurchaseEvaluation CQRS + Controller + WorkflowService` — commit **sinh ra** `DeletePurchaseEvaluationCommand` (git log -S).
- `378c993` `[CLAUDE] FE-Admin+FE-User: PE detail polish B12 — Lưu (no close), **Xóa phiếu**, header bar simplify, NCC name col, **no-delete có quotes**` — commit đưa nút Xóa lên UI (phía người soạn).
- **Không có** commit nào GỠ endpoint/handler xóa PE (git log -S `DeletePurchaseEvaluationCommand` chỉ ra 2 commit: 1 tạo + 1 `wal: flush` hôm nay).

**Lệnh đã chạy đầy đủ (để tái kiểm):**
```
git log --oneline -S "DeletePurchaseEvaluationCommand" -- .
git log --oneline -S "Xóa phiếu"
git log --oneline -S "handleDelete" -- fe-admin fe-user          → RỖNG
git log --oneline -i --grep="thu h"
git log --oneline -i --grep="xóa phiếu"
git show --stat 6db195d
grep -rni "thu hồi|thuhoi|recall|withdraw" --include=*.cs --include=*.tsx --include=*.ts src fe-admin/src fe-user/src
```
`[CHƯA XÁC MINH]` Tôi **không** chạy `git log --diff-filter=D` (xóa nguyên file) vì hướng "Từ chối bị gỡ" đã cho kết quả khớp verbatim lời anh; nếu reviewer muốn loại trừ khả năng có file bị xóa hẳn thì cần chạy thêm.

---

## Q7 — Pattern tham chiếu

### F24. **KHÔNG có tiền lệ** thu-hồi/hủy một chứng-từ ĐANG CHẠY workflow `[CODE]`

Grep `public record Delete.*Command` toàn `src/Backend/SolutionErp.Application` (lọc các module chứng từ) → chỉ **2** lệnh xóa cấp-chứng-từ tồn tại trong repo:
- `DeletePurchaseEvaluationCommand` (`PurchaseEvaluationFeatures.cs:1394`) — allow-list `{Nháp, Từ chối}`
- `DeleteContractCommand` (`ContractFeatures.cs:565`) — ngưỡng `< DangInKy`

**0 lệnh** xóa/hủy cho `Proposal`, `LeaveRequest`, `OtRequest`, `TravelRequest`, `VehicleBooking`, `ItTicket` — các module này **không có đường rút đơn nào cả**.

**Thứ gần nhất (nhưng KHÔNG phải workflow):** `CancelMeetingBookingHandler` (`src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs:457-479`) — đáng đọc vì nó là **khuôn duy nhất trong repo cho "hủy có vết"**:
```csharp
var isOwner = entity.BookedByUserId == userId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
    throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép huỷ booking.");

// Status=Cancelled (NOT IsDeleted=true) — preserve history + audit trail.
entity.Status = MeetingBookingStatus.Cancelled;
```
3 điểm đáng mượn: (1) **check người** trong handler (owner-or-admin) — thứ handler xóa PE đang thiếu hoàn toàn (F2); (2) **chuyển TRẠNG THÁI thay vì IsDeleted**, chú thích rõ lý do "preserve history"; (3) DELETE verb ở API nhưng semantics là cancel (`MeetingFeatures.cs:25`).

`[SUY LUẬN]` ⇒ **Spec S155 phải dựng luật mới**, không có khuôn PE/HĐ để copy. Hai hướng khả dĩ, cả hai đều có tiền lệ trong repo:
- **(A) Nới guard xóa** (rẻ nhất — đường ống đã có, lũy kế tự đúng theo F10) + thêm authz + changelog.
- **(B) Khôi phục "Từ chối"** (S60 để sẵn đường lui, F22) — nhưng `TuChoi` **KHÔNG** triệt tiêu `PrevSubmitted` ở dòng chính-xác? — **có triệt tiêu**: `ComputeAsync` chỉ nhận `{ChoDuyet, DaDuyet}` (`PeBudgetAccumulator.cs:54-55`) và `ComputePendingAsync` loại `TuChoi` ở cả 3 phép (`:108`, `:116`, `:130`) ⇒ **`TuChoi` rơi khỏi 100% các phép lũy kế**. Tức (B) cũng giải quyết được bài toán số liệu, mà **giữ được phiếu để tra cứu** — hợp ý F15 (vết không bị chôn).
Quyết định A/B **không thuộc thẩm quyền tôi** — nêu để em main/anh chọn.

---

## Tổng kết + những gì CHƯA xác minh

**Chưa xác minh (khai rõ, không lấp bằng suy đoán):**
1. **Chưa chạm DB nào** (Dev lẫn prod) — mọi kết luận đến từ đọc mã. Cụ thể **chưa** chứng bằng runtime rằng `IsDeleted=true` làm số lũy kế tụt (F10 là suy luận từ query filter, độ tin cao nhưng chưa có phép đo).
2. **Chưa đếm** hiện prod có bao nhiêu phiếu ChoDuyet-có-winner đang ăn lũy kế sai. Lệnh gợi ý cho implementer (Dev):
   `sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q "SELECT p.MaPhieu,p.Phase,p.ProjectId,p.WorkItemId FROM PurchaseEvaluations p WHERE p.IsDeleted=0 AND p.Phase=10 AND EXISTS(SELECT 1 FROM PurchaseEvaluationSuppliers s WHERE s.PurchaseEvaluationId=p.Id AND s.IsWinner=1)"`
   (prod qua SSH thường chết khi nạp SQL-client — S134/S148, cần implementer làm trực tiếp.)
3. **Chưa chạy** `git log --diff-filter=D` (F23).
4. **Chưa điều tra FE** — thuộc phần `sub-invest-fe-2.md`.

<!-- END sub-invest-be-1 · TOTAL=24 PHAT-HIEN -->

