# sub-implementer-backend-5 — WAVE 5 BE: vá F1 + F2 (2 FLAG HIGH mất dữ liệu)

> Ghi TRONG LÚC LÀM, flush từng phần, tự `wc -c` kiểm byte thật.

- Ngày: 2026-07-27 · run `2026-07-27-S155-pe-delete-approver`
- Vật sửa: `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs`
- Nền đo trước wave: `dotnet build` 0W/0E · `dotnet test` 549 PASS / 0 FAIL

---

## §0 — ĐỌC NGUYÊN VĂN F1 + F2 (reviewer-diff-dot1.md:33-80)

F1: `PurgeOrphanOpinionsAsync` (`:886-918`) dùng `ExecuteDeleteAsync` = auto-commit NGAY khi
không có transaction ambient. Gọi tại `:681-683`, `SaveChangesAsync` mãi `:822`. 4 nguồn ném
nằm GIỮA (`ResolveUserNamesAsync:686` round-trip · `Remove():771` severed-association ·
`db.PurchaseEvaluations…ToListAsync:794-800` round-trip · ghi changelog N phiếu trong SaveChanges).
Tiêu chí nghiệm thu: test giả lập SaveChanges ném SAU purge ⇒ số row opinion KHÔNG đổi.

F2: điều kiện purge `:889-891` (+6 khối lặp tới `:915`) rộng hơn nhãn "mồ côi" ở CẢ HAI vế —
(a) `!db.PurchaseEvaluations.Any(p => p.Id == … && !p.IsDeleted)` coi phiếu XOÁ MỀM ngang phiếu
không tồn tại; (b) `o.IsDeleted` — opinion xoá mềm trên phiếu CÒN SỐNG = bản ghi kiểm toán.
Phương án (a) của reviewer: chỉ purge khi phiếu cha thật sự KHÔNG CÒN ROW
(`IgnoreQueryFilters().Any(...)`), phiếu xoá mềm thì CHẶN admin như phiếu sống.

---

## §1 — ĐO NỀN TRƯỚC KHI SỬA (không tin doc, chạm đĩa)

| Sự thật | Bằng chứng |
|---|---|
| `IApplicationDbContext` KHÔNG expose `Database` | `grep -n "Database\|BeginTransaction" src/.../Common/Interfaces/IApplicationDbContext.cs` → **0 hit** |
| Handler đã có tiền lệ ép kiểu `((DbContext)db)` | `ApprovalWorkflowV2AdminFeatures.cs:941` (trong `DetachTrackedOpinionsOn`) |
| Tiền lệ transaction repo | `ContractCodeGenerator.cs:35-59` — `var context = (DbContext)db; await using var tx = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);` + try/Commit/catch/Rollback |
| `Microsoft.EntityFrameworkCore.Relational` CÓ trong Application | `SolutionErp.Application.csproj` — `<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />` |
| 7 bảng `*LevelOpinion` KHÔNG có global query filter | `grep -rn "HasQueryFilter" Configurations/` → chỉ 1 hit `PurchaseEvaluationConfiguration.cs:84` (bảng CHA). ⇒ vế `o.IsDeleted` trong purge là THẬT (không phải dead code) |
| FK opinion→cha = **Cascade**, opinion→Level = **Restrict** | `PurchaseEvaluationLevelOpinionConfiguration.cs:20-28` |
| SQLite fixture BẬT FK enforcement thật | test cũ `UpdateAwDefinitionTests.cs:562-565` assert `DbUpdateException` khi `Remove(level)` có opinion ⇒ FK Restrict fire thật trong SQLite |
| Fixture SQLite hỗ trợ transaction | `SqliteDbFixture.cs:36-40` comment + `UseSqlite(shared connection)` |

### Hệ quả SUY RA TỪ ĐO (quan trọng cho thiết kế)

FK cha→opinion là **Cascade** ⇒ xoá cứng phiếu cha thì opinion cũng đi theo ⇒ **"mồ côi thật"
(cha KHÔNG CÒN ROW) gần như không sinh ra được qua đường ứng dụng**. Nghĩa là sau khi thu hẹp
theo F2, `PurgeOrphanOpinionsAsync` gần như là **lớp phòng-vệ-chiều-sâu** cho dữ liệu lệch sinh
ngoài luồng (sửa DB tay / import / FK tắt), KHÔNG còn là đường chạy thường ngày. Đúng ý F2:
guard chặn HẾT những gì còn cha, purge chỉ dọn thứ đã không thể khôi phục.

---

## §2 — VÁ F1 (rào giao dịch) — ĐÃ LAND

