# sub-implementer-backend-4 — WAVE 3: vá 6 test đỏ (hạng mục F, S155)

Run: `2026-07-27-S155-pe-delete-approver` · vai: implementer-backend · ngày 2026-07-27

---

## BƯỚC 1 — ĐO TRƯỚC KHI SỬA (không sửa mù)

Lệnh: `dotnet test SolutionErp.slnx --nologo -v minimal`

```
Failed!  - Failed: 6, Passed: 499, Skipped: 0, Total: 505 - SolutionErp.Infrastructure.Tests.dll (net10.0)
```
(Domain: 45 PASS — không đỏ.)

6 đỏ ĐÚNG như bảng lead giao. Thông điệp lỗi THẬT (trích từ output):

| # | Test | Exception THẬT | Ném tại |
|---|---|---|---|
| 1 | `F1c_SafeUpdate_LogsChangelogWithReason_OnlyToUnfinishedPes` | `DbUpdateConcurrencyException: expected to affect 1 row(s), but actually affected 0 row(s)` | `ApprovalWorkflowV2AdminFeatures.cs:806` (`SaveChangesAsync`) |
| 2 | `F12_SafeUpdate_AddsPersonToExistingLevel_RunningPeAcceptsNewApproverImmediately` | y hệt #1 | `:806` |
| 3 | `F16_ProposalWorkflow_SafeUpdate_Succeeds_AndWritesNoPeChangelog` | y hệt #1 | `:806` |
| 4 | `F14_RemoveApproverWithLiveOpinion_BlockedPolitely_NotFkViolation` | `InvalidOperationException: association ... 'ApprovalWorkflowLevel' and 'PurchaseEvaluationLevelOpinion' ... has been severed, but the relationship is ... required` | **UpdateAwDefinitionTests.cs:580** (dòng CHỨNG-NHÂN của CHÍNH TEST, không phải handler) |
| 5 | `F14_F5a_OpinionBelongsToDeletedPe_RemovalAllowed_AndOrphanPurged` | y hệt #4 | handler `:755` `db.ApprovalWorkflowLevels.Remove(gone)` |
| 6 | `ZZ_DIAG_AddLevel` | giàn giáo chẩn-đoán, tự ném `Exception` để in tracker | — |

Dump quyết định (từ ZZ_DIAG):
```
ENTRIES=[ApprovalWorkflowLevel/Modified/Id=60537ebc-...]
TRACKER=[ApprovalWorkflow/Modified || ApprovalWorkflowLevel/Modified || User/Unchanged ×3
         || Role/Unchanged || IdentityUserRole/Unchanged || ApprovalWorkflowStep/Unchanged
         || ApprovalWorkflowLevel/Unchanged]
```

---

## BƯỚC 2 — ROOT CAUSE ĐO ĐƯỢC (không chép giả thuyết)

### LỚP B (3 test) — nguyên nhân KHÔNG phải `AsNoTracking`

Giả thuyết lead: "vùng ghi changelog dùng `db.PurchaseEvaluations.AsNoTracking()` rồi đem cập nhật ⇒ affected 0".
**BÁC BỎ bằng đọc mã:** `:778-783` chỉ `Select(p => new { p.Id, p.Phase })` — projection sang anonymous type, KHÔNG hề gán/ghi lại PE nào. Không có UPDATE nào phát cho `PurchaseEvaluations`. Và F16 (module Đề xuất) thậm chí KHÔNG vào nhánh changelog PE (`:774-776` lọc `DuyetNcc/DuyetNccPhuongAn`) mà vẫn đỏ y hệt ⇒ thủ phạm nằm NGOÀI vùng changelog.

**Root cause THẬT (khớp đúng dump `Added` → `Modified`):**

`src/Backend/SolutionErp.Domain/Common/BaseEntity.cs:5`
```csharp
public Guid Id { get; set; } = Guid.NewGuid();   // ⇐ khoá LUÔN có giá trị ngay khi `new`
```

Handler thêm dòng Cấp mới bằng **navigation-add** trên entity cha ĐANG ĐƯỢC THEO DÕI
(`:730-747`): `step.Levels.Add(new ApprovalWorkflowLevel { ... })`.

Khi `DetectChanges` phát hiện một entity LẠ trong collection của cha đã-tracked, EF quyết
`Added` hay `Modified` bằng heuristic **"khoá đã set ⇒ coi là đã tồn tại"**. Vì `BaseEntity`
gán sẵn `Id = Guid.NewGuid()`, khoá KHÔNG BAO GIỜ là default ⇒ EF chọn **Modified** ⇒ phát
`UPDATE ApprovalWorkflowLevels SET ... WHERE Id = @p` cho một dòng CHƯA TỒN TẠI ⇒ 0 row
⇒ `DbUpdateConcurrencyException`.

Bằng chứng đối chứng: `POST` tạo mới (`CreateAwDefinitionCommandHandler`) KHÔNG dính vì nó
`db.ApprovalWorkflows.Add(wf)` — add từ GỐC ⇒ cả đồ thị bị ép `Added`, heuristic không chạy.

⇒ **Fix:** đăng ký dòng mới TƯỜNG MINH qua `DbSet.Add` (ép state `Added`, bỏ qua heuristic).

