# sub-implementer-backend-0 — W7 BE (bản cứng `Hdc_*`, RÚT-1-MỐC)

> Vai: implementer-backend · Run: `2026-07-29-S161-khkk-w7-bancung` · Ngày 2026-07-29
> Task: (1) +param `type` cho `ListContractsQuery` + controller · (2) `ContractListItemDto` append-CUỐI `HasSealedCopy` derive từ attachments · (3) build 0 err + suite 587/0 GIỮ.
> LUẬT: KHÔNG commit/push/slnx · KHÔNG enum-extend · KHÔNG policy mới · KHÔNG đụng vùng O-A.

---

## §0 — Ghi-đĩa-từng-mục (trạng thái sống)

- [x] §1 Đo tiền-đề từ ĐĨA (trước khi sửa 1 dòng nào)
- [x] §2 Sửa DTO (`ContractDtos.cs`)
- [x] §3 Sửa query + filter + projection (`ContractFeatures.cs`)
- [x] §4 Sửa controller (`ContractsController.cs`)
- [x] §5 Build
- [x] §6 Test suite
- [x] §7 Tự đối-chứng chéo hợp-đồng FE (nghi thức W3đ1 §6)
- [x] §8 Bàn giao / caveat

---

## §1 — Đo tiền-đề từ ĐĨA (KHÔNG tin spec mù)

### 1.1 `AttachmentPurpose` — enum SẴN, 0 extend
`src/Backend/SolutionErp.Domain/Contracts/ContractAttachment.cs:5-11`
```
DraftExport = 1 · ScannedSigned = 2 · SealedCopy = 3 · Other = 99
```
⇒ ĐÚNG gate 5-anchor: `SealedCopy=3` có sẵn ⇒ **0 enum-extend** (khớp spec §②-2 RÚT-1-MỐC).

### 1.2 🔴 ContractAttachment KHÔNG CÓ soft-delete — MEMORY-PACK slice SAI ở điểm này
- `ContractAttachment : BaseEntity` (`ContractAttachment.cs:13`), KHÔNG phải `AuditableEntity`.
- `BaseEntity` (`src/Backend/SolutionErp.Domain/Common/BaseEntity.cs:3-10`) = `Id · CreatedAt · UpdatedAt · CreatedBy · UpdatedBy` — **KHÔNG có `IsDeleted`**.
- `ContractAttachmentConfiguration` (`.../Configurations/ContractConfiguration.cs:78-93`) — **KHÔNG có `HasQueryFilter`** (đối lập `ContractConfiguration.cs:44` `HasQueryFilter(x => !x.IsDeleted)` của `Contract`).
- Delete attachment = **HARD delete**: `db.ContractAttachments.Remove(att)` — `ContractAttachmentFeatures.cs:143`.

⇒ Slice gợi ý `a.Purpose == SealedCopy && !a.IsDeleted` sẽ **KHÔNG COMPILE** (`bool ContractAttachment.IsDeleted` không tồn tại).
⇒ Projection ĐÚNG = `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)` — hard-delete ⇒ row còn = file còn, không cần lọc.
(Đây đúng là ca "verify EF/entity là source-of-truth, KHÔNG trust spec blindly" — bài S35.)

### 1.3 Nav `Contract.Attachments` có thật
`ContractConfiguration.cs:42`: `b.HasMany(x => x.Attachments).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(Cascade)` ⇒ `x.c.Attachments.Any(...)` dịch được sang `EXISTS(...)`.

### 1.4 Tiền-lệ param `type` — đã có ở query anh em
`ContractFeatures.cs:408-409` `ListDeletedContractsQuery(ContractType? Type = null)` + filter `:434`
`ContractsController.cs:31` `[FromQuery] ContractType? type = null`
⇒ W7 chỉ cookie-cutter đúng khuôn đó sang `ListContractsQuery`, 0 sáng-tạo.

### 1.5 3 call-site dựng `ContractListItemDto` (đếm bằng grep, KHÔNG bằng trí nhớ)
- `ContractFeatures.cs:335` List (**MỚI derive ở đây**)
- `ContractFeatures.cs:450` ListDeleted (giữ nguyên 12 arg ⇒ nhận default)
- `ContractFeatures.cs:529` Inbox (giữ nguyên 12 arg ⇒ nhận default)
⇒ Vì thế param mới **BẮT BUỘC có default** (`= false`), nếu không 2 site kia vỡ compile.

