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-28 11:12:16 +07:00

31 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".

Đâ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 · Action · PhaseAtChange · UserId · Summary · ContextNote có từ ngày 1 — tiền lệ PE: Changelogs là nguồn transition-marker/backfill (Mig 60/61)
7 ContractSigningPlanCodeSequences Prefix PK · LastSeq khuôn ProposalCodeSequence.cs; format đề xuất KHKK/{YYYY}/{Seq:D3}owner chốt format

Biến thể tối thiểu 5 bảng (bỏ #6 + #7) — KHÔNG khuyến nghị: mất audit-trail transition (Mig 60/61 chứng minh là cần) và mất mã phiếu atomic. Nếu owner muốn cắt, cắt #7 trước (mã tay), giữ #6.

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. 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 CreateContractFromEvaluationFeatures.cs:53-54) + 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-265). 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 ApprovalWorkflowLevel.cs:116-125, 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: +4 const Csp / Csp_List / Csp_Create / Csp_Inbox VÀ đưa CẢ 4 vào mảng All. Lead verify MenuKeys.cs:171: OffDeXuat+3 con đều trong All; còn Pe_* sinh bằng factory :150/:154 NẰM NGOÀI All ⇒ per-action policy không tồn tại. 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 "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). Dark-launch được bằng IsVisible=0 rồi bật sau.
  • 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 (dark-launch) không có (vô hình với user)
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 scaffold (dark-launch)

  • Mục tiêu: đặt nền DB + quyền, không đổi gì người dùng thấy.
  • Vai: implementer-backend · review database-agent (design-review migration trước apply).
  • File: 7 entity + ContractSigningPlanPhase.cs + 7 EF Configuration + ApplicationDbContext/IApplicationDbContext DbSets + Mig 69 (3-file rule) + ApprovalWorkflow.cs enum +slot 10 + MenuKeys.cs +4 const vào All + DbInitializer menu 4 dòng IsVisible=0 + admin-permission.
  • Acceptance ĐO ĐƯỢC:
    1. dotnet ef migrations list69_AddContractSigningPlans ở cuối.
    2. sqlcmd -Q "SELECT COUNT(*) FROM sys.tables"89 → 96 (+7).
    3. dotnet test SolutionErp.slnx562 PASS giữ nguyên, 0 fail (chưa có test mới).
    4. sqlcmd đếm policy: |MenuKeys.All| × 4 tăng đúng +16; grep ContractSigningPlans.Read có trong list policy.
    5. Menu KHÔNG hiện trên cả 2 FE (IsVisible=0) — screenshot hoặc GET /api/menus không chứa Csp.
    6. dotnet ef migrations script 68 69 + script Down chạy sạch trên DB copy (reversible thật, không chỉ nhìn code).
  • Rủi ro: seed-permission có >1 site (S155) ⇒ grep hết trước khi kết luận. · Enum slot 10 nếu ai đó chèn trước ⇒ re-verify ApprovalWorkflow.cs ngay trước khi viết.

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 bật IsVisible=1.
  • 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). User có quyền → 201. (Đây là phép chặn lỗ #82 tái phát — bắt buộc chạy, không được bỏ.)
    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.
  • 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 (phép chặn bẫy §2.1): workflow 1 Bước có 1 Cấp 2 ngườingười thứ hai duyệt được và phiếu tiến sang Bước kế (không đòi cả 2 ký). Test unit + 1 lượt UAT thật.
    2. Con-trỏ đôi: workflow 3 Bước → sau mỗi lượt duyệt, CurrentWorkflowStepIndex CurrentApprovalLevelOrder đều đổi đúng (sqlcmd đọc 2 cột sau mỗi bước).
    3. Finalize choke-point: duyệt cấp cuối → mọi Line có ApprovedAmount != NULL (sqlcmd COUNT(*) WHERE ApprovedAmount IS NULL = 0). Test cả 3 đường tới DaDuyet (thường · AllowApproverFinalize · CeoApprovalThreshold) — grep 4-site RULE.
    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 mới, 0 fail.
  • 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).