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