Files
solution-erp/.claude/workflows/runs/2026-07-29-S160-khkk-dryrun-plan/sub-investigator-codebase-2.md
2026-07-29 16:18:19 +07:00

32 KiB
Raw Blame History

sub-investigator-codebase-2 — LANE-2 CODEBASE DRY-RUN MAP (S160, /fable-real)

Vai: investigator-codebase · engine-đắt REAL · ghi-đĩa-trong-lúc-làm (chống #53). Trục: CODE THẬT trên repo. Lane-1 (song song) lo nghiệp vụ từ QT/FORM. Spec KHKK cũ (GĐ2 b.7→12, W1-W5, 7 bảng, Mig 69, type=10) = RATIFIED — chỉ BỔ SUNG GĐ3+GĐ4. Ranh giai đoạn (owner chốt @S159, spec §1.2): GĐ2=b.7→12 · GĐ3=b.13→18 (tận dụng Contract V2) · GĐ4=b.19→21 + ký cứng upload-only.

§0 — Nguồn đã đọc + tiền đề

  • run.md S160 (lệnh anh verbatim + ràng buộc 5-anchor + authz S156 + gotcha #84 no-SQL-tay).
  • Spec S157 497 dòng ĐỌC TRỌN (①②③④⑤): 7 bảng ContractSigningPlan*, Mig 69, ApplicableType=10, W1-W5, NOTE W1 @S160 (Khkk_* skeleton ĐÃ prod, dark-launch ĐẢO — menu public, ComingSoon→trang thật mới là "bật").
  • src-QT-TRINH-KY-HD.txt: bảng 2 = 9 bước ISO (Lựa chọn→Soạn thảo→Góp ý→Đàm phán→In HĐ→CCM→BOD→Đóng dấu→Phát hành/Lưu trữ) ↔ ContractPhase 1-9 khớp 1:1 (xác nhận S157 lens-fidelity).
  • src-RG-001-v02-ma-so-hd.txt (2.907B): format A/B/SOL&C/D + 6 ví dụ HĐ + 2 dạng PO (X/Y/PO Z theo nguyên tắc · A/PO/SOL&C/D không nguyên tắc).

(mục chi tiết append dần bên dưới — mỗi §=1 câu hỏi)


§1 — Q1: MAP TÁI DÙNG b.13→21 → Contract V2 (mọi dòng RE-VERIFY trên đĩa 07-29, KHÔNG chép mù S156 — line đã TRÔI vì arc-2 thêm ListDeleted)

Khung đọc: GĐ3 = b.13→18 (owner @S159) chạy trên state machine 5-trạng-thái ContractPhase.cs:14-28 (sống: DangSoanThao=2 · ChoDuyet=10 · DaPhatHanh=9 · TraLai=98 · TuChoi=99; 7 phase [LEGACY] = chính các trạm 13-21 đã gỡ CỐ Ý Mig 21). GĐ4 = b.19→21. Duyệt V2 = ContractWorkflowService.ApproveV2Async (ContractWorkflowService.cs:217-394) cookie-cutter PE: OR-of-N :259-260 · UPSERT ContractLevelOpinions :292-316 · skipToFinal :320-352 · terminal gen-mã+DaPhatHanh :366-385.

b Việc (theo QT/PDF) Phase/feature hiện có Trạng thái Chứng (file:line, đo 07-29)
13 PMH soạn draft HĐ từ giá chốt KHKK DangSoanThao; 2 đường tạo: bridge PE→HĐ + create thường (create thường ĐÃ nhận pin V2 ContractFeatures.cs:109; gen MaHopDong NGAY tại create :113-118 — RG-001 có mã từ nháp, KHÔNG đợi terminal) PARTIAL Bridge pin V1-only CreateContractFromEvaluationFeatures.cs:68-71,108 + KHÔNG đọc giá KHKK (giaTri=SUM Quote.IsSelected :88-90) → chi tiết §2
14 BCH bổ sung/hoàn thiện draft UpdateContractDraftCommand guard duy nhất Phase==DangSoanThao (ContractFeatures.cs:156-157), KHÔNG check drafter ⇒ BCH SỬA được về cơ chế PARTIAL Nhưng BCH KHÔNG XEM được: view-guard GetEligiblePhases (:334-351) map DangSoanThao chỉ cho Drafter/DeptManager (:342-343); role ProjectManager chỉ được DangGopY legacy. Command cũng KHÔNG có ApprovalWorkflowId (:136-145) ⇒ 0 đường re-pin V2 sau tạo
15 Đàm phán với TP (ngoài hệ thống) Không phase riêng (DangDamPhan=LEGACY đã gỡ) — ghi vết = comment + attachment SẴN (triết lý Mig 21: trạm offline = chứng từ đính kèm) ContractPhase.cs:19 LEGACY; attachment upload KHÔNG phase-guard (ContractAttachmentFeatures.cs 0 hit guard Phase — chỉ changelog :84,:144)
16 TP ký nháy → BCH chuyển PMH (scan) Upload attachment mọi phase GAP nhỏ AttachmentPurpose (ContractAttachment.cs:5-11) = DraftExport/ScannedSigned/SealedCopy/Other — KHÔNG có "ký nháy" (InitialSigned). Enum-extend int-column 0-migration (tiền lệ PE ComparisonTable=4)
17 PMH trình ký; CCM kiểm tra + ký nháy Trình DangSoanThao→ChoDuyet (ContractWorkflowService.cs:70-88, init con-trỏ đôi :81-84); CCM = Step 1 của workflow V2 ApplicableType.Contract=3 — duyệt qua ApproveV2Async PARTIAL 🔴 3 lỗ: (i) guard trình đòi role DrafterDeptManager (:73-78) — PMH mang Procurement bị 403 (lớp "vai đi qua không có"); (ii) approver KHÔNG XEM được HĐ ChoDuyet — GetEligiblePhases 0 entry ChoDuyet; (iii) KHÔNG vào inbox — PhaseActorRoles (:421-430) 100% legacy, 0 entry ChoDuyet
18 CEO ký nếu >5 tỷ Step CEO trong workflow V2 (Level đích danh Director) — chạy được nếu CEO ký MỌI HĐ GAP (phần ngưỡng) CeoApprovalThreshold = cột chung ApprovalWorkflow.cs:43 nhưng grep ContractWorkflowService.cs = 0 hit (đọc trọn 443 dòng 07-29) — admin set vào workflow Contract-type bị NGÓ LƠ im lặng. AllowApproverFinalize cũng PE-only. Đường có sẵn 0-code: 2 workflow (có/không Step CEO) + user chọn lúc tạo
(kết GĐ3) Duyệt cấp cuối → DaPhatHanh terminal + gen mã nếu chưa có (:366-385) SẴN Terminal của V2 = "Đã phát hành" — nghĩa là b.19-21 GĐ4 xảy ra SAU terminal về mặt hệ thống
19 HR đóng dấu 2 lựa chọn: (a) HR = Step cuối workflow V2 (SẴN, chỉ cấu hình — terminal dời về sau HR); (b) tracking riêng trên vật thể "bản cứng" GĐ4 SẴN-config / GAP-nếu-tracking-riêng DangDongDau=8 LEGACY (ContractPhase.cs:23) — KHÔNG hồi sinh. §3 đề xuất
20 CCM scan bản cứng, lưu trữ AttachmentPurpose.SealedCopy=3 upload ở DaPhatHanh SẴN ContractAttachment.cs:9; upload không phase-guard ⇒ up được sau terminal
21 Chuyển phát nhanh bản gốc về TP + lưu Không field nào (mã vận đơn/số bản gốc/vị trí lưu) GAP (nhỏ — comment đủ nếu owner không đòi field) Contract.cs:8-70 đọc trọn: 0 field bản-cứng (không seal-date, không courier, không số-bản-gốc, không vị-trí-lưu-file-cứng)

Kết Q1: cơ chế duyệt (17-18) + terminal + attachment (15,16,20) = TÁI DÙNG được ngay sau khi vá 3 lỗ "đường ống" (view/inbox/vai-trình) — đúng verdict LAI S156, hôm nay re-verify còn nguyên. GĐ4 (19-21) KHÔNG có vật mang trong Contract entity — cần quyết định §3 (field-on-Contract vs entity mới).

Đếm endpoint (đo 07-29): ContractsController.cs = 30 endpoint (8 đọc + 22 ghi), class [Authorize] trần :13; ngoại lệ DUY NHẤT có policy: GET /deleted [Authorize(Policy="Contracts.Read")] :28-29 (arc-2 S159) — khuôn per-action ĐÃ CÓ TIỀN LỆ ngay trong file này.


§2 — Q2: BRIDGE KHKK chen giữa PE và Contract

Hiện trạng bridge (đo 07-29): POST /api/purchase-evaluations/{id}/create-contract (PurchaseEvaluationsController.cs:339-346, body {ContractType, TenHopDong, Bypass} :379-382 — KHÔNG có ApprovalWorkflowId) → handler CreateContractFromEvaluationFeatures.cs: guard Phase==DaDuyet + winners.Count>0 + pe.ContractId==null (:53-60); per-winner giaTri = SUM Quote.IsSelected (:88-90); pin V1-only WorkflowDefinitionId=activeWfId (:68-71,:108); gen mã atomic self-committing codegen (:78-115); idempotency pe.ContractId = contracts[0].Id (:145). Picker: GET /purchase-evaluations/approved-pending-contract (:335-337) → ListApprovedPurchaseEvaluationsQuery filter Phase==DaDuyet && ContractId==null (CreateContractFromEvaluationFeatures.cs:173).

(a) KHKK kế thừa từ PE (đã RATIFIED spec S157 §2.3 — chỉ tóm + anchor sống): header PurchaseEvaluationId loose-Guid + ProjectId denorm + DepartmentId (C5) + snapshot HoSoLink; Lines auto-sinh từ winners pe.Suppliers.Where(IsWinner) với PeReferenceAmount = SUM Quote.IsSelected per-winner snapshot 1 lần — copy đúng phép tính bridge cũ :88-90 + guard :53-60. Khuôn nguyên con nằm sẵn trong file bridge.

(b) Contract tạo từ KHKK kế thừa gì (spec §2.5 RATIFIED, W5): (i) giaTri = line.ApprovedAmount khớp w.SupplierId thay SUM :88-90 khi PE có plan DaDuyet + ContextNote lệch "giá KH x vs SUM-PE y" vào changelog (khuôn :121-140); (ii) +param ApprovalWorkflowId pin V2 — thiếu nó HĐ từ phiếu rơi nhánh V1: nếu 0 active WorkflowDefinition cho type đó → KẸT CỨNG ConflictException ContractWorkflowService.cs:115-116 (re-verify 07-29: đúng dòng def == null || def.Steps.Count == 0); (iii) phần còn lại giữ nguyên từ PE (TenHopDong/PaymentTerms→DraftData/BudgetManual* :101-107); (iv) 🆕 ghi ngược Line.ContractId = contract.Id per-winner (cột C6 spec đã có chỗ) — vòng lặp :82-115 đã per-winner nên chỗ ghi tự nhiên.

(c) Sửa bridge cũ hay thêm bridge mới → SỬA BRIDGE CŨ (1 file), KHÔNG đẻ bridge thứ 2 — đây là hướng spec W5 đã ratify; lý do kỹ thuật đứng vững hôm nay: entry-point PE giữ idempotency pe.ContractId (:59-60,:145) + FE nút tạo HĐ đã tồn tại màn PE; bridge mới từ KHKK = endpoint thứ 2 cùng nghĩa (2 chỗ sửa về sau — lớp lỗi 2-site S155). Bổ sung DUY NHẤT cho dry-run: màn KHKK detail (DaDuyet) thêm nút deep-link gọi CÙNG endpoint cũ — 0 endpoint mới, 0 handler mới. (Phương án B ghi để review chốt: POST /contract-signing-plans/{id}/create-contracts riêng — chỉ đáng nếu owner muốn 1 KHKK chia nhiều đợt HĐ; hiện 0 yêu cầu.)

(d) Cardinality chuỗi PE↔KHKK↔Contract:

Cặp Ràng buộc Cơ chế enforce
PE → KHKK 1 PE — ≤1 plan SỐNG (1-N lịch sử, tạo lại được sau TuChoi) app-level AnyAsync(p.PeId==x && p.Phase!=TuChoi) → 409 (spec §2.3; KHÔNG đặt được UNIQUE-filtered theo Phase động — đúng lựa chọn app-level)
PE → Contract 1 PE — N HĐ (multi-winner Mig 58) pe.ContractId = contracts[0].Id :145 + guard :59-60 "đã tạo HĐ rồi"
KHKK → Contract 1 plan — N Lines (UNIQUE filtered PlanId+SupplierId) — mỗi Line ↔ ≤1 HĐ Line.ContractId Guid? (C6) ghi tại bridge; truy ngược HĐ→plan qua Line
⚠️ đường tắt PE → Contract KHÔNG QUA KHKK vẫn sống (Q11 mặc định = cảnh báo mềm) dry-run phải chấp nhận 2 đường song song cho tới khi owner nâng cứng Q11

Pattern tái dùng cite được: (1) pull-model picker ListApprovedPurchaseEvaluationsQuery:173 — nhân bản đổi vị ngữ && chưa-có-plan-sống cho màn "PE chờ lập KH" (W2 spec cũ); picker tạo-HĐ đổi nguồn thành plan DaDuyet (hoặc giữ PE + banner có/không plan — Q11). (2) Multi-entity create + self-committing codegen atomic :78-82 comment + loop. (3) Auto-Lines từ winners :56-62,:82-91.


§3 — Q3: GĐ4 Hdc_* trang thật cần gì

🔴 SỬA GROUNDING đề bài (2 chỗ):

  1. Hdc_* KHÔNG phải 6-leaf {WfView/List/Create/Pending/Approved/Deleted} — đó là cấu trúc Khkk_* (DbInitializer.cs:1779-1786). Hdc_* thật = 7 leaf theo ContractType, 1 leaf/loại, seed loop DbInitializer.cs:1862-1867 (tree.Add(("Hdc_"+code, label+" - Bảng cứng", MenuKeys.HopDongCung, ...))), label "01. HĐ Thầu phụ - Bảng cứng"…"07." (:1980-1986), staticMap → /coming-soon?stage=4&t=01..07 (fe-user Layout.tsx:63-69). Owner tự vẽ cấu trúc này @S159-đợt4 — KHÔNG mirror mù bộ 6-leaf.
  2. Enum KHÔNG có "KyCung" — grep KyCung toàn src = 0 hit. Phase 8 = DangDongDau [LEGACY] (ContractPhase.cs:23), 9 = DaPhatHanh terminal SỐNG (:24). "Bản cứng" chưa từng là phase.

Đã có gì cho GĐ4 (đo): DaPhatHanh = terminal V2 gen-mã (ContractWorkflowService.cs:366-385) · AttachmentPurpose.ScannedSigned=2/SealedCopy=3 (ContractAttachment.cs:8-9) upload không phase-guard ⇒ up ĐƯỢC sau terminal · Contract.cs 0 field bản-cứng.

Đề xuất TỐI THIỂU cho dry-run (options — review chốt):

  • (A) 0-migration DERIVE ⬅ đề xuất chính: mỗi leaf Hdc_<Code> → trang list GET /contracts?type=N&phase=9 (param phase CÓ SẴN ContractsController.cs:20, FE đã chứng minh dùng được: leaf Ct_*_Approved/my-contracts?type=N&phase=9 Layout.tsx:141); trạng thái bản cứng = DERIVE attachments.Any(Purpose==SealedCopy) → badge "Đã lưu bản cứng / Chưa"; hành động = upload ScannedSigned/SealedCopy + enum-extend InitialSigned=4 ký-nháy (int column, 0-mig — tiền lệ PE ComparisonTable=4). List DTO hiện KHÔNG trả attachments (ContractListItemDto 12 field ContractFeatures.cs:323-327) ⇒ cần +1 cột derive hasSealedCopy trong query (sửa DTO, 0-mig).
  • (B) field mỏng trên Contract: HardCopyStatus int? (+HardCopyNote) — 1 mig AddColumn (Mig 70+, bảng giữ 89) khi owner muốn trạng thái TƯỜNG MINH (in/ký/đóng dấu/lưu/chuyển phát per bước b.19-21) thay vì derive từ attachment.
  • (C) entity ContractHardCopy riêng — KHÔNG khuyến nghị cho dry-run: b.19-21 là 3 thao tác trên 1 HĐ đã phát hành, không có vòng đời duyệt riêng (giấy đã ký xong); bảng mới = màn admin + FK + configuration thừa.

Bind 7 leaf (phương án A): Hdc_<Code>/hard-copies?type=<N> (page mới) hoặc rẻ nhất tái dùng /my-contracts?type=N&phase=9&hdc=1 biến thể hiển thị. Route mới = 4-place mirror ×2 app (App.tsx route fe-user/src/App.tsx:52-65 khuôn + Layout staticMap :63-69 đổi khỏi coming-soon + page + types) — khuôn MyContractsPage.tsx ĐÃ đọc đủ type/phase/deleted params (:29,:34-35) + tự rẽ /contracts/deleted (:41-46) ⇒ mirror được, page ~300-400 LOC/app theo khuôn.

Mirror khuôn 6-leaf Ct_ được không?* — Về CƠ CHẾ (regex resolvePath Layout.tsx:132-143 + query-param page) = ĐƯỢC và NÊN; về HÌNH DẠNG menu = KHÔNG (owner đã chốt 7-leaf-per-type cho Hdc). Nếu sau này owner muốn Pending/Approved trong Hdc thì nới regex Hdc_([^_]+)_(...) — hiện KHÔNG tự đẻ key mới (gotcha #84 seeder-only + đổi cấu trúc menu = hỏi owner).

Authz lớp menu: Hdc_* leaf + root HopDongCung đều NGOÀI MenuKeys.All (MenuKeys.cs:37-42 — CỐ Ý, comment ghi rõ) ⇒ 0 policy Hdc_*/HopDongCung.* tồn tại; per-action authz cho trang Hdc phải dùng policy root Contracts.{Action} (root ∈ All :168) — tiền lệ đúng khuôn: GET /contracts/deleted dùng Contracts.Read (ContractsController.cs:29).


§4 — Q4: "DRY-RUN" về mặt kỹ thuật (prod đang có 37 NCC / 17 user / 62 dự án seed / 7 HĐ V1 thật)

Đề xuất: phương án LAI "dự án test riêng", KHÔNG cờ IsDryRun.

Phương án Đánh giá Chứng
(i) Dự án test riêng ZZTEST + 1-2 NCC test ⬅ đề xuất cho GĐ3+GĐ4 Mã HĐ dry-run nằm CHUỖI SEQUENCE RIÊNG — prefix codegen = {ProjectCodeYear}/{typeCode}/SOL&{SupplierCode} (ContractCodeGenerator.cs:33), seq per-prefix (:40-53) ⇒ KHÔNG ăn số của dự án thật. 2 loại framework scope=Year vẫn tách nhờ SupplierCode test. Dự án test NGOÀI danh sách seed ⇒ soft-delete xong KHÔNG resurrect (#75/#76 chỉ áp per-code trong seed SeedRealMasterDataAsync ungated). Tạo qua UI CRUD, 0 code ContractCodeGenerator.cs:33,:40-53
(ii) Cờ IsDryRun — KHÔNG khuyến nghị +1 mig + PHẢI sửa MỌI list/inbox/picker query (blast hàng chục handler — đúng lớp lỗi cardinality S87/S88 "sửa write-site sót read-site") + FE filter ×2 app. Đắt hơn giá trị 1 đợt dry-run bài học feedback_cardinality_change_grep_consumers
(iii) Cứ dùng thật ĐÚNG cho KHKK GĐ2 (7 bảng mới 0-data — mỗi phiếu đầu đều là "thử", xóa mềm sạch); SAI cho Contract GĐ3 (7 HĐ V1 prod thật chạy nhánh V1 ContractWorkflowService.cs:97 comment + 37 NCC thật trong picker + mã ăn sequence thật nếu chọn dự án thật)

Rollback path (soft-delete sẵn có + 2 hố):

  • Cơ chế nền: soft-delete qua interceptor (Remove→IsDeleted, lớp #81-EXT indirect) + global HasQueryFilter(!IsDeleted) — bằng chứng Contract có filter: ListDeleted phải IgnoreQueryFilters().Where(IsDeleted) (ContractFeatures.cs:372-373 + LUẬT comment :356-360). Màn "Đã xóa" chỉ-xem ĐÃ CÓ cả Contract (arc-2) lẫn PE (S155).
  • 🔴 HỐ 1 — HĐ dry-run chạy trọn vòng KHÔNG XÓA ĐƯỢC qua API: guard DeleteContractCommand = Phase >= DangInKy (ContractFeatures.cs:632-633) là so sánh SỐ trên enum ⇒ chặn cả DaPhatHanh=9, ChoDuyet=10, TraLai=98, TuChoi=99 (chỉ xóa được phase 2/3/4). Đường vòng tồn tại nhưng xấu: Admin manual override set targetPhase=DangSoanThao (ContractWorkflowService.cs:191-198) rồi DELETE. ⇒ dry-run phải CHẤP NHẬN HĐ test DaPhatHanh nằm lại (lọc bằng dự án ZZTEST) hoặc dùng đường vòng admin — ghi vào checklist wave cuối, review chốt.
  • HỐ 2 — phiếu KHKK/PE DaDuyet cũng không xóa (KHKK spec allow-list DangSoanThao|TuChoi mirror PE :1404) — cùng cách xử lý.
  • Sequence KHÔNG rollback — gap số chấp nhận được, chính comment repo nói vậy (ContractFeatures.cs:113-115 "wasted... Acceptable").
  • Notification dry-run bắn cho user THẬT (in-app LogTransitionAsync→NotifyAsync ContractWorkflowService.cs:407-427) — không tắt được per-record; dặn trước team.

Rủi ro lẫn data thật (liệt kê để checklist): (1) record test hiện CHUNG list với data thật (IDOR theo role; admin thấy hết) → convention "mọi record dry-run thuộc ZZTEST" + tên phiếu tiền tố; (2) CẤM đặt Code dự án/NCC test TRÙNG danh sách seed (twin FLOCK-01 S148 — seed re-fill theo Code); (3) pe.ContractId idempotency — mỗi vòng thử cần PE test MỚI (:59-60); (4) file upload thật nằm wwwroot/uploads — delete attachment có cleanup, HĐ xóa mềm thì file ở lại (rác chấp nhận); (5) menu đã public 13/13 role + authz trần ⇒ trong lúc dry-run ai cũng bấm được nút ghi trên HĐ THẬT — sang §5. Ràng buộc đo đạc: SSH prod chết khi mở SQL-client (S134/S148 re-confirm) ⇒ phép đo prod trong §7 ưu tiên curl API làm chân chính; sqlcmd chỉ chạy local/Dev hoặc đưa lệnh cho người có RDP.


§5 — Q5: AUTHZ khoanh vùng cho dry-run (options — REVIEW CHỐT, không tự quyết)

Hiện trạng lộ (menu public 13/13 role ❹ + [Authorize] trần): bất kỳ user đăng nhập nào gọi được 22 endpoint ghi của ContractsController (:13 class-trần; ngoại lệ duy nhất /deleted có policy :28-29). Phân nhóm theo độ nguy hiểm:

Nhóm Endpoint Nguy hiểm THẬT (đã re-verify service)
🔴 Workflow POST {id}/transitions (:61-66) Reject-trước-guard ContractWorkflowService.cs:49-66: nhánh Reject KHÔNG check role, KHÔNG check phase-nguồn ⇒ user bất kỳ TraLai/TuChoi bất kỳ HĐ ở bất kỳ phase — kể cả HĐ THẬT DaPhatHanh (đặt TuChoi=99 lên HĐ đã phát hành). Nhánh Approve NGƯỢC LẠI có guard (OR-of-N 403 :259-265; trình đòi Drafter/DeptManager :73-78)
🔴 CRUD POST / · PUT {id} · DELETE {id} Create = tạo HĐ rác + ĂN SEQUENCE thật ngay lúc tạo (gen mã tại create ContractFeatures.cs:113-118) nếu chọn dự án thật; Update draft KHÔNG check drafter (:151-157) ⇒ sửa GiaTri nháp người khác; Delete không role-check (chỉ phase-guard :632-633)
🟠 Attachment POST/DELETE {id}/attachments Không phase-guard (§1) ⇒ thay/xóa file scan trên HĐ THẬT đã phát hành; delete cleanup file vật lý
🟠 Details 15 endpoint add/update/delete details (:123-226) Sửa ruột chi tiết 7 loại HĐ mọi lúc (handler có lock-edit 17 handler phase≠DangSoanThao? — lock S8/S9 áp Contract handlers cũ; details HĐ đã qua guard này, mức rủi ro còn lại là nháp người khác)
🟡 Comment POST {id}/comments Spam/ghi đè ngữ cảnh — nhẹ
🟡 Form-export FormsController (api/forms, cũng [Authorize] trần :11): render/export-pdf (:25,:32) = STATELESS đọc-render, rủi ro thấp; POST templates (:42) = ghi template dùng chung — cùng lỗ class-trần, NGOÀI ContractsController đo 07-29

Khuôn per-action CÓ SẴN trong repo (đối chiếu): (a) Master: SuppliersController.cs:67-99 4× [Authorize(Policy="Suppliers.Update")]; (b) chính ContractsController: /deleted dùng Contracts.Read :28-29; (c) S155 kết luận cho PE: root-policy dùng được NGAY vì root ∈ MenuKeys.All — áp y nguyên cho Contract (Contracts ∈ All MenuKeys.cs:168 ⇒ 4 policy Contracts.{Read,Create,Update,Delete} ĐÃ đăng ký runtime, 0 key mới, 0 mig). 🔴 Sửa grounding đề bài #3: "khuôn PE controller per-action 3 tầng" KHÔNG tồn tại — PurchaseEvaluationsController cũng class-trần (S155 đo :15); khuôn per-action thật nằm ở Master + /deleted.

OPTIONS cho review chốt (chi phí ↑ dần):

  • O-A GIỮ NGUYÊN + chấp nhận có ý thức (đúng O-3 "hợp đồng cứ từ từ" + tinh thần dry-run mở): rủi ro tập trung 2 ô 🔴; giảm nhẹ bằng quy ước ZZTEST + backup DB trước đợt dry-run (scripts/backup-sql.ps1). 0 file.
  • O-B PER-ACTION ROOT-POLICY 22 endpoint ghi (Contracts.Create/Update/Delete theo verb): 1 file controller, 0 key mới, tiền lệ /deleted. ⚠️ kèm bắt buộc: cấp CanCreate/CanUpdate/CanDelete cho role tham gia dry-run QUA SEEDER (gotcha #84, KHÔNG SQL tay) TRƯỚC deploy — không thì 403 im lặng (gotcha #44) làm chết chính dry-run. Chú ý: policy chặn theo MENU-permission, user được cấp vẫn đụng được HĐ THẬT ⇒ O-B thu hẹp AI bấm, không thu hẹp BẤM VÀO ĐÂU.
  • O-C = O-B + vá tối thiểu Reject-trước-guard: thêm 1 điều kiện fromPhase==ChoDuyet cho nhánh Reject (ContractWorkflowService.cs:49) — đóng đúng ca "TuChoi HĐ DaPhatHanh", không đổi hành vi hợp lệ nào (Reject hợp lệ chỉ có nghĩa khi đang ChoDuyet). Chạm service HĐ ⇒ cần anh gật riêng (O-3).
  • O-D khoanh DATA: trước dry-run, đo phase 7 HĐ V1 thật (curl admin GET /api/contracts — SSH-SQL không dùng được) — nếu cả 7 đã DaPhatHanh thì O-C đóng được ~hết mặt tấn công transitions lên chúng. UNCERTAIN hiện tại: phase thật của 7 HĐ V1 chưa đo được từ đây — khai đúng trạng thái, không đoán.

§6 — Q6: WAVE-STRUCTURE KỸ THUẬT GĐ3+GĐ4 (nối W1-W5 cũ; đánh số tiếp W6→W9)

Nguyên tắc giữ nguyên spec cũ: mỗi wave deploy độc lập, không để nửa tính năng chết trên prod. Đơn vị độ lớn = số file + có/không mig (KHÔNG ước giờ). Số bảng nền hôm nay: 89 sys.tables (88 app + history); mig cuối trên đĩa = 20260727033522_AddPeAllowApproverDeletemig kế tiếp = 69 (W1 KHKK dùng), GĐ3/GĐ4 dưới đây 0 mig trừ W8 nếu owner chọn (B).

Wave Tên Deploy riêng? Người dùng THẤY gì Mig? Reuse gì Độ lớn (file)
W6 Đường ống HĐ V2 (điều kiện tiên quyết GĐ3) có — 0 UI mới, HĐ V2 hết "vô hình" Approver V2 xem được + thấy trong inbox HĐ đang chờ mình (nay: không) 0 mở rộng GetEligiblePhases (ContractFeatures.cs:334-351) + inbox PhaseActorRoles (:421-430) theo khuôn PE ResolveV2InboxIdsAsync; guard trình :73-78 nới cho Procurement/drafter-của-HĐ (lớp 403-PMH) 2-3 file BE (ContractFeatures + service guard [+ DTO nếu trả currentApproval])
W7 Cầu KHKK→HĐ + pin V2 (= W5 spec cũ, đổi tên cho liền mạch) 🔴 CHẶN owner (chạm Contract, Q6/Q11) Nút "Tạo HĐ" từ KHKK DaDuyet; HĐ sinh ra chạy trạm V2 thật (hết kẹt ConflictException :115-116) 0 CreateContractFromEvaluationFeatures.cs (+param ApprovalWorkflowId, giá từ ApprovedAmount, ContextNote lệch, ghi Line.ContractId) + body record PurchaseEvaluationsController.cs:379-382 + 1 nút FE ×2 app 2 BE + 2 FE
W8 GĐ4 bản cứng Hdc_* (7 leaf hết ComingSoon) 7 trang "…- Bảng cứng" list HĐ phase=9 + badge trạng thái bản cứng + upload ScannedSigned/SealedCopy/InitialSigned 0 nếu (A); 1 mig AddColumn nếu owner chọn (B) HardCopyStatus param phase (ContractsController.cs:20) + khuôn MyContractsPage.tsx:29-46 + enum-extend AttachmentPurpose (ContractAttachment.cs:5-11) + staticMap Layout.tsx:63-69 re-point ~6-8 FE (page+types+App.tsx+Layout ×2 app) +1-2 BE (DTO thêm cờ derive)
W9 Authz khoanh vùng (theo option §5 review chốt) (không thấy gì nếu làm đúng) — hoặc 403 sạch thay vì cho phép âm thầm 0 khuôn /deleted :28-29 + SuppliersController.cs:67-99; + seeder cấp quyền (gotcha #84) 1 BE controller + 1 DbInitializer (+1 service nếu O-C)

Thứ tự & phụ thuộc: W6 phải TRƯỚC W7 (không thì HĐ pin V2 xong vẫn không ai duyệt được — đúng lỗi "cơ chế đúng, đường đi không có"). W8 độc lập hoàn toàn với W6/W7 (chỉ đọc HĐ DaPhatHanh) ⇒ chạy song song được — hữu ích cho dry-run vì owner thấy GĐ4 sớm. W9 nên đi trước hoặc cùng buổi mở rộng người bấm; nếu review chọn O-A thì W9 = 0 file (ghi quyết định vào plan cha). Kết nối W1-W5 cũ: W1-W4 (KHKK) không đụng Contract ⇒ chạy trước/song song thoải mái. W5 cũ ≡ W7 ở đây (đổi nhãn để khỏi 2 tên cho 1 việc — plan cha nên chọn 1 cách gọi).


§7 — Q7: ACCEPTANCE ĐO ĐƯỢC per wave (khuôn H5: pin N ≥ x + TÊN test cụ thể; neo TÍNH-CHẤT tránh literal tự-lão-hoá)

⚠️ Neo số test: KHÔNG viết "562 + N" như spec cũ (mốc 562 tự lão hoá + N thả tự do = 0 bit). Viết: dotnet test SolutionErp.slnx PASS 0 fail, và số test SAU ≥ số test TRƯỚC + x, và x test dưới đây tồn tại ĐÚNG TÊN. Baseline đo NGAY TRƯỚC wave bằng chính lệnh đó. Phép prod theo khuôn 3-chân-kiềng (cicd): ① CI run PASS · ② artifact/bundle prod verify byte-level (curl-grep, #77) · ③ dữ liệu prod đúng (curl API — KHÔNG sqlcmd qua SSH, chết client S134/S148).

W6 — Đường ống HĐ V2 (≥4)

  1. Test-before, class ContractV2PipelineTests (tests/SolutionErp.Infrastructure.Tests/Application/), ≥3 tên: Inbox_ApproverCapDangCho_ThayHopDongChoDuyet · Inbox_CapKhac_KhongThayHopDong (ca ÂM) · View_ApproverV2_KhongPhaiDrafter_XemDuocHopDongChoDuyet (nay ForbiddenException ContractFeatures.cs:488-489).
  2. dotnet test PASS 0 fail; số test ≥ baseline + 3.
  3. Phép prod ③: đăng nhập user approver-V2 (không Admin) → curl GET /api/contracts/inbox trả ≥1 HĐ đang ChoDuyet mà user là Level hiện tại; cùng user GET /api/contracts/{id}200 (trước W6: inbox 0 + detail 403).
  4. Ca ÂM giữ nguyên đường cũ: user KHÔNG thuộc Level nào và không Drafter → GET /{id} vẫn 403 (chứng không mở toang bằng cách bỏ guard).
  5. Phép prod ①: CI run xanh trên commit W6.

W7 — Cầu KHKK→HĐ + pin V2 (≥4)

  1. Test-before ContractSigningPlanBridgeTests: Bridge_CoPlanDaDuyet_GiaTriHopDong_BangApprovedAmountCuaDungNCC · Bridge_KhongCoPlan_GiuSumBaoGiaPE_VaCanhBaoMem · Bridge_PinApprovalWorkflowV2_HopDongTrinhDuoc_KhongConConflictException · Bridge_GhiNguoc_LineContractId_DungTungWinner.
  2. dotnet test PASS 0 fail; số test ≥ baseline + 4; test cũ CreateContractFromEvaluationMultiWinnerTests (đã tồn tại trên đĩa) vẫn xanh — chống hồi quy multi-winner.
  3. TÍNH-CHẤT đo được (thay literal): với 1 PE có W winner và plan DaDuyet, POST /{peId}/create-contract trả đúng W contractId, và mỗi HĐ có giaTri == ApprovedAmount của Line cùng supplierId; tổng W HĐ == SUM(ApprovedAmount) toàn plan.
  4. Phép prod ③ (curl, dự án ZZTEST): HĐ vừa tạo → POST /{id}/transitions {targetPhase:10, decision:1} bởi Drafter → 204 (không 409 ConflictException); đọc lại GET /{id} thấy phase=10 + currentApprovalLevelOrder=1.
  5. Changelog có ContextNote so-lệch khi giá KH ≠ SUM-PE (đọc GET /{id}/changelogs, chuỗi chứa cả 2 số).

W8 — GĐ4 bản cứng (≥4)

  1. Test ContractHardCopyTests: Danhsach_BangCung_ChiTraHopDong_PhaseDaPhatHanh · CoBanCung_Derive_TuAttachment_SealedCopy (+ nếu chọn (B): HardCopyStatus_MacDinh_Null_KhongVoLuoc).
  2. dotnet test PASS 0 fail; số test ≥ baseline + 2 (hoặc +3 nếu (B)).
  3. FE: npm run build PASS ×2 app; 7 leaf Hdc_* KHÔNG còn trỏ /coming-soon — grep Hdc_ trong fe-user/src/components/Layout.tsx + fe-admin/.../Layout.tsx trả 0 hit chuỗi coming-soon trên các dòng Hdc_.
  4. Phép prod ②+③: sau deploy, curl-grep bundle prod chứa route mới (byte-level, #77 — cache≠deploy); rồi curl GET /api/contracts?type=1&phase=9 trả list (200) và mọi item có phase==9.
  5. Ca ÂM: leaf Hdc của loại KHÔNG có HĐ phát hành nào → trang rỗng có empty-state, KHÔNG lỗi 500.

W9 — Authz (≥4; nếu review chọn O-A thì wave này = ghi quyết định, các acceptance dưới KHÔNG áp)

  1. AuthorizePolicyRegressionTests (ĐÃ TỒN TẠI tests/SolutionErp.Infrastructure.Tests/Api/) vẫn xanh — nó bắt ca policy trỏ menu-key ngoài MenuKeys.All (⇒ chứng Contracts.* hợp lệ, không đẻ 500).
  2. Test mới ContractsControllerAuthzTests: MoiEndpointGhi_CoAuthorizePolicy (reflection đếm, neo TÍNH-CHẤT: mọi action [HttpPost|HttpPut|HttpDelete] của ContractsController[Authorize(Policy=...)] — không neo con số 22) + User_ThieuQuyen_GoiPost_Tra403 (ca ÂM, KHÔNG 500/200).
  3. dotnet test PASS 0 fail; số test ≥ baseline + 2.
  4. Phép prod ③ sau deploy: user dry-run ĐÃ được seeder cấp quyền → POST /api/contracts 201; user chưa cấp → 403 (không 500). ⚠️ chạy ngay sau deploy: 403 nhầm cho người đang dry-run = tự chặn chính mình (gotcha #44).
  5. Nếu chọn O-C: Reject_KhiHopDongDaPhatHanh_BiChan (ca ÂM cho lỗ ContractWorkflowService.cs:49-66) + verify HĐ thật không đổi phase.

Gate 5-anchor (ràng buộc run.md) — anchor gợi ý cho mỗi wave: W6 = ContractFeatures.cs:334-351 · :421-430 · :488-489 · ContractWorkflowService.cs:73-78 · ContractsController.cs:38-40. W7 = CreateContractFromEvaluationFeatures.cs:53-60 · :68-71 · :88-90 · :145 · ContractWorkflowService.cs:115-116. W8 = ContractAttachment.cs:5-11 · DbInitializer.cs:1862-1867 · Layout.tsx:63-69 · MyContractsPage.tsx:29-46 · ContractsController.cs:16-24. W9 = ContractsController.cs:13 · :28-29 · SuppliersController.cs:67 · MenuKeys.cs:163-181 · ContractWorkflowService.cs:49-66.