Files
solution-erp/.claude/workflows/runs/2026-07-28-S157-ke-hoach-ky-ket-hd/spec-ke-hoach-ky-ket-hd-28-07-2026.md
2026-07-29 15:23:09 +07:00

50 KiB
Raw Blame History

SPEC — "KẾ HOẠCH KÝ KẾT HỢP ĐỒNG" (khúc còn thiếu giữa Duyệt NCC và HĐ NTP/NCC)

Run: 2026-07-28-S157-ke-hoach-ky-ket-hd · lead-written sau verify (H21 ①: engine propose-only ⟂ lead ghi spec) Nguồn propose: sub-invest-kehoach-1.md §A+§B (23.085 B, /fable-real investigator-codebase Fable) Lead verify: 4 claim load-bearing đo lại độc lập trên đĩa — xem run.md S4. §C+§D dưới đây lead viết. Trạng thái: CHỜ /fable-clone reviewer chấm + 5 câu owner chưa trả lời (không chặn Wave 1-2).


① TÍNH NĂNG / ĐỀ BÀI

1.1 Vấn đề

Giữa Duyệt NCC (module PE, kết thúc PE.DaDuyet) và Hợp đồng NTP/NCC (module Contract, bắt đầu DangSoanThao) có một khúc quy trình KHÔNG TỒN TẠI Ở CẢ HAI TẦNG:

  • Không có trong code — repo 0-hit shopdrawing|TVGS|duyệt mẫu.
  • Không có trong quy trình ISO đang ban hànhQT TRINH KY HOP DONG TP-NCC.docx bảng 2 đi THẲNG bước 1 "Lựa chọn NTP/NCC" → bước 2 "Soạn thảo hợp đồng", 0 bước trung gian.
  • Chỉ có trên sơ đồ PDF 01- Quy trinh hien tai --- 14_2026 (bản mới), các bước b.7→12.

🔴Spec này vừa là spec phần mềm, vừa là ĐỀ XUẤT BỔ SUNG QUY TRÌNH ISO. Anh cần duyệt ở cả 2 tư cách.

1.2 Tên + mục đích (owner đặt)

anh: "Bước này là bước 'đề xuất giá trị ký kết hợp đồng'" · sau đó gọi là "Kế hoạch ký kết Hợp đồng". 🔄 OWNER CHỐT NHÃN @S159 (2026-07-29, AskUser ❶): tên phiếu/menu = "Đề xuất ký kết hợp đồng" — mọi label VN trong spec đọc theo tên này (tên bảng/class English ContractSigningPlan* GIỮ NGUYÊN). Cùng lượt anh chốt: ranh GĐ2 = b.7→12 (b.12 chốt con số ∈ GĐ2) · GĐ3 = b.13→18 tận dụng Contract V2 · GĐ4 = b.19→21 + ký cứng upload-only · b.7 email BỎ khỏi scope, thay bằng tạo-phiếu-từ-PE-DaDuyet (= chính thiết kế W2 — Q8 ĐÓNG: out-of-scope).

Đây là phát biểu MỤC ĐÍCH: đầu ra của khúc này = MỘT CON SỐ CÓ PHÊ DUYỆT (giá trị ký kết đề xuất). b.8-9 (duyệt mẫu / shopdrawing với TVGS) = CĂN CỨ; b.10-12 = chuỗi duyệt chính con số đó. ⇒ Vật mang trong hệ thống = một PHIẾU, không phải thư mục đính kèm.

1.3 Bối cảnh kiến trúc — 2 phát hiện nền (lead đo, invest xác nhận từ nguồn gốc)

(A) 7 phase [LEGACY] của ContractPhase CHÍNH LÀ quy trình ISO 9 bước, mã-hoá cứng. Khớp 1:1 đúng thứ tự: DangChon=1↔Lựa chọn NTP/NCC · DangSoanThao=2↔Soạn thảo · DangGopY=3↔Góp ý · DangDamPhan=4↔Đàm phán · DangInKy=5↔In HĐ · DangKiemTraCCM=6↔Kiểm tra CCM · DangTrinhKy=7↔Duyệt BOD · DangDongDau=8↔Đóng dấu · DaPhatHanh=9↔Phát hành. Post-Mig 21/S17 thay bằng ChoDuyet=10 + con-trỏ chạy trên workflow V2 admin-config ⇒ chuyển từ cứng 9 bước sang cấu-hình N bước. Khung V2 sinh ra CHÍNH ĐỂ biểu diễn quy trình ISO này.

(B) Khoảng trống nằm ĐÚNG giữa ISO-1 và ISO-2 (xem 1.1).

1.4 🔴 OWNER ĐÃ CHỐT (2026-07-28) — ràng buộc cứng của spec

# Anh nói (verbatim) Hệ quả spec
O-1 "Đúng chính xác, cấu trúc trình ký giống, chỉ khác nội dụng thôi." TÁI DÙNG khung duyệt V2 (ApprovalWorkflow>Step=Phòng>Level=NV, OR-of-N, LevelOpinions). 0 bảng approval mới. Dựng mới = NỘI DUNG phiếu. ⇒ trục tranh luận Q2 hôm qua ĐÓNG.
O-2 "Cái này để tham khảo thôi, ko vấn đề j, đa số là trễ." (SLA) KHÔNG SLA-engine, không auto-approve-on-timeout, không cảnh báo chặn, không wave nào cho SLA. Giữ hiển thị deadline. Lỗ AddDays(7) hardcode = ghi nhận, ngoài scope.
O-3 "Chỗ hợp đồng cứ từ từ nhé." Lỗ hổng an ninh ContractWorkflowService.cs:48-66 + controller HĐ class-trần: NGOÀI SCOPE, treo có chủ đích. Wave nào chạm module Contract phải hỏi lại anh.

1.5 Quy trình chi tiết — 6 bước (b.7→12)

Role hệ thống có thật (AppRoles.cs:5-16): Procurement (PMH) · CostControl (CCM) · Director+AuthorizedSigner (BOD/NĐUQ) · ProjectManager/Drafter (BCH) · HrAdmin · Admin. 🔸 "BCH dự án X" KHÔNG có model (Department.ManagerUserId 0-row) ⇒ né bằng: BCH = người tạo phiếu (CreatedBy); PMH/CCM/CEO = đích danh user trong Level của workflow V2 (đúng triết lý V2, 0 model mới).

