# B6-W5b — implementer-backend — làm XANH 13 test T-matrix (YC-029)

> Run `2026-08-12-S190-soltuon-dieu-chinh-hd` · vai implementer-backend · lượt W5b (nối sau W5a test-before).
> Nguồn lệnh: `sub-b5-plan-final.md` khối `### W5` việc 2+3 · `style-gate-v2.md` §B · bảng ô `sub-style-review-b4-be2.md:198-226`.
> **KHÔNG commit** (lead gộp cuối W5) · **KHÔNG chạm test file** (hợp đồng API đã chốt @W5a).

## Việc đã làm

### 1. `src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs` — khối DELETE viết lại

Thân cũ (`:885-902`, 18 dòng) → khối mới `:885-983`. 4 thứ nở đúng 3 tên bị pin:

| # | Thứ | Bằng chứng |
|---|---|---|
| 1 | `record DeleteContractCommand(Guid Id, string? Reason = null)` — positional, default ở CUỐI ⇒ 0 call-site vỡ | `ContractFeatures.cs:917` |
| 2 | `DeleteContractCommandValidator : AbstractValidator<…>` — `Id.NotEmpty()` + `Reason.MaximumLength(2000)` | `:919-930` |
| 3 | Handler primary-ctor `(IApplicationDbContext db, ICurrentUser currentUser)` | `:932-934` |
| 4 | Header khối khai quyết-định + 3 bẫy | `:885-915` |

**5 nấc, đúng thứ tự đặc tả** (`:938-971`):

| Nấc | Mã | Dòng | Ca canh |
|---|---|---|---|
| 0 | `!IsAuthenticated \|\| UserId is null` → `UnauthorizedException` | `:939-940` | T-9 |
| 1 | `FirstOrDefaultAsync` → `NotFoundException` | `:942-943` | T-10 |
| 2 | `isOwner = DrafterUserId == currentUser.UserId` · `isAdmin = Roles.Contains(AppRoles.Admin)` → `ForbiddenException` | `:948-951` | T-7 / T-8 / T-11 |
| 3 | `Phase < DangInKy` ∪ `{TraLai, TuChoi}` → `ConflictException` | `:956-964` | T-2..T-6 |
| 4 | `Remove` **TRƯỚC** → `ContractChangelogs.Add` **SAU** → 1 `SaveChangesAsync` | `:972-987` | T-1 / T-13 |

Chi tiết đáng ghi:

- **Nấc-0 KHÔNG chép từ KHKK** — khuôn `ContractSigningPlanFeatures.cs:1763` so thẳng người soạn với actor, nên HĐ `DrafterUserId=null` + actor `UserId=null` cho `null == null` ⇒ hoá "chủ sở hữu" ⇒ xoá lọt. Lấy nấc-0 từ `PeSoftDeleteFeatures.cs:56-58`. Comment `:895-899` viết theo chiều **cấm chép ngược** (đừng xoá nấc-0 ở đây cho "giống KHKK"; muốn đều thì thêm cho KHKK).
- **Allow-list phase KHÔNG chép hình `is not (…)` của KHKK** — HĐ rộng hơn: gồm cả legacy 1/3/4. Viết dạng `Phase < DangInKy || == TraLai || == TuChoi` (`:956-958`) — giữ đúng tinh thần rào cũ `Phase >= DangInKy` rồi **mở thêm** 2 phase terminal-mềm, thay vì liệt kê đóng.
- **Message 409** liệt trạng thái cấm theo 2 nhóm (`:960-964`): "Đã gửi duyệt phải Trả lại/Từ chối trước" (ChoDuyet=10, cấm kể cả chủ HĐ — approver-delete là Q4, ngoài đợt) + "từ 'Đang in ký' trở đi (trình ký, đóng dấu, đã phát hành)" (≥5, cấm mọi đường kể cả Admin). Message cũ `"Không được xóa HĐ đã qua phase 'Đang in ký'."` bị thay — đã grep trước: **0 test/mã nào assert chuỗi đó** (`grep -rn --include=*.cs "Đang in ký" src/ tests/` = 3 hit, đều là bảng nhãn phase `WorkflowAdminFeatures.cs:80` / `ContractExcelExporter.cs:18` + chính dòng bị thay).
- **Field-set changelog** (`:974-985`) mirror 2 site ghi tay: `ContractId` · `EntityType=Contract` · `EntityId=null` (khai TƯỜNG MINH dù trùng default — T-1 assert) · `Action=Delete` · `PhaseAtChange` = biến snapshot lấy **trước** `Remove` (`:969`) · `UserId` · `UserName = FullName ?? Email` · `Summary` · `ContextNote = reason` (trim-to-null `:968`, khuôn KHKK `:1785`).