### 1.6 Endpoint upload đã có sẵn (FE lane cần — đo hộ, KHÔNG sửa)
`ContractsController.cs:85-101` `POST /api/contracts/{id}/attachments`
- multipart field: `file` (IFormFile, **tên field = `file`**) · `purpose` (`[FromForm]`, int enum, mặc định `Other=99`) · `note` (`[FromForm]`, optional)
- guard: `[RequestSizeLimit(25_000_000)]` + validator 20 MB + MIME whitelist (`ContractAttachmentFeatures.cs:31-41`: pdf/doc/docx/xls/xlsx/png/jpeg/webp)
- authz: **KHÔNG có `[Authorize(Policy=...)]` riêng** → chỉ `[Authorize]` trần cấp class (`:13`). Đây là vùng **O-A để mở nguyên trạng** theo lệnh lead ⇒ KHÔNG đụng.
⇒ FE gửi `purpose=3` để đánh dấu bản cứng.

---

## §2 — DTO: append-CUỐI `HasSealedCopy`

File `src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs`
Append sau `DateTime CreatedAt` (vị trí CUỐI, có default `= false`).

Lý do default: xem §1.5 (2 call-site kia giữ 12 arg). Hệ quả **KHAI THẲNG**: ở màn "Đã xóa" + "Hộp thư" cờ luôn `false` (không derive) — đúng chỉ-thị "KHÔNG đổi gì khác"; badge W7 chỉ sống trên `GET /api/contracts`.

## §3 — Query + filter + projection

File `src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs`
1. `ListContractsQuery` — positional-append `ContractType? Type = null` (KHÔNG chèn giữa: `new ListContractsQuery(phase, supplierId, projectId)` ở controller là positional).
2. Filter đặt **cùng khối** 3 filter cũ (`Phase/SupplierId/ProjectId`), sau khối IDOR ⇒ không đụng scope W6.
3. Projection List: `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)`.

## §4 — Controller

File `src/Backend/SolutionErp.Api/Controllers/ContractsController.cs` — `List` (`:16-24`): `[FromQuery] ContractType? type = null` (đặt cạnh `projectId`, TRƯỚC `CancellationToken`) + truyền vào ctor thứ 4.

---

## §5 — BUILD (SỐ ĐO THẬT, chạy SAU khi 4 edit đã land)

```
$ dotnet build SolutionErp.slnx -v minimal
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:44.12
```
6 assembly link OK (Domain · Domain.Tests · Application · Infrastructure · Api · Infrastructure.Tests). **0 Error / 0 Warning.**

## §6 — TEST (SỐ ĐO THẬT)

```
$ dotnet test SolutionErp.slnx --no-build
Passed! - Failed: 0, Passed:  45, Skipped: 0, Total:  45 - SolutionErp.Domain.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 542, Skipped: 0, Total: 542 - SolutionErp.Infrastructure.Tests.dll (net10.0)
```
**TOTAL 587 passed / 0 failed** ⇒ baseline 587 (45D + 542I) **GIỮ NGUYÊN**, 0 regression, 0 skip.

### 6.1 🟢 Bằng chứng RUNTIME (không chỉ compile) — may mà có sẵn
`tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs:165-166`
```csharp
=> new ListContractsQueryHandler(db, actor)
       .Handle(new ListContractsQuery(Phase: phase) { PageSize = 200 }, CancellationToken.None);
```
2 điều rút ra:
1. Test dựng query bằng **named-arg `Phase:`** ⇒ positional-append `Type` KHÔNG phá call-site test (đó là lý do append CUỐI, không chèn giữa).
2. Handler này CHẠY THẬT trên SQLite fixture trong suite ⇒ projection mới `Attachments.Any(...)` đã được **provider dịch + thực thi thành công**, không phải chỉ "qua compiler". Đây là mức chứng mạnh hơn build-only.

## §7 — Tự đối-chứng chéo hợp-đồng FE (5/5, TRƯỚC bàn giao — nghi thức W3đ1 §6)

| # | Lead định-nghĩa (LITERAL) | BE trên đĩa (dòng SAU khi sửa) | Khớp |
|---|---|---|---|
| 1 | `GET /api/contracts?type={int}` param MỚI `type` (ContractType? 1..7) | `ContractsController.cs:25` `[FromQuery] ContractType? type = null` → `:27` truyền vị-trí thứ 4 | ✅ |
| 2 | `&phase=9` vẫn chạy (DaPhatHanh) | `ContractsController.cs:20` `[FromQuery] ContractPhase? phase` GIỮ NGUYÊN, 0 dòng đụng | ✅ |
| 3 | DTO append-CUỐI `bool HasSealedCopy`, FE đọc `hasSealedCopy` | `ContractDtos.cs:25` field CUỐI. Wire-name: `Program.cs:33-34` `AddJsonOptions` CHỈ add `UtcDateTimeJsonConverter`, **KHÔNG override `PropertyNamingPolicy`** ⇒ giữ default ASP.NET Core = camelCase ⇒ JSON = `hasSealedCopy` | ✅ |
| 4 | Cờ = có ≥1 attachment `purpose=3` | `ContractFeatures.cs:355` `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)` | ✅ |
| 5 | Upload dùng endpoint CÓ SẴN, FE không cần endpoint mới | `ContractsController.cs:85-101` (`POST {id}/attachments`, field `file` + `purpose` + `note`) — **0 dòng thay đổi**, xem §1.6 | ✅ |