b Việc Ai Đầu vào Đầu ra Chuyển bước Nhánh lỗi
7 PMH chuyển thông tin cho BCH Procurement PE DaDuyet (winners IsWinner, giá SUM Quote.IsSelected, HoSoLink) BCH biết việc Pull-model: màn "PE đã duyệt — chờ lập KH" lọc Phase==DaDuyet && chưa có plan (khuôn CreateContractFromEvaluationFeatures.cs:173) + notify in-app. Email ra ngoài = ngoài scope (SMTP TODO toàn hệ thống) PE thiếu winner → không hiện
8 Duyệt mẫu + shopdrawing với TVGS BCH nhập hộ (TVGS ngoài hệ thống) Mẫu + shopdrawing từ NTP/NCC (offline) Danh mục CĂN CỨ trong phiếu: {Kind, NCC, Status, TvgsName, ngày, scan} Vòng đời per-item: ChuaNop→DaNop→TvgsDuyet|TvgsBac; TvgsBac→DaNop (nộp lại) — 🔴 sơ đồ KHÔNG vẽ vòng lặp này, thực tế có Item bị bác = đổi status + note, KHÔNG đụng state machine phiếu
9 Tổng hợp + so sánh giá → ĐỀ XUẤT giá trị BCH (Drafter phiếu) Căn cứ b.8 + báo giá PE (snapshot PeReferenceAmount lúc tạo — nguyên tắc freeze Mig 67) Phiếu đủ dòng giá đề xuất per-winner + file so sánh → TRÌNH Guard trình: đủ dòng giá per-winner (mirror completeness-guard PE PurchaseEvaluationWorkflowService.cs:180-212); căn cứ chưa đủ TvgsDuyetcảnh báo mềm (tiền lệ S62) — O-Q1 owner quyết mềm/cứng Ghi chú sơ đồ "hồ sơ sai sót → gửi mail làm rõ" = comment + item TvgsBac
10 PMH kiểm tra, xác nhận giá trị Procurement (đích danh Level) Phiếu ChoDuyet Bước 1 Duyệt → Bước 2; ý kiến vào LevelOpinions (UPSERT khuôn ContractWorkflowService.cs:292-316) OR-of-N trong Cấp (ApprovalWorkflow.cs:81-94) Trả lại → TraLai, hoặc 4 return-mode per-level (cờ CÓ SẴN ApprovalWorkflowLevel.cs:116-125)
11 CCM kiểm tra CostControl Bước 2 Duyệt → Bước 3 như trên như trên
12 CEO ký HOẶC CCM đóng dấu Director / cơ chế kết thúc sớm Bước 3 (hoặc kết thúc tại Bước 2) 🔴 GIÁ TRỊ KÝ KẾT CHỐT per-winner (ApprovedAmount) ghi tại finalize choke-point (mirror ApplyApprovedPriceOnFinalize PE :973-1002 + RULE :1008 mọi nhánh set DaDuyet phải gọi helper) Terminal DaDuyet Từ chối → TuChoi; trả lại → TraLai

Rẽ nhánh b.12 "hoặc" — 4 đường ĐỀU CÓ SẴN trong khung V2, 0 migration: (i) CeoApprovalThreshold + CCM tích "duyệt done miễn CEO" (PE :892-929, fail-closed: đòi threshold + role CostControl + giá < ngưỡng) · (ii) AllowApproverFinalize per-slot (PE :870-890) · (iii) AllowApproverSkipToFinal per-slot, CEO vẫn ký thật (Contract :320-352) · (iv) 2 workflow (có/không Step CEO) admin pin. 🔸 Điều kiện rẽ THẬT: cả 3 nguồn IM LẶNGQ3 owner; chọn đường nào cũng không đổi schema.

1.6 b.8-9 là bảng con trong phiếu — không phải phiếu con, không phải module riêng

Lý do: (1) owner phát biểu b.8-9 = căn cứ, không phải đối tượng duyệt riêng; (2) PDF chỉ vẽ MỘT chuỗi duyệt (10-12) cho cả khúc — phiếu con = đẻ chuỗi duyệt thứ 2 không có trên giấy; (3) vòng đời mẫu/shopdrawing là per-item status, không phải state machine phiếu; (4) TVGS ngoài hệ thống ⇒ chỉ cần ghi vết. Module riêng chỉ đáng khi mẫu/shopdrawing cần quản lý độc lập với gói thầu — chưa có yêu cầu.

1.7 Mâu thuẫn / im lặng giữa 3 nguồn (bảng tra — KHÔNG tự quyết)

# Điểm ISO doc PDF FO-002.01 Xử lý
1 Khúc b.7→12 KHÔNG CÓ CÓ (duy nhất) không nói Spec = đề xuất bổ sung ISO
2 SLA khúc 7→12 im lặng im lặng im lặng O-2: tham khảo, không hoà giải
3 SLA khúc HĐ 07/07/07/07/03/01 ngày 3d + 7-10d 🔴 "mỗi bộ phận 01 ngày, quá hạn XEM NHƯ ĐÃ THÔNG QUA" 3 nguồn 3 kiểu; auto-pass mâu thuẫn mô hình duyệt-chặn V2. O-2 ⇒ chỉ ghi nhận
4 Điều kiện rẽ b.12 "hoặc", 0 điều kiện không nói Q3 owner
5 Nhánh trả lại 10/11/12 không vẽ không vẽ không vẽ Hệ thống có sẵn TraLai + 4 return-mode
6 Bypass Chủ đầu tư → GĐ không nói không nói = Contract.BypassProcurementAndCCM (đã có) — KHÔNG áp phiếu KH
7 Checklist kiểm tra per-phòng (PRO: điều khoản/thanh toán/rủi ro pháp lý · CCM: giá vs NS) mô tả lời không CÓ (checkbox từng mục) Hệ thống chỉ có LevelOpinions.Comment tự do ⇒ GAP, O-Q2 owner có số hoá checkbox không
8 Tên phòng/vai PRO/CCM/BOD/NĐUQ/HRA Procurement/CCM/CEO PB-DA/PRO/CCM/GĐ Khớp AppRoles0 mâu thuẫn
9 Chuỗi ký FO-002.01 (ĐỀ XUẤT→PRO→CCM→GĐ) ≈ ISO 5-7 ≈ b.9→10→11→12 CÙNG HÌNH Đối chứng GIẤY cho O-1

② CÁCH IMPLEMENT

2.1 ⚠️ BẪY SỐ MỘT — CẤM copy "Đề xuất" (Proposal) cho phần DUYỆT

ProposalFeatures.cs là twin nhỏ nhất (849 LOC BE) nên rất cám dỗ. Nhưng phần duyệt của nó phá OR-of-N: SelectMany flatten mọi Level xuyên Steps :427-429ElementAtOrDefault(order-1) lấy 1 row :433 → so đúng 1 ApproverUserId :439. Comment trong chính file tự thú: "Lite version: assume 1 step per workflow". Entity Proposal cũng chỉ có CurrentApprovalLevelOrder, KHÔNG có CurrentWorkflowStepIndex.

⇒ 2 người cùng Cấp (OR-of-N hợp lệ theo ApprovalWorkflow.cs:81-94) bị biến thành 2 nấc TUẦN TỰ (AND). Mà nhiều người cùng cấp chính là thứ tờ FO-002.01 cần.

🔴 Service PHẢI copy ContractWorkflowService.ApproveV2Async:217-394 (con-trỏ ĐÔI StepIndex+LevelOrder · GroupBy Order :246 · OR-of-N :259-260 · LevelOpinions UPSERT :292-316 · skipToFinal :320-352). Proposal chỉ làm khuôn cho CRUD / DTO / menu / FE.

Lead đã tự đọc ProposalFeatures.cs:420-445 xác minh — claim ĐÚNG, không phải suy diễn.

2.2 Schema — Mig 69 AddContractSigningPlans · 7 CreateTable, 0 ALTER bảng cũ

Down = DropTable ×7, reversible sạch. Convention: PascalCase EN · loose-Guid không FK vật lý sang PE/Supplier (convention PE, ghi ở Mig 49) · FK vật lý chỉ nội bộ module + sang bảng V2 dùng chung.