### 2. `src/Backend/SolutionErp.Api/Controllers/ContractsController.cs` — DELETE action

- `:88-90`: `Delete(Guid id, [FromQuery] string? reason = null, CancellationToken ct = default)` → `new DeleteContractCommand(id, reason)`. `ct` phải nhận `= default` vì tham số optional không được đứng trước tham số bắt buộc (nếp đã có ở `List(…)` `:26`).
- **KHÔNG gắn policy per-action** (be2-R1 LẬT so bản nháp). Comment `:78-87` khai vì sao + `[carry:w8-delete-policy]`, đặt **trên** `[HttpDelete]` (ngoài phạm vi awk của ô C10 — đặt trong thân sẽ tự làm ô đo của chính mình đỏ).

### 3. `src/Backend/SolutionErp.Domain/Contracts/ContractChangelog.cs` — gỡ ghost-wire (be2-R13)

Comment cũ `:10-12` khai *"Populate qua MediatR `AuditBehavior` interceptor … xem Application/Common/Behaviors/AuditBehavior.cs"*.

**Đo:** `ls src/Backend/SolutionErp.Application/Common/Behaviors/` → **chỉ có `ValidationBehavior.cs`**; `grep -rln --include=*.cs "AuditBehavior" src/ tests/` → **đúng 1 file: chính `ContractChangelog.cs`** (tức chuỗi này chỉ sống trong lời tự khai của nó, 0 mã nào gọi). Đây là lời hứa chưa land, không phải neo trôi.

Thay bằng sự thật + trỏ **2 đường ghi TAY** (`:10-22`): `Infrastructure/Services/ChangelogService.cs` (5 site) và `Add(new ContractChangelog{…})` thẳng trong handler — 3 site: `CreateContractFromSigningPlanFeatures.cs:247` · `CreateContractFromEvaluationFeatures.cs:121` · khối DELETE mới. Nêu luôn **vì sao câu cũ nguy hiểm hơn không có chú thích**: người đọc tưởng nhật ký tự ghi ⇒ thao tác mới lặng lẽ không để lại vết.

## Kết quả test (đỏ→xanh)

Baseline **680** đã đo tươi @W5a (`45 + 635`) trước khi chạm file ⇒ Đ-9 không kích hoạt, KHÔNG đo lại (đo lại lúc này bất khả: cây đang ĐỎ COMPILE vì test-before).

| Vòng | Trạng thái | Số đo | Nguyên nhân |
|---|---|---|---|
| 0 (W5a) | 🔴 ĐỎ COMPILE | build FAILED | CS1729 ctor handler 2-args (`:155`) · CS1729 ctor command 2-args (`:156,:463,:472`) · CS0246 `DeleteContractCommandValidator` (`:458`) · CS0117 `Reason` (`:468`) |
| 1 (W5b, sau 3 file trên) | 🟢 XANH | **45 + 648 = 693 / 0 FAIL** | — |

```
dotnet test SolutionErp.slnx --nologo -v q -p:BuildInParallel=false -maxcpucount:1
Passed! - Failed: 0, Passed:  45, Total:  45  — SolutionErp.Domain.Tests.dll        (giữ nguyên)
Passed! - Failed: 0, Passed: 648, Total: 648  — SolutionErp.Infrastructure.Tests.dll (635 → 648, +13)
```

**Xanh vòng 1, 0 vòng sửa-vì-đỏ-assert** — không có ca nào phải đàm phán lại với test.