Kỹ thuật chọn: `await using var tx` + `CommitAsync` cuối, **KHÔNG try/catch** — đúng khuôn nhà
(`LeaveOtApprovalFeatures.cs:34-44`, `ProposalFeatures.cs:386`, `TravelVehicleApprovalFeatures.cs:822`).
Dispose của `IDbContextTransaction` rollback nếu chưa Commit ⇒ **diff là CHÈN-THÊM, 0 dòng thân
method bị thụt lề lại** (nếu bọc try/catch thì ~150 dòng phải re-indent = nhiễu review, khó soi).

- Mở rào: ngay TRƯỚC `PurgeOrphanOpinionsAsync` (thao tác không-hoàn-tác đầu tiên). Guard phía
  trên chỉ ĐỌC `AsNoTracking` rồi ném, không cần nằm trong rào; mở sớm hơn chỉ kéo dài giữ khoá.
- KHÔNG dùng `IsolationLevel.Serializable` như 4 code-generator: ở đó Serializable chống ĐUA
  cấp-số; ở đây yêu cầu là NGUYÊN TỬ, siết isolation chỉ làm màn quản trị dễ deadlock.
- `ExecuteDeleteAsync` tự enlist vào transaction đang mở của context ⇒ 7 câu DELETE nay cùng
  sống/chết với `SaveChangesAsync`.

## §3 — VÁ F2 (thu hẹp purge + chặn hồ sơ xoá mềm) — ĐÃ LAND

Phân hoạch KÍN, không còn kẽ (đây là điểm thiết kế chính):

| Trạng thái chữ ký | Xử lý sau vá | Ai lo |
|---|---|---|
| cha SỐNG + ý kiến SỐNG | chặn 409 (thông điệp CŨ, giữ nguyên) | `CountLiveOpinionsAsync` (tầng 1) |
| cha XOÁ MỀM (row còn) | **chặn 409 + nêu TÊN hồ sơ** ← MỚI | `ListRetainedOpinionsAsync` (tầng 2) |
| ý kiến XOÁ MỀM, cha sống | **chặn 409 + nêu TÊN hồ sơ** ← MỚI | `ListRetainedOpinionsAsync` (tầng 2) |
| cha KHÔNG CÒN ROW | purge (xoá cứng) — đường duy nhất còn lại | `PurgeOrphanOpinionsAsync` |

- Áp CẢ 7 bảng chữ ký (PE · HĐ · Đề xuất · Nghỉ phép · Tăng ca · Công tác · Đơn xe), tầng 2
  JOIN sang cha để lấy mã hồ sơ: `MaPhieu` · `MaHopDong` · `MaDeXuat` · `MaDonTu`×4.
- Guard tầng 2 áp cho **cả `toRemove` lẫn `approverChanged`** — reviewer viết "chặn admin **như
  phiếu sống**", mà phiếu sống thì cả bỏ-người lẫn đổi-người đều bị chặn. Đổi người trên dòng
  còn giữ chữ ký của hồ sơ xoá mềm = khi khôi phục, ý kiến người A hoá thành của người B.
- `IgnoreQueryFilters()` BẮT BUỘC ở tầng 2 và ở purge: PE/Contract có global filter `!IsDeleted`
  (`PurchaseEvaluationConfiguration.cs:84`) — không tắt thì hồ sơ xoá mềm biến mất khỏi JOIN và
  ta lại kết luận "mồ côi" = đúng cái sai đang chữa.
- Sửa luôn dòng changelog `"Dọn N chữ ký mồ côi (thuộc phiếu đã xoá)"` → `"(hồ sơ mang chữ ký đã
  KHÔNG CÒN trong hệ thống)"` — nhãn cũ nay MÔ TẢ SAI việc đang làm.

### Đo ngay sau khi vá (trước khi đụng test)

`dotnet build SolutionErp.slnx` → **0 Warning / 0 Error**.
`dotnet test --filter UpdateAwDefinitionTests` → **14 PASS / 1 FAIL**. Đúng 1 fail, đúng cái phải
fail: `F14_F5a_OpinionBelongsToDeletedPe_RemovalAllowed_AndOrphanPurged` — test này khẳng định
CHÍNH hành vi mà F2 gọi là sai ("phiếu xoá mềm ⇒ cho gỡ + purge chữ ký"). Thông điệp thật nhận
được: `… còn 1 chữ ký/ý kiến đang LƯU, thuộc: Phiếu duyệt NCC PE-F14b-001 — đã xoá …`
⇒ chứng minh 3 việc cùng lúc: guard tầng 2 CHẠY · `IgnoreQueryFilters()` DỊCH ĐƯỢC trên SQLite ·
thông điệp NÊU ĐÚNG TÊN hồ sơ. 0 test nào khác đỏ ⇒ vá không lan.
