12 KiB
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) +paramtypechoListContractsQuery+ controller · (2)ContractListItemDtoappend-CUỐIHasSealedCopyderive 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)
- §1 Đo tiền-đề từ ĐĨA (trước khi sửa 1 dòng nào)
- §2 Sửa DTO (
ContractDtos.cs) - §3 Sửa query + filter + projection (
ContractFeatures.cs) - §4 Sửa controller (
ContractsController.cs) - §5 Build
- §6 Test suite
- §7 Tự đối-chứng chéo hợp-đồng FE (nghi thức W3đ1 §6)
- §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ảiAuditableEntity.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ậpContractConfiguration.cs:44HasQueryFilter(x => !x.IsDeleted)củaContract).- 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:335List (MỚI derive ở đây)ContractFeatures.cs:450ListDeleted (giữ nguyên 12 arg ⇒ nhận default)ContractFeatures.cs:529Inbox (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 địnhOther=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ửipurpose=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
ListContractsQuery— positional-appendContractType? Type = null(KHÔNG chèn giữa:new ListContractsQuery(phase, supplierId, projectId)ở controller là positional).- Filter đặt cùng khối 3 filter cũ (
Phase/SupplierId/ProjectId), sau khối IDOR ⇒ không đụng scope W6. - 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
=> new ListContractsQueryHandler(db, actor)
.Handle(new ListContractsQuery(Phase: phase) { PageSize = 200 }, CancellationToken.None);
2 điều rút ra:
- Test dựng query bằng named-arg
Phase:⇒ positional-appendTypeKHÔNG phá call-site test (đó là lý do append CUỐI, không chèn giữa). - 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 = nulltrongListContractsQueryContractFeatures.cs:327—if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);ContractFeatures.cs:355— deriveHasSealedCopy(chỉ ở projection List)ContractsController.cs:25+:27— param + truyềnContractDtos.cs:25—bool HasSealedCopy = falseKHÔ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)
hasSealedCopy=falsecứ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ở).- Cờ không phân biệt ai upload / phase nào: bất kỳ attachment
purpose=3là true (kể cả HĐ chưaDaPhatHanh). Đú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ọcphase=9. - KHÔNG có guard chặn upload
purpose=3sai phase — đây là vùng O-A (attachment-guard) lead dặn để mở nguyên trạng. Không tự thêm. - 0 migration · 0 enum-extend · 0 policy mới · 0 seed/menu ⇒
sys.tables+ số menu 142 + authz O-A không đổi. - KHÔNG commit / KHÔNG push / KHÔNG sửa
SolutionErp.slnx(0 file.csMỚ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=...)]choList(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
ListDeletedContractsQueryHandlerdù 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.