13 ca chạy RIÊNG để chứng "+13 là chính 13 ca này", không suy từ hiệu số:

```
dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~ContractDeleteGuardTests"
Passed! - Failed: 0, Passed: 13, Total: 13, Duration: 5 s
```

Build sạch cả 6 project (Api có trong lượt build ⇒ thay đổi controller có biên dịch thật):

```
dotnet build SolutionErp.slnx --nologo → Build succeeded. 0 Warning(s) 0 Error(s)
```

**Test file KHÔNG bị chạm** (bằng chứng độc lập với lời khai): mtime `ContractDeleteGuardTests.cs` = `19:50:58` (W5a) vs 3 file src của tôi = `19:58:53 / 19:59:07 / 19:59:18`. 0 lệnh Write/Edit của tôi trỏ vào `tests/`.

## Ô acceptance BE

Bảng be2 bản đã sửa (`sub-style-review-b4-be2.md:198-226`). Đặt trước: `F=…/ContractFeatures.cs` · `H='/class DeleteContractCommandHandler/,/^}/'` · `C=…/ContractsController.cs` · `T=…/ContractDeleteGuardTests.cs`.

| Ô | Kỳ vọng | Số THẬT | Verdict |
|---|---|---|---|
| C1 | `grep -c 'record DeleteContractCommand(Guid Id, string? Reason = null)' $F` = 1 | **1** | PASS |
| C2 | awk validator \| `grep -c 'MaximumLength(2000)'` = 1 | **1** | PASS |
| C3 | awk `$H` \| `grep -c 'ICurrentUser currentUser'` = 1 | **1** | PASS |
| C4 | trong `$H`: NF < FB < CF, 3 dòng | **3 dòng — 12 NotFound · 20 Forbidden · 29 Conflict** (offset trong range) | PASS |
| C5 | `AppRoles.Admin` = 1 · `DrafterUserId == currentUser.UserId` = 1 | **1 · 1** | PASS |
| C6 | Remove < Add · `SaveChangesAsync` = 1 | **41 Remove · 43 Add · 56 Save (đúng 1)** | PASS |
| C10 | awk DELETE action \| `grep -c 'Authorize(Policy'` = **0** | **0** | PASS |
| C11 | `grep -c '^\[Authorize\]$' $C` = 1 | **1** | PASS |
| C12 | `grep -c 'FromQuery\] string? reason' $C` = 1 | **1** | PASS |
| C16 | `dotnet test` = 680+N, N≥13 | **693 (= 680+13)** | PASS *(lật STATUS = việc lead lúc commit; tôi KHÔNG chạm `docs/`)* |
| C17a | `catch (` trong `Api/Controllers/` = 0 · control `Api/` > 0 | **0 · control 2** | PASS |
| C17b | `Console.WriteLine` trong `src/Backend/` = 0 · control `Console\.` | **0 · control `Console\.` = 0** | ⚠️ PASS-có-ngoặc (dưới) |
| C17c | `grep -rE "FromSqlRaw\|ExecuteSqlRaw\|SqlQueryRaw"` = 0 · control > 0 | **0 · control `IApplicationDbContext\|ZZZNOTEXIST` = 313** | PASS |
| C18 | `{ git diff --name-only HEAD; git ls-files -o --exclude-standard; } \| sort -u` | 9 mục — **3 file của tôi + 1 test file W5a + 5 mục KHÔNG phải của tôi** (dưới) | PASS |
| C19 | `grep -rn --include=*.cs 'Contracts\.Delete' src/ tests/` = 0 | **0** | PASS |
| C21 | ma-trận `CanDelete` khoá `Contracts` + control dương | **12/13 role = 0 (chỉ Admin=1, `Drafter`=0)** · control `KeHoachKyKet` **`Drafter`=1** | PASS |

Ô thuộc file test (đo để tham chiếu, **không** sửa): C14a `AuditingInterceptor` = 4 · C14b `IgnoreQueryFilters` = 5 · C15 `[Fact]` = **14**.

**3 ghi chú về THƯỚC — cái này quan trọng hơn mấy dấu PASS ở trên:**