### LỚP A (2 test) — `ExecuteDelete` không đụng change-tracker (đúng hướng lead) + 1 ca KHÁC

`PurgeOrphanOpinionsAsync` (`:870-902`) dùng `ExecuteDeleteAsync` — đi thẳng SQL, **không**
cập nhật change-tracker. Bản sao `PurchaseEvaluationLevelOpinion` mà test đã `Add`+`Save`
vẫn nằm trong tracker ở state `Unchanged`. Tới `db.ApprovalWorkflowLevels.Remove(gone)`
(`:755`), EF chạy cascade-check TỨC THÌ (`CascadeDeleteTiming.Immediate` = mặc định), thấy
dependent còn tracked + FK `Restrict` + non-nullable ⇒ ném `InvalidOperationException`
NGAY TẠI `Remove()` (stack: `InternalDbSet.Remove` → `StateManager.CascadeDelete` →
`HandleConceptualNulls`) — chưa kịp tới SaveChanges.

🔴 **PHÂN BIỆT 2 CA (không được gộp):**
- **F14** (phiếu CÒN SỐNG): handler PHẢI chặn 409. Đo được: guard `:658-665` CHẠY ĐÚNG,
  mọi assert phía handler đều xanh. Chỗ nổ là **dòng 580 của chính test** (khối CHỨNG-NHÂN
  tự tay `Remove` để chứng minh FK là thật) — xem BƯỚC 5.
- **F14_F5a** (phiếu ĐÃ xoá mềm): handler PHẢI cho qua + purge. Đây mới là lỗi production thật.

---

## BƯỚC 3 — CÁC THAY ĐỔI

### (1) `ApprovalWorkflowV2AdminFeatures.cs` — vá LỚP B (dòng Cấp mới bị đoán nhầm là "đã tồn tại")

Vùng `toAdd` (`:730-759` sau sửa): tách `new ApprovalWorkflowLevel {...}` ra biến rồi
**đăng ký tường minh**:
```csharp
db.ApprovalWorkflowLevels.Add(newLevel);   // ép state Added, không qua heuristic khoá-đã-set
step.Levels.Add(newLevel);                 // giữ nav in-memory nhất quán cho phần đọc phía sau
```
Đúng khuôn đã có sẵn trong repo: `PeWorkItemBudgetFeatures.cs:44` cũng `db.X.Add(rec)` tường minh.

### (2) `ApprovalWorkflowV2AdminFeatures.cs` — vá LỚP A (`ExecuteDelete` bỏ quên change-tracker)

Thêm helper `DetachTrackedOpinionsOn(IApplicationDbContext, HashSet<Guid>)` (đặt cạnh
`PurgeOrphanOpinionsAsync`) + gọi NGAY TRƯỚC vòng `db.ApprovalWorkflowLevels.Remove(gone)`.
Quét `((DbContext)db).ChangeTracker.Entries()` theo **tên cột** `ApprovalWorkflowLevelId`
⇒ phủ đủ **cả 7 bảng chữ ký** (PE + HĐ + Proposal/Leave/Ot/Travel/Vehicle — đã kiểm:
`grep -rl ApprovalWorkflowLevelId src/.../Domain/` ra ĐÚNG 7 file, không dư entity nào khác)
mà không phải liệt kê tay từng DbSet.
Cast `((DbContext)db)` là khuôn đã có trong repo (`PeWorkItemBudgetFeatures.cs:52`,
`PurchaseEvaluationFeatures.cs:125`) ⇒ **KHÔNG phải đổi `IApplicationDbContext`**.

An toàn: tới điểm gọi, guard F-5 (`:658-673`) đã đọc DB bằng `AsNoTracking` và xác nhận các
dòng đó có **0 chữ ký còn hiệu lực** ⇒ bản sao nào còn tracked cũng là rác ⇒ detach = làm
tracker khớp lại DB (không nuốt dữ liệu thật).

### (3) `UpdateAwDefinitionTests.cs` — GỠ HẲN `ZZ_DIAG_AddLevel`
Xoá trọn `[Fact] ZZ_DIAG_AddLevel` (26 dòng giàn giáo, ném `Exception` để in tracker).
KHÔNG đụng bất kỳ `[Fact]` nào khác.

---

## BƯỚC 4 — ĐO SAU KHI VÁ (chạy riêng lớp test)

`dotnet build SolutionErp.slnx` → **0 Warning · 0 Error**.

`dotnet test --filter FullyQualifiedName~UpdateAwDefinitionTests`:
```
Failed! - Failed: 1, Passed: 14, Skipped: 0, Total: 15
```
- LỚP B: **F12 · F1c · F16 → XANH** (F12 xanh với assert NGUYÊN VẸN — kể cả nửa contrast
  `ForbiddenException` trước sửa, `CurrentApprovalLevelOrder == 2` sau sửa, và chữ ký gắn
  đúng dòng Cấp mới của bob).
- LỚP A: **F14_F5a → XANH**.
- `ZZ_DIAG` → biến mất (15 test còn lại, trước là 16).
- **F14 → VẪN ĐỎ**, xem BƯỚC 5.

