Files
solution-erp/.claude/workflows/runs/2026-07-29-S161-khkk-w7-bancung/sub-implementer-backend-0.md
2026-07-30 00:19:52 +07:00

12 KiB
Raw Blame History

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)

  • §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ải AuditableEntity.
  • BaseEntity (src/Backend/SolutionErp.Domain/Common/BaseEntity.cs:3-10) = Id · CreatedAt · UpdatedAt · CreatedBy · UpdatedByKHÔ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.csList (: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:

  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:289ContractType? Type = null trong ListContractsQuery
  • ContractFeatures.cs:327if (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:25bool 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/menusys.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.