1. **C17b control CHẾT.** be2 pin control `Console\.` → **72**; đo lại với `--include=*.cs` ra **0** ở `src/Backend/`, `src/`, và cả `src/ + tests/`. Con số 72 là **dương-giả từ grep không lọc** (bin/obj nhị phân) — đúng bệnh mà chính C19 dựng ra để bắt. Hệ quả thật: ô C17b **không phân biệt được** "lượt sửa này tránh `Console.WriteLine`" với "cả kho chưa từng có dòng nào". Tôi thay control chuyên biệt bằng control-đường-đi (`^using ` trong `src/Backend/` = **1697**) — cái này chỉ chứng grep chạy đúng đường, KHÔNG cứu được nghĩa của ô. Ô này nên đọc là *0 thật, nhưng không có răng*.
2. **C15 rò use⟂mention (+1).** `grep -c '\[Fact\]' $T` = 14, trong khi số test CHẠY = 13. Dòng thứ 14 là **comment** `:481` (*"Viết RIÊNG một `[Fact]` theo X-9…"*). Dạng neo của W5a — `grep -c "^    \[Fact\]"` = **13** — mới là dạng đúng. Ô as-written (`≥13`) vẫn cho verdict đúng ở đây, nhưng nó xanh **nhờ bất đẳng thức lỏng**, không nhờ đo trúng; sửa `[Fact]` thành `= 13` mà không neo cột thì ô tự đỏ oan.
3. **C18 có 5 mục KHÔNG phải của tôi**, khai để lead không cộng nhầm vào phạm vi W5b: `.claude/commands/pause.md` · `.claude/commands/tiep.md` · `.claude/governance/.session-counter.json` · `docs/HANDOFF.md` · `docs/governance/so-yeu-cau-chu-du-an.md`. Tôi không biết chúng bẩn từ lượt nào và **không suy đoán**; chỉ khẳng định phần đo được: 0 lệnh Write/Edit của tôi trỏ vào chúng (2 mục đã bẩn sẵn ở ảnh git-status đầu phiên). File của tôi đúng 3: `ContractFeatures.cs` · `ContractsController.cs` · `ContractChangelog.cs`.

## DỪNG/GHI CHÚ

- **0 điều kiện Đ-1..Đ-10 kích hoạt.** Không có ca test nào phải đàm phán lại (Đ-3 im), không đụng file ngoài wave (Đ-4 im), baseline khớp 680 (Đ-9 im), không `--no-verify`.
- **Đ-5 suýt kích hoạt — KHÔNG kích hoạt, khai để lead biết:** plan `:108` trỏ `CreateContractFromSigningPlanFeatures.cs` và `CreateContractFromEvaluationFeatures.cs` như thể nằm trong `Application/Contracts/`; đĩa thật chúng ở `Application/ContractSigningPlans/` và `Application/PurchaseEvaluations/`. **Số dòng `:247` / `:121` khớp CHÍNH XÁC** ⇒ đây là lệch đường-dẫn khi thuật lại, không phải cây trôi. Đã trỏ đường ĐÚNG trong comment `ContractChangelog.cs:18-20`.
- **KHÔNG commit** (theo lệnh). **KHÔNG chạm** `docs/STATUS.md` (X-2 — dòng session DELTA `680→693` là việc của lượt commit; row canonical `Tests` `:476` KHÔNG lật, chỉ chép từ cicd-verify).
- **Việc còn treo, KHÔNG thuộc W5b:** ô C-12 / C-MIRROR-KHAI / C-2 / C-3 phía FE (`ContractDetailContent.tsx` ×2 app) — vai implementer-frontend. Cặp FE **chưa được chạm trong lượt này**; nút Xoá trên UI chưa có ⇒ đường mới hiện chỉ gọi được qua API.
- **Ranh đã giữ:** Write/Edit đúng 4 đích {`ContractFeatures.cs` · `ContractsController.cs` · `ContractChangelog.cs` · file này}. 0 chạm `tests/**`, `fe-*/**`, `Persistence/**`, agent-memory, MD canonical. **0 migration** (đúng thiết kế — không đổi schema).