# Bảng Cột chính Ràng buộc
1 ContractSigningPlans (header) MaKeHoach? · PurchaseEvaluationId (loose+IX) · ProjectId (denorm lọc) · Phase int · ApprovalWorkflowId Guid? · CurrentWorkflowStepIndex int? · CurrentApprovalLevelOrder int? · DrafterUserId · SlaDeadline? · HoSoLink nvarchar(1000)? · GhiChu AuditableEntity + HasQueryFilter(!IsDeleted) (khuôn PurchaseEvaluationConfiguration.cs:84)
2 ContractSigningPlanLines (giá per-winner) PlanId FK Cascade · SupplierId loose · PeReferenceAmount decimal(18,2) SNAPSHOT lúc tạo · ProposedAmount · ApprovedAmount? (chỉ ghi tại finalize) · Note UNIQUE filtered (PlanId, SupplierId) WHERE IsDeleted=0 (gotcha #57)
3 ContractSigningPlanDossierItems (căn cứ b.8-9) PlanId FK Cascade · Kind int (MauVatLieu=1/Shopdrawing=2/Khac=99) · SupplierId? · Name · Status int (ChuaNop=0/DaNop=1/TvgsDuyet=2/TvgsBac=3) · TvgsName? · TvgsResultAt? · Note IX (PlanId)
4 ContractSigningPlanLevelOpinions PlanId + ApprovalWorkflowLevelId · Comment nvarchar(2000) · SignedAt · SignedByUserId · SignedByFullName UNIQUE (PlanId, LevelId); FK Cascade Plan + Restrict Level (mirror ContractLevelOpinions Mig 33)
5 ContractSigningPlanAttachments PlanId FK Cascade · FileName/Path/Size/ContentType · Purpose int · DossierItemId? khuôn ProposalAttachment.cs
6 ContractSigningPlanChangelogs PlanId · EntityType · EntityId · Action · PhaseAtChange · UserId · UserName · Summary · FieldChangesJson nvarchar(max) · ContextNote nvarchar(2000) có từ ngày 1 — tiền lệ PE Mig 60/61. 🔴 4 cột in đậm THÊM sau review (lens-schema C1): thiếu chúng thì LogTransitionAsync copy sang KHÔNG BIÊN DỊCH (ContractWorkflowService.cs:405 gọi changelog.LogWorkflowTransitionAsync) + mất index twin (PlanId, EntityType)
7 🆕 ContractSigningPlanApprovals (lịch sử transition) PlanId FK Cascade · FromPhase · ToPhase · Decision · Comment · ApprovalWorkflowLevelId? · ApprovedByUserId · ApprovedAt 🔴 THÊM sau review (lens-schema H1): service được lệnh copy CÓ ghi vào bảng nàyContractWorkflowService.cs:151 db.ContractApprovals.Add(...); twin PE PurchaseEvaluation.cs:137 + index (PeId, ApprovedAt). Thiếu ⇒ đoạn copy trỏ bảng KHÔNG TỒN TẠI
8 ContractSigningPlanCodeSequences CẮT 🔴 GỠ sau review (lens-schema T1): TÁI DÙNG WorkflowAppCodeSequence — bảng này đã dùng chung cho 4 module Office, phân biệt bằng Prefix namespaced ("DT/LR/2026" · "DT/OT/2026" · "DX/XE/2026"WorkflowAppCodeSequence.cs:8-11). Prefix "KHKK/{YYYY}" nhét vào = 0 va chạm. Tính atomic đến từ transaction SERIALIZABLE trong CodeGen service, KHÔNG từ việc bảng thuộc riêng module (ContractCodeSequence.cs:5 nói rõ) ⇒ lập luận "cắt = mất mã atomic" của bản đầu SAI, đã rút.

Tổng vẫn 7 bảng (thêm #7 Approvals, cắt CodeSequences). Down = DropTable ×7. Biến thể tối thiểu 6 bảng (bỏ #6 Changelogs) — KHÔNG khuyến nghị: Mig 60/61 chứng minh cần changelog để backfill. (Bản đầu đề xuất "tối thiểu 5, cắt #6+#7" — nay #7 cũ đã cắt sẵn, và lý do giữ #6 vẫn đứng.)

Cột chuỗi PHẢI khai độ dài (lens-schema C3 — thiếu thì EF sinh nvarchar(max), không index được, không dùng được trong filtered-unique): MaKeHoach 50 · GhiChu 2000 · Name 500 · Note 1000 · TvgsName 200 · Summary 1000 · ContextNote 2000 · SignedByFullName 200 IsRequired (khuôn ContractLevelOpinionConfiguration.cs:21-22) · FileName 500 / Path 1000 / ContentType 200.

+2 cột header THÊM sau review:

  • DepartmentId Guid? (lens-schema C5) — twin PurchaseEvaluation.cs:17. Thiếu nó thì vế DeptManager của guard trình không so được với gìbất kỳ ai mang vai DeptManager trình được phiếu của phòng khác. ApprovalWorkflowStep.DepartmentId chỉ là hint của quy trình, KHÔNG thay được phòng của phiếu.
  • ContractId Guid? trên Lines (lens-schema C6) — đường quay ngược Kế-hoạch → HĐ sau b.13; twin PurchaseEvaluation.ContractId:31 + index. HĐ sinh per-NCC, Lines cũng per-NCC ⇒ đây là chỗ tự nhiên.
  • 🔸 SlaDeadline? GIỮ nhưng khai rõ: FE hiển thị tham khảo, KHÔNG đăng ký SlaExpiryJob, KHÔNG có SlaWarningSent (O-2 anh chốt SLA chỉ tham khảo). Khai vậy để nó không rơi vào lớp "cột có mà 0 ai đọc" — người đọc nó là FE, không phải job.

Enum:

  • ApprovalWorkflowApplicableType +ContractSigningPlan = 10 — append-only. Lead verify: ApprovalWorkflow.cs:53-67 dừng ở TravelRequest = 9, slot 10 TRỐNG THẬT.
  • ContractSigningPlanPhase MỚI, giá trị sạch: DangSoanThao=1 / ChoDuyet=2 / DaDuyet=3 / TraLai=98 / TuChoi=99.
  • 🔴 DossierItemStatus PHẢI bắt đầu từ 1 (vá lens-schema C2): ChuaNop=1 / DaNop=2 / TvgsDuyet=3 / TvgsBac=4KHÔNG dùng 0. Mọi enum repo bắt đầu ở 1 (Office/Enums.cs:9,:19,:31,:41,:50,:58 · ApprovalWorkflow.cs:55), và 0 là falsy trong JSstatus ? A : B, if (filter.status), ?status=0 rơi im lặng ở FE. Giữ Khac = 99 cho Kind (đúng khuôn ItTicketCategory.Other = 99). KHÔNG copy 7/98 của PE (sẹo lịch sử enum PE — phiếu mới không nợ data cũ); giữ 98/99 cho quen mắt FE badge.
  • 🔴 KHÔNG thêm cột SigningPlanId lên PE — tra ngược bằng Plans.Where(PurchaseEvaluationId==x). Thêm cột = đẻ "cột có mà 0 ai đọc" (lớp lỗi đã cắn 6 lần ở repo này).

2.3 CQRS + Service + Controller

Application/ContractSigningPlans/ContractSigningPlanFeatures.cs (mega-file, khuôn ProposalFeatures.cs):

  • CreateContractSigningPlanCommand(PeId, ApprovalWorkflowId, GhiChu?) — validator: PE tồn tại + Phase==DaDuyet (mirror cầu src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs:53-54🔴 đường dẫn đầy đủ, đã sửa: file nằm ở PurchaseEvaluations/ KHÔNG phải Contracts/; spec bản đầu để tên trần khiến reviewer đoán sai thư mục và trượt 1 lượt đọc — lens-claims điểm củng cố 3)
    • chưa có plan sống (AnyAsync(p.PeId==x && p.Phase!=TuChoi) → Conflict; cho tạo lại sau TuChoi)
    • workflow ApplicableType==ContractSigningPlan (mirror Proposal :258-267🔴 mở rộng khoảng, đã sửa: :258-265 chỉ là phần nạp wfType; guard thật nằm ở :266-267 if (wfType.Value != (int)...) throw new ConflictException(...). Ai copy đúng 258-265 sẽ copy phần đọc mà bỏ mất chính cái guard — lens-claims S4). Handler auto-sinh Lines từ winners: pe.Suppliers.Where(IsWinner), PeReferenceAmount = SUM Quote.IsSelected per winner (copy đúng phép tính cầu :56-62, :88-90) — snapshot 1 lần.
  • UpdateContractSigningPlanDraftCommand — guard Phase ∈ {DangSoanThao, TraLai} + cho re-pin ApprovalWorkflowId (mirror PE; tránh vết Contract UpdateDraft KHÔNG có đường re-pin).
  • Get / List / pendingMe — detail Include Lines+DossierItems+Attachments+LevelOpinions + workflow-tree (khuôn Proposal :161-228); 🔴 inbox V2 precompute TỪ NGÀY 1 (mirror PE ResolveV2InboxIdsAsync) — bẫy đã cắn Contract: thiếu cái này ⇒ approver không mở được phiếu đang chờ chính mình (ContractFeatures.cs:363-372 legacy-only, grep ChoDuyet = 0 hit).
  • UpsertDossierItemCommand / DeleteDossierItemCommand — chỉ mở ở DangSoanThao|TraLai (căn cứ là input của con số đang duyệt). Attachment mở mọi phase (triết lý PE S147: BE không phase-guard attachment).

Infrastructure/Services/ContractSigningPlanWorkflowService.cs — copy ApproveV2Async:217-394 (§2.1), adapt 3 chỗ:

  1. Terminal → Phase=DaDuyet + ApplyApprovedValuesOnFinalize: mọi Line ApprovedAmount ??= ProposedAmount tại choke-point duy nhất + RULE grep-4-site (khuôn PurchaseEvaluationWorkflowService.cs:1008).
  2. Port 2 nhánh kết thúc sớm: AllowApproverFinalize :870-890 + finalizeByCcmDelegation+CeoApprovalThreshold :892-929 (tổng so ngưỡng = SUM ProposedAmount).
  3. Reject → 4 return-mode per-level (cờ sẵn tại Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:116-125🔴 anchor đã sửa: bản đầu ghi ApprovalWorkflowLevel.cs nhưng KHÔNG CÓ FILE NÀO TÊN VẬY (Glob 0 hit; class ApprovalWorkflowLevel nằm TRONG ApprovalWorkflow.cs:95) — lens-claims S2, mức MAJOR vì implementer mở theo spec sẽ file-not-found; logic mirror PE ApplyReturnModeAsync:368-547) — tối thiểu TraLai toàn phần nếu muốn gọn wave đầu.

🔴 Guard trình = CreatedBy==actor DeptManager AdminCỐ Ý KHÁC khuôn role-based (ContractWorkflowService.cs:70-79 đòi Drafter|DeptManager) để né lớp 403-PMH: PMH mang role Procurement nên bị chặn ở b.13/b.17 — đúng lớp "cơ-chế đúng, vai đi qua không có". Nếu owner muốn đồng nhất role-based thì BCH users phải được gán role Drafter.

🔴 Notify: mirror LogTransitionAsync báo Drafter (ContractWorkflowService.cs:407-427) + THÊM notify đích danh approver của Cấp kế — fix lỗ notify-chỉ-Drafter ngay từ ngày 1, đừng chép nợ.

Api/Controllers/ContractSigningPlansController.cs🔴 authz 2 TẦNG (bài học #82 / S118): class [Authorize] + per-action [Authorize(Policy = "ContractSigningPlans.{Read|Create|Update|Delete}")]. KHÔNG chép kiểu ContractsController.cs:12-13 class-trần (đó chính là lỗ đang treo). Policy TỰ SINH khi key vào MenuKeys.All (Program.cs:82-89 foreach All × Actions). Endpoints: GET list/inbox · GET {id} · POST · PUT {id} · POST {id}/transitions · dossier-items CRUD · attachments · DELETE {id} (allow-list DangSoanThao|TuChoi, mirror PE :1404).

2.4 Menu + Permission + FE 2 app (Pattern 16-bis 4-place mirror)

  • 🔴🔴 BE MenuKeys.cs — TÊN KEY PHẢI TRÙNG TIỀN-TỐ POLICY (vá lỗi CHÍ MẠNG review lens-wave H2 bắt): +4 const ContractSigningPlans / ContractSigningPlans_List / _Create / _Inbox VÀ đưa CẢ 4 vào mảng All. Policy controller = ContractSigningPlans.{Action}khớp key. ⚠️ **Bản đầu spec đặt key Csp* mà policy ContractSigningPlans.* ⇒ LỆCH ⇒ Program.cs KHÔNG sinh policy ⇒ [Authorize(Policy=...)] trỏ policy không tồn tại ⇒ HTTP 500 lúc chạy, KHÔNG phải 403. Repo ĐÃ CÓ lưới bắt: tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs:138-139 ghi nguyên văn "policy trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không sinh policy đó ⇒ 500 lúc chạy" — lead tự đọc xác nhận test tồn tại. 🔑 Bài học: §2.4 bản đầu nhận diện ĐÚNG bẫy Pe_*, rồi §2.3 vẫn tự vấp đúng bẫy đó bằng cơ chế khác (lệch TÊN thay vì factory-ngoài-All) — bài học nằm trong tay vẫn không tự bật. Lead verify MenuKeys.cs:168 (anchor đã sửa từ :171:171Personal,, review lens-claims S1): OffDeXuat+3 con đều trong All; còn Pe_* sinh bằng factory :150/:154 NẰM NGOÀI All. Theo khuôn Off_DeXuat, KHÔNG theo Pe_*. ⇒ 2 row canonical docs/STATUS.md (Menu keys · Policies = |All|×4) phải bump cùng lúc.
  • Seed DbInitializer.cs: 4 dòng menu (khuôn Off_DeXuat :1817-1820, label VN "Đề xuất ký kết hợp đồng"🔄 nhãn owner chốt @S159, thay "Kế hoạch ký kết HĐ" / "Danh sách" / "Tạo mới" / "Inbox duyệt") + admin-permission (:2383grep MỌI site seed-permission, S155 ghi nhận có 2 site cho vài nhóm). 🔴 GIẤU MENU Ở W1 = KHÔNG CẤP CanRead cho role thườngKHÔNG phải IsVisible=0. (vá H6 @S159. Bản S157 viết ở đây "Dark-launch được bằng IsVisible=0" trong khi §③ WAVE 1 :288-291 cùng file đã đo đĩa và BÁC đúng cơ chế đó — GetMyMenuTreeQuery.cs:96-100 lọc chỉ theo CanRead, IsVisible chỉ pass-through :88, MenuDtos.cs:13 ghi thẳng "fe-admin vẫn thấy". Bản sai nằm thượng nguồn bản đúng trong dòng đọc của người thi công ⇒ xếp HIGH, không phải nit.)
  • FE ×2 app (duplicate CÓ CHỦ ĐÍCH), mỗi app 4 chỗ: App.tsx routes · Layout.tsx staticMap · lib/menuKeys.ts · pages/ ×3 (khuôn bộ Proposal ~978 LOC/app: List 252 + Create 245 + Detail 386 + types 95). Detail thêm: bảng Lines (đề xuất/chốt per-NCC) · bảng DossierItems (status chip + TVGS + scan) · panel workflow (mirror PE Panel-3 ✓/●/○) · banner "Đến lượt bạn" (khuôn PE blockedByV2Level).
  • Designer admin nhận type 10: fe-admin ApprovalWorkflowsV2Page.tsx map typeCode→int :143 + các site BE ApprovalWorkflowV2AdminFeatures.cs. ⚠️ Invest tự khai KHÔNG verify được path fe-user matrix-view (WorkflowMatrixViewPage.tsx grep 0 hit) ⇒ PHẢI grep lại lúc làm, đừng tin line cũ.

2.5 Cầu tiếp giáp b.12→13 (sửa 1 file) — ⚠️ chạm module Contract

CreateContractFromEvaluationFeatures.cs: (a) đọc plan DaDuyet của PE → có thì giaTri = line.ApprovedAmount khớp w.SupplierId thay SUM :88-90, ghi ContextNote lệch "giá KH x vs SUM-PE y" vào changelog :121-140 (khuôn audit-note D4 S134); không có plan → giữ SUM + cảnh báo mềm (Q11 owner nâng cứng sau). (b) +param ApprovalWorkflowId? pin V2 cho HĐ — hiện pin V1-only :68-71,:108 ⇒ HĐ V2-less trình xong KẸT CỨNG ConflictException ContractWorkflowService.cs:115-116. 🔴 Đây là "chỗ hợp đồng" — anh chốt "cứ từ từ"wave này phải hỏi lại anh trước khi chạy.


③ CHECKLIST — CHIA WAVE (lead viết)

Nguyên tắc chia: mỗi wave deploy độc lập được, không để nửa tính năng chết trên prod (bài học PE nút-xóa S155: §F.0 chứng minh đợt-1-một-mình vô dụng ⇒ phải chia theo "đẩy riêng thì người dùng thấy gì").

Wave Tên Chặn bởi owner? Deploy riêng được?
W1 Schema + permission (giấu bằng KHÔNG cấp CanRead) không có (vô hình với user thường)
W2 CRUD phiếu nháp + căn cứ b.8-9 không có (bật menu, tạo được phiếu)
W3 Duyệt 3 trạm + finalize chốt giá ⚠️ Q3 (chọn cơ chế rẽ b.12) — nhưng chạy được với mặc định có (phiếu chạy trọn vòng)
W4 Gate + checklist per-phòng O-Q1, O-Q2 có (siết dần)
W5 Cầu sang Hợp đồng Q6, Q11 + anh cho phép chạm HĐ

WAVE 1 — Schema + permission + Designer type-10 (vá sau review)

🔴 NOTE @S160 (sau ARC-2 S159 — tiền-đề menu của W1 ĐỔI, đọc TRƯỚC khi thi công):

  1. Menu skeleton GĐ2 ĐÃ TỒN TẠI trên prod (arc-2 đợt-3+5, commit 2a72695/ea962b1): root Khkk_G1 "1. Kế hoạch ký kết HĐ (NCC-TP)" + 6 leaf Khkk_{WfView,List,Create,Pending,Approved,Deleted} — seed tại DbInitializer.cs:1779-1786, leaf trỏ /coming-soon, CanRead 13/13 role (grant-list :2179). ⇒ W1 TÁI DÙNG bộ Khkk_* này — CẤM đẻ bộ key mới cùng nghĩa (Ct_KeHoachKyKet_* của bản spec gốc); mục "MenuKeys.cs +4 const + DbInitializer menu 4 dòng" ở File: dưới coi như ĐÃ LÀM MỘT PHẦN, việc còn lại ở lớp menu = re-point leaf khỏi /coming-soon sang trang thật.
  2. Tiền-đề dark-launch "giấu bằng KHÔNG cấp CanRead" KHÔNG còn đúng cho lớp MENU — anh chốt @S159 (verbatim): "đang phát triển → cho hiển thị hết để mọi người góp ý" ⇒ menu GĐ2 đã public từ skeleton. Dark-launch của W1 lùi về lớp TRANG/DATA (ComingSoon → trang thật mới là "bật"); dòng " có (vô hình với user thường)" ở bảng wave trên đọc lại theo nghĩa này.
  3. Seeder skip-existing (gotcha #84): thêm key qua seeder OK, nhưng ĐỔI nhãn/parent/CanRead của key ĐÃ TỒN TẠI phải đi đường update-backfill kiểu labelBackfill đợt-5, KHÔNG phải insert; và CẤM SQL tay cho menu. 🔴 Designer KÉO TỪ W2 SANG ĐÂY (vá lens-wave H1): lệnh Create phiếu đòi workflow ApplicableType=10 tồn tại; wiring Designer type-10 không nằm trong file-list wave nào ở bản đầu ⇒ W2 đẩy riêng tạo được 0 phiếu = tái phạm đúng §F.0 của S155 (đợt-1-một-mình vô dụng). Kéo sang đây còn biến W1 từ 0-giá-trị thành UAT được (admin dựng quy trình 3 bước PMH→CCM→CEO).
  • Vai: implementer-backend · design-review database-agent trước khi apply migration.
  • File: 7 entity + 2 enum (Phase, DossierItemStatus) + 7 EF Configuration + DbSets ×2 interface + Mig 69 (3-file rule) + ApprovalWorkflow.cs enum +slot 10 + MenuKeys.cs +4 const vào All + DbInitializer menu 4 dòng + admin-permission + fe-admin ApprovalWorkflowsV2Page.tsx map typeCode→10 + 🔴 ApprovalWorkflowV2AdminFeatures.cs 3 SITE liệt-kê-thủ-công (vá lens-schema H2): :911+:929 merge "chữ ký còn sống" · :971+:992 RetainedOpinionRef · :1044+:1056 dọn chữ ký MỒ CÔI. Thiếu 3 site này ⇒ admin sửa quy trình type-10 KHÔNG thấy chữ ký phiếu KHKK, xoá Level đang có chữ ký → FK Restrict nổ 500 — đúng lớp lỗi S155 vừa vá xong.
  • Acceptance ĐO ĐƯỢC:
    1. dotnet ef migrations list → migration cuối cùng có tên chứa AddContractSigningPlans. (🔴 sửa: bản đầu viết 69_AddContractSigningPlans — EF in 20260728xxxxxx_AddContractSigningPlans, không có tiền tố 69_ ⇒ literal cũ KHÔNG BAO GIỜ khớp. lens-wave checklistEvidence #1.)
    2. sqlcmd -d SolutionErp -Q "SELECT COUNT(*) FROM sys.tables WHERE is_ms_shipped=0"89 → 96. (🔴 sửa: thêm is_ms_shipped=0 + DB đích — mốc 89 canonical đo bằng đúng vị ngữ này.)
    3. 🔴 dotnet testAuthorizePolicyRegressionTests XANH (thay dòng "sqlcmd đếm policy" — lens-wave H3: policy đăng ký in-memory trong Program.cs, KHÔNG nằm trong DB ⇒ phép cũ không chạy được). Test này chính là thứ bắt lỗi tên-key-lệch-policy ở §2.4. Số học +16 policy (54→58 key × 4) vẫn đúng.
    4. 🔴 dotnet test SolutionErp.slnx562 + N, N ≥ 2, 0 fail — VÀ 2 test dưới đây PHẢI tồn tại đúng tên: AuthorizePolicyRegressionTests phủ 4 key Ct_KeHoachKyKet_* mới (đã nêu ở acceptance #3) · ContractSigningPlanSchemaTests.BayBang_MoiBang_CoAuditFields_VaQueryFilter. (🔴 "562 giữ nguyên" của bản đầu = 0 bit, không falsify được gì — lens-wave H5. 🔴 Vá lần 2 @S159: bản S157 sửa thành "562 + số test mới" nhưng vẫn để N thả tự doN=0 vẫn PASS ⇒ chưa đóng lỗ. Đây là ca vá-một-nửa cùng lớp với :353 — grep 562 ra đúng 2 site, S157 chỉ chạm 1.)
    5. Admin tạo được 1 quy trình type=10 3 Bước, pin IsUserSelectable; xoá 1 Level chưa có chữ ký → OK.
    6. dotnet ef migrations script <trước> <sau> + script Down chạy sạch trên DB copy (reversible thật).
  • 🔴 BỎ tiêu chí "menu không hiện" của bản đầu (lens-wave H6 — đo đĩa bác): GetMyMenuTreeQuery.cs:96-100 filter CHỈ theo CanRead, IsVisible chỉ pass-through :88; MenuDtos.cs:13 ghi thẳng "fe-admin vẫn thấy"IsVisible=0 KHÔNG phải cơ chế dark-launch toàn cục, chỉ tối với fe-user. Cách đúng để giấu: không cấp CanRead cho role thường ở W1.
  • Rủi ro: seed-permission có >1 site (S155) ⇒ grep hết. · Slot enum 10 re-verify ngay trước khi viết. · 🔸 W1 đóng băng format mã + số bảng vào Mig 69 ⇒ "không chặn" chỉ đúng ở nghĩa không chờ câu trả lời, không đúng ở nghĩa không có quyết định nào bị khoá (lens-wave M1). Owner nên liếc format KHKK/{YYYY}/{Seq:D3} trước khi W1 chạy.

WAVE 2 — CRUD phiếu nháp + căn cứ b.8-9 (bật menu)

  • Mục tiêu: BCH tạo được phiếu nháp, điền dòng giá đề xuất + danh mục căn cứ, chưa trình được.
  • Vai: implementer-backend (Features + Controller) · implementer-frontend (3 page × 2 app) · test-specialist (test-after).
  • File: ContractSigningPlanFeatures.cs (Create/Update/Get/List + DossierItem CRUD + attachment) · ContractSigningPlansController.cs (2-tầng authz) · FE ×2 app 4 chỗ · 🔴 DbInitializer CẤP CanRead cho role đích (BCH/PRO/CCM) — KHÔNG phải "bật IsVisible=1". (vá H6 site-4 @S159 — site này lọt cả 3 vòng trước: HANDOFF đếm H6 = 1 site, lead @S159 đếm 3, đo lại ra 4. Lọt vì grep IsVisible CÓ trả về dòng này nhưng nó nằm ở W2 và trông như bước dữ-liệu vô hại; chỉ khi hỏi "W1 đã không set IsVisible=0 thì bật IsVisible=1 ở W2 còn nghĩa gì?" mới lộ. ⇒ grep tìm được CHUỖI, chỉ mô-hình nhân-quả tìm được HỆ-QUẢ — bài học bổ sung cho luật grep-cùng-lớp S122.)
  • Acceptance ĐO ĐƯỢC:
    1. POST /api/contract-signing-plans từ PE DaDuyet201, response có id; DB có N Lines = số winner của PE đó (sqlcmd đếm đối chiếu PurchaseEvaluationSuppliers WHERE IsWinner=1).
    2. PeReferenceAmount mỗi Line == SUM(Quotes WHERE IsSelected) của đúng NCC đó (sqlcmd 2 câu, so khớp).
    3. Tạo lần 2 trên cùng PE409 Conflict.
    4. Tạo từ PE chưa DaDuyet400/409 (không phải 500).
    5. 🔴 Authz 2 tầng: user thiếu quyền gọi POST403 (không phải 200/401/500). User có quyền → 201. (Phép chặn lỗ #82 tái phát — bắt buộc chạy.) + gate rẻ bổ sung (lens-claims điểm củng cố 2): grep -c "Authorize(Policy" ContractSigningPlansController.cs ≥ 4. Đối chứng: cùng lệnh trên ContractsController.cs = 0 hit toàn file ⇒ mọi endpoint HĐ chỉ cần "đăng nhập bất kỳ".
    6. FE 2 app: npm run build PASS ×2 · SHA256 3 file mirror IDENTICAL.
    7. Menu hiện đúng 4 mục tiếng Việt trên cả 2 app.
    8. 🔴 dotnet test phải có ≥1 test MỚI cho W2 (lens-wave H5: bản đầu W2 không có dòng test nào dù W2 đẻ controller + policy). Tối thiểu: 1 test authz-regression cho controller mới + 1 test guard Phase!=DaDuyet.
    9. 🔸 DELETE (lens-wave M6): W2 mở DELETEDangSoanThao ngay từ wave này — nếu để tới W3 thì guard "chưa có plan sống" (409) khoá cứng PE: tạo nhầm 1 phiếu là không tạo lại được cho PE đó.
  • Rủi ro: quên HasQueryFilter(!IsDeleted) ⇒ phiếu xóa mềm vẫn hiện. · UNIQUE filtered thiếu WHERE IsDeleted=0 ⇒ tạo lại sau xóa bị chặn oan (gotcha #57).

WAVE 3 — Duyệt 3 trạm + finalize chốt giá 🔴 wave nặng nhất

  • Mục tiêu: phiếu chạy trọn DangSoanThao → ChoDuyet → DaDuyet, chốt ApprovedAmount.
  • Vai: implementer-backend (service) · implementer-frontend (panel + banner) · test-specialist (test-before cho OR-of-N — đây là critical algorithm) · reviewer (adversarial trước commit).
  • File: ContractSigningPlanWorkflowService.cs (copy ApproveV2Async:217-394) · transitions endpoint · inbox V2 precompute · notify · FE workflow panel + banner "Đến lượt bạn".
  • Acceptance ĐO ĐƯỢC:
    1. 🔴 OR-of-N còn sống — 4 vế, THIẾU VẾ NÀO LÀ 0 BIT (vá lens-wave H4: bản đầu chỉ thử 1 người ⇒ trúng-may dưới đúng bug ElementAtOrDefault, và service bỏ hẳn guard cũng PASS). Workflow có 1 Cấp 2 người (A, B): (a) 2 phiếu song sinh: phiếu-1 để A duyệt → tiến; phiếu-2 để B duyệt → cũng tiến (chứng OR thật, không phải trúng thứ tự); (b) ca âm: người ngoài cấp duyệt → 403 (chứng guard còn sống); (c) SELECT COUNT(*) FROM ContractSigningPlanLevelOpinions WHERE PlanId=@id = 1 (không đúp); (d) phiếu KHÔNG đòi cả A và B cùng ký. Test unit + 1 lượt UAT thật.
    2. Con-trỏ đôi: workflow 3 Bước → sau mỗi lượt duyệt, đọc sqlcmd 2 cột: CurrentWorkflowStepIndex đi 1→2→3, CurrentApprovalLevelOrder reset về 1 mỗi khi sang Bước mới. (🔴 sửa: "2 con trỏ đều đổi" của bản đầu sai nghĩa đen — trong 1 Bước nhiều Cấp thì StepIndex không đổi; lens-wave M7.)
    3. Finalize choke-point: duyệt cấp cuối → mọi Line của đúng phiếu đóApprovedAmount != NULL: SELECT COUNT(*) FROM ContractSigningPlanLines WHERE PlanId=@id AND ApprovedAmount IS NULL = 0. (🔴 sửa: bản đầu thiếu AND PlanId=@id ⇒ đếm toàn bảng ⇒ FAIL giả vĩnh viễn ngay khi có phiếu nháp thứ hai tồn tại; lens-wave M7.) Test cả 3 đường tới DaDuyetgrep 4-site RULE. ⚠️ Mâu thuẫn cần anh gỡ (lens-wave M2 + M3): acceptance này ép đủ 3 đường, nhưng bảng wave khai W3 "chạy được với mặc định"; 2 trong 3 đường (AllowApproverFinalize · CeoApprovalThreshold) từng bị anh phán "PHÁ VỠ" ở S155 (owner-decision 10/11). ⇒ W3 mặc định chỉ làm đường THƯỜNG; 2 đường kia chờ anh xác nhận còn muốn không.
    4. Inbox: approver Cấp đang chờ gọi GET /inboxthấy phiếu; approver cấp khác → không thấy. (Phép chặn đúng lỗ Contract đang mắc.)
    5. LevelOpinions UPSERT: duyệt 2 lần cùng cấp → DB 1 row (không đúp), SignedByUserId đúng người thật.
    6. Trả lại → TraLai, sửa rồi gửi lại chạy từ Cấp 1 Bước 1.
    7. Notify: approver Cấp kế nhận thông báo (không chỉ Drafter).
    8. 🔴 dotnet test562 + N, N ≥ 6, 0 fail — VÀ 6 test dưới đây PHẢI tồn tại đúng tên (vá H5 @S159; bản S157 để N thả tự do ⇒ N=0 vẫn PASS ⇒ acceptance 0 bit, đúng lỗ reviewer chỉ). Sàn 6 test = ánh xạ 1:1 với acceptance #1#6 ngay trên, class ContractSigningPlanApprovalTests (tests/SolutionErp.Infrastructure.Tests/Application/):
      # Tên test BẮT BUỘC Chốt acceptance
      1 OrOfN_MotNguoiTrongCap_DuyetDuoc_KhongDoiDuCaHaiKy #1 (d) — ca ÂM: phiếu KHÔNG đòi cả A và B
      2 ConTroDoi_SangBuocMoi_LevelOrder_ResetVe1 #2 — StepIndex 1→2→3, LevelOrder reset
      3 Finalize_MoiLineCuaDungPhieuDo_CoApprovedAmount #3 — AND PlanId=@id, chống FAIL-giả toàn bảng
      4 Inbox_ChiApproverCapDangCho_ThayPhieu #4 — ca ÂM: cấp khác KHÔNG thấy
      5 LevelOpinions_DuyetHaiLanCungCap_ChiMotRow #5 — UPSERT + SignedByUserId đúng người thật
      6 TraLai_GuiLai_ChayLaiTuCap1Buoc1 #6 — vòng trả-lại
      🔸 #7 (notify approver cấp kế) không tính vào sàn 6 — cần hạ tầng thông báo, để test-after.
      🔴 Sàn là SÀN, không phải trần: N > 6 là bình thường và tốt; N < 6 hoặc thiếu 1 tênW3 TRƯỢT.
  • Rủi ro: 🔴 copy nhầm Proposal ⇒ mất OR-of-N âm thầm (build sạch, test cũ vẫn xanh) — acceptance #1 là lưới duy nhất bắt được. · Quên inbox precompute ⇒ lặp lại đúng lỗi Contract.

WAVE 4 — Gate trình + checklist per-phòng CHẶN: O-Q1, O-Q2

  • Mục tiêu: siết chất lượng đầu vào theo ý anh.
  • Chờ anh: O-Q1 căn cứ b.8 chưa đủ TvgsDuyetcảnh báo mềm hay chặn cứng? · O-Q2 checkbox FO-002.01 (PRO: điều khoản/thanh toán/rủi ro pháp lý · CCM: giá vs NS) → số hoá thành checklist hay giữ comment tự do?
  • Acceptance (khi có câu trả lời): trình thiếu căn cứ → đúng hành vi anh chọn (toast cảnh báo hoặc 409); checklist (nếu số hoá) lưu + hiện lại đúng trên phiếu đã duyệt.

WAVE 5 — Cầu sang Hợp đồng CHẶN: Q6, Q11 + anh cho phép chạm HĐ

  • 🔴 Wave này chạm module Contract — anh đã chốt "chỗ hợp đồng cứ từ từ". KHÔNG tự chạy.
  • Chờ anh: Q6 giá vào HĐ = ApprovedAmount của KH (mặc định thiết kế: KH thắng + audit lệch) hay vẫn SUM-PE? · Q11 chưa có KH duyệt thì tạo HĐ = chặn hay cảnh báo?
  • Acceptance: HĐ tạo từ PE có KH DaDuyetGiaTri == SUM(ApprovedAmount); changelog có ContextNote lệch; HĐ pin được workflow V2 (không còn ConflictException kẹt cứng).

Ngoài mọi wave (ghi nhận, KHÔNG làm trong run này)

  • SLA — O-2: chỉ hiển thị. AddDays(7) hardcode vẫn sai nhưng không ai enforce ⇒ không đáng 1 wave.
  • Lỗ hổng an ninh HĐ (ContractWorkflowService.cs:48-66 Reject-trước-guard + controller class-trần) — vẫn SỐNG trên prod, anh chốt "từ từ". Nhắc đúng 1 dòng cho khỏi quên.
  • FO-002.01 auto-pass ("quá hạn xem như đã thông qua") — mâu thuẫn mô hình duyệt-chặn V2. O-2 ⇒ ghi nhận.

④ §D — HẤP THỤ 6 việc sửa của review-synthesis.md §G (S156)

# Việc review yêu cầu Xử lý trong spec này
1 Claim SAI "hardcoded policy fallback" → thật là ConflictException:115-116 (HĐ kẹt cứng ChoDuyet) Đã sửa — §2.5(b) ghi đúng: pin V1-only ⇒ HĐ V2-less KẸT CỨNG, hỏng cứng không degrade êm. Lead tự xác minh trên đĩa.
2 Siết biên b.14→19, không phải b.13→21 Spec này không nhắc lại biên cũ; phạm vi khai rõ = b.7→12, khúc 13→21 để nguyên verdict LAI S156.
3 Thiếu Q vai người TRÌNH (PMH Procurement bị 403 :70-79) Đã xử ở thiết kế, không đẩy thành câu hỏi: §2.3 guard trình = CreatedBy DeptManager Admin cố ý khác khuôn role-based, kèm lối thoát nếu owner muốn đồng nhất.
4 Thêm cảnh báo lỗ hổng reject — việc RIÊNG Giữ đúng 1 dòng ở "Ngoài mọi wave"; không nhét vào wave (anh chốt "từ từ").
5 Q5 thiếu phương án (d) skipToFinal đã wire Có mặt — §1.5 liệt 4 đường rẽ b.12, trong đó (iii) AllowApproverSkipToFinal Contract:320-352.
6 Viết lại Q2 theo 2 trục + 3 số đo cùng đơn vị từ twin thật ⚠️ Trục cơ-chế ĐÓNG bởi O-1 (anh chốt tái dùng khung) ⇒ chỉ còn trục NỘI DUNG. Bảng dưới.

Trục NỘI DUNG — 3 nhánh, 3 số cùng đơn vị

Số twin đo thật: Proposal = 4 bảng · 849 LOC BE · 1.956 LOC FE (invest wc/grep trực tiếp). Số cho từng nhánh = ngoại suy từ twin đó — khai rõ đâu là đo, đâu là ước.

Nhánh Bảng mới LOC BE (ước) LOC FE (ước) Đánh giá
(A) Phiếu độc lậpspec này chọn 7 (5 nếu cắt) ~1.1001.400 ~2.0002.400 Vòng đời riêng, xóa/soft-delete riêng, workflow riêng type 10. Đắt nhất về bảng, rẻ nhất về rủi ro.
(B) Mở rộng PE (thêm cột + bảng con vào phiếu PE) ~3 ~600800 ~9001.200 Rẻ hơn, nhưng: PE DaDuyetterminal thành công — mở lại vòng duyệt thứ 2 trên phiếu đã đóng ⇒ phá bất-biến PE, đụng cả budgetFrozen snapshot Mig 67. Rủi ro cao nhất.
(C) Entity con của Contract ~4 ~700900 ~1.1001.400 Kéo khúc 7→12 vào module HĐ — mà anh vừa chốt "chỗ hợp đồng cứ từ từ"; đồng thời HĐ chưa tồn tại ở b.7-12 (HĐ mới sinh ở b.13) ⇒ sai thứ tự nghiệp vụ.

Lý do chọn (A) — không phải vì rẻ, mà vì: nó là PHIẾU theo đúng phát biểu mục đích của anh (có đối tượng được duyệt + 3 trạm + người chốt); (B) phá bất-biến của PE đang chạy ổn; (C) sai thứ tự nghiệp vụ và chạm vùng anh vừa yêu cầu để yên. Nếu owner ưu tiên cắt chi phí thì cắt trong (A) (7→5 bảng), đừng đổi sang (B)/(C).



⑤ KẾT QUẢ REVIEW /fable-clone reviewer — 4 lăng kính, run wf_57af0163-053

4/4 lane sạch, 0 chết, 0 rỗng (đóng gói C2 ăn: ≤3 file/lane + ép khung rỗng lượt 1-2 + trần 25).

Lăng kính Verdict Điểm
lens-wave (wave-plan + acceptance) SUA-TRUOC-W1 19 (6H · 8M · 5L) + 10 củng cố
lens-fidelity (trung thực nguồn) GO-WITH-FIXES 23 (3 CAO · 8 TRUNG · 12 THẤP) + 8 củng cố
lens-schema (7 bảng) GO-WITH-FIXES (5 HIGH chặn W1) 22 (5H · 7M · 3MED · 7 củng cố)
lens-claims (verify file:line) SPEC §② VỮNG 48 claim: 39Đ / 4S / 5KKĐ

🔑 Claim NỀN của spec ĐỨNG VỮNG

lens-fidelity đọc lại src-QT-TRINH-KY-HD.txtXÁC NHẬN: bảng 2 ISO = tiêu đề + đúng 9 dòng bước (:59-68), khớp 9/9 cả tên trạm lẫn thứ tự với ContractPhase; "đi thẳng 1→2" đúng theo nghĩa hẹp (không dòng nào giữa :60:61). Spec không sập.

🔴 Đã VÁ trong bản này (7 lỗi nặng)

  1. Tên key ≠ tiền tố policy ⇒ HTTP 500Csp* vs ContractSigningPlans.*. Repo có sẵn test bắt (AuthorizePolicyRegressionTests.cs:138-139). Đây là lỗi tự-vấp-bẫy-mình-vừa-nhận-diện. → §2.4
  2. W2 đẩy riêng tạo được 0 phiếu — wiring Designer type=10 rơi khỏi mọi wave → kéo vào W1
  3. Thiếu bảng Approvals — service được lệnh copy CÓ ghi vào (ContractWorkflowService.cs:151) → thêm #7
  4. Changelogs thiếu 4 cột (EntityType/EntityId/UserName/FieldChangesJson) ⇒ copy service không biên dịch
  5. 3 site ApprovalWorkflowV2AdminFeatures.cs chưa nêu ⇒ admin xoá Level có chữ ký → FK Restrict nổ 500
  6. Status bắt đầu từ 0 ⇒ bẫy falsy JS → đổi sang 1
  7. 4 anchor lệch — nặng nhất ApprovalWorkflowLevel.cs KHÔNG TỒN TẠI (class nằm trong ApprovalWorkflow.cs:95)

Kèm: bỏ tiêu chí dark-launch sai cơ chế (🔴 đính chính @S159: @S157 chỉ vá 1/3 site:288-291 sửa đúng, còn :224 và bảng wave :252 vẫn dạy cơ chế sai, tức spec tự mâu thuẫn suốt từ S157. Vá nốt 2 site còn lại @S159. Đây là ca "vá một chỗ, sót chỗ cùng lớp" — bài học S122: được-chỉ-1-chỗ ⇒ PHẢI grep cùng-lớp) · thay "sqlcmd đếm policy" (không đo được) bằng test regression · OR-of-N nâng từ 1 vế lên 4 vế có ca âm · sửa 2 phép đo sai phạm vi · thêm độ dài 13 cột chuỗi · thêm DepartmentId + ContractId · cắt CodeSequences (tái dùng WorkflowAppCodeSequence).

⚠️ CÒN MỞ — cần anh hoặc cần đo thêm

  • Nguồn thứ 4 chưa ai đọc: ISO bước 1 trỏ ra SOL-PRO-SP-001 Quy trình Cung ứng (:47, :60). ⇒ Câu "khúc này KHÔNG CÓ trong quy trình ISO" nên hạ phạm vi thành "không có trong QT trình ký" trước khi trình anh với tư cách đề xuất sửa ISO. Anh có file này không?
  • W3 hai đường kết thúc sớm (AllowApproverFinalize · CeoApprovalThreshold) — anh từng phán "PHÁ VỠ" ở S155. Giữ hay bỏ?
  • 5 câu owner (O-Q1 · O-Q2 · Q3 · Q6 · Q11) vẫn nguyên.
  • 5 claim chưa kiểm được (lens-claims KKĐ): ranh CUỐI của 6 khoảng dòng · WorkflowMatrixViewPage.tsx (spec đã tự gắn cờ "grep lại lúc làm" — giữ cờ) · mệnh đề "HĐ V2-less kẹt cứng" đúng CÓ ĐIỀU KIỆN (phụ thuộc DB prod có WorkflowDefinitions active + step cho ContractType đó không — chưa đo DB).
  • Gate rẻ reviewer đề nghị, lead nhận: trước mỗi wave, mở 5 anchor bất kỳ của wave đó; ≥1 anchor trượt ⇒ chấm lại toàn bộ anchor của wave. (Anchor drift lan từ MÃ sang SPEC: comment ApprovalWorkflow.cs:161 ghi lệch 1 dòng, spec chép y nguyên.)