Điểm 3 là chỗ dễ "giả-định thay phép-đo" nhất (mặc định camelCase ai cũng *tưởng* đúng) ⇒ đã đi đọc `Program.cs` xác nhận không có override, thay vì tin quy ước.

### 7.1 Vị trí 4 thay đổi (grep xác nhận sau sửa)
- `ContractFeatures.cs:289` — `ContractType? Type = null` trong `ListContractsQuery`
- `ContractFeatures.cs:327` — `if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);`
- `ContractFeatures.cs:355` — derive `HasSealedCopy` (chỉ ở projection List)
- `ContractsController.cs:25` + `:27` — param + truyền
- `ContractDtos.cs:25` — `bool HasSealedCopy = false`
KHÔNG đụng: `:450` (filter Type của ListDeleted, đã có từ S159) · `:466` + `:545` (2 projection còn lại).

## §8 — Bàn giao / caveat (khai thẳng, không giấu)

1. **`hasSealedCopy` = `false` cứng ở 2 màn khác** (Đã xóa `:450` · Hộp thư `:529`) vì giữ literal "KHÔNG đổi gì khác". Nếu FE về sau muốn badge ở 2 màn đó → phải mở thêm 1 dòng derive mỗi site (rẻ, nhưng là quyết-định của lead, KHÔNG phải mình tự mở).
2. **Cờ không phân biệt ai upload / phase nào**: bất kỳ attachment `purpose=3` là true (kể cả HĐ chưa `DaPhatHanh`). Đúng tinh thần 1-MỐC "Upload file cứng lên → xem như xong", nhưng FE nên chỉ hiển thị badge trên list đã lọc `phase=9`.
3. **KHÔNG có guard chặn upload `purpose=3` sai phase** — đây là vùng O-A (attachment-guard) lead dặn để mở nguyên trạng. Không tự thêm.
4. **0 migration · 0 enum-extend · 0 policy mới · 0 seed/menu** ⇒ `sys.tables` + số menu 142 + authz O-A không đổi.
5. KHÔNG commit / KHÔNG push / KHÔNG sửa `SolutionErp.slnx` (0 file `.cs` MỚI ⇒ slnx không cần đụng).

### 8.1 Isolation audit (git status cuối lượt) — có 1 file KHÔNG PHẢI của tôi, khai luôn
```
 M src/Backend/SolutionErp.Api/Controllers/ContractsController.cs        ← của tôi
 M src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs     ← của tôi
 M src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs    ← của tôi
?? .claude/workflows/runs/2026-07-29-S161-khkk-w7-bancung/sub-implementer-backend-0.md ← của tôi (run-folder)
 M .claude/governance/.session-counter.json                              ← 🔴 KHÔNG PHẢI TÔI
```
`.session-counter.json` = hook `session-counter-tick.ps1` bump `counter 34→35 · S160→S161 · head febe6b1→8f37910` (đọc diff xác nhận: chỉ 4 field + 1 history entry, đúng khuôn tick). Tôi KHÔNG có tool nào chạm file đó trong lượt này. Ghi ra đây để em main audit diff sau P2 không quy nhầm cho lane này.

Tổng diff code của tôi: **3 file, +35/−4 dòng** (phần lớn là comment giải-thích "vì sao", không phải logic).

### 8.2 Cái tôi CỐ Ý KHÔNG làm (để lead khỏi phải hỏi)
- KHÔNG thêm test (`tests/**` = test-specialist) — chỉ đảm bảo compile + suite cũ xanh.
- KHÔNG thêm `[Authorize(Policy=...)]` cho `List` (giữ `[Authorize]` trần như cũ) — thêm policy = đổi authz O-A, ngoài lệnh.
- KHÔNG migration / KHÔNG enum / KHÔNG seed / KHÔNG menu-key.
- KHÔNG đụng `ListDeletedContractsQueryHandler` dù nó là hàng-xóm gần nhất (W6 đã ghi rõ đó là bề-mặt IDOR nhạy cảm — `ContractFeatures.cs:426-431`).

---

> **ERRATA (lead ghi @S162, nguồn: reviewer W7 §4.1):** dòng tự-khai +35/−4 ở §8 SAI — đo lại 2 phương pháp độc lập (git diff --numstat cộng dọc + grep -c '^+[^+]') đều ra **+31/−4**. Số-tự-khai không kèm lệnh sinh ra nó = không có phép kiểm làm nó trượt (đúng lớp S119). Hành vi KHÔNG ảnh hưởng — mã trên đĩa là mã đã build + test 590/0.
