31 KiB
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-codebaseFable) Lead verify: 4 claim load-bearing đo lại độc lập trên đĩa — xemrun.mdS4. §C+§D dưới đây lead viết. Trạng thái: CHỜ/fable-clone reviewerchấ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ành —
QT TRINH KY HOP DONG TP-NCC.docxbả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 đủ TvgsDuyet → cả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ẶNG → Q3 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 | 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 | CÓ | = 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 AppRoles — 0 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 | CÓ | Đố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-429 → ElementAtOrDefault(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-445xá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-67dừng ởTravelRequest = 9, slot 10 TRỐNG THẬT.ContractSigningPlanPhaseMỚ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
SigningPlanIdlên PE — tra ngược bằngPlans.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ầuCreateContractFromEvaluationFeatures.cs:53-54) + chưa có plan sống (AnyAsync(p.PeId==x && p.Phase!=TuChoi)→ Conflict; cho tạo lại sauTuChoi) + workflowApplicableType==ContractSigningPlan(mirror Proposal:258-265). Handler auto-sinh Lines từ winners:pe.Suppliers.Where(IsWinner),PeReferenceAmount = SUM Quote.IsSelectedper winner (copy đúng phép tính cầu:56-62,:88-90) — snapshot 1 lần.UpdateContractSigningPlanDraftCommand— guardPhase ∈ {DangSoanThao, TraLai}+ cho re-pinApprovalWorkflowId(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 PEResolveV2InboxIdsAsync) — 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-372legacy-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ỗ:
- Terminal →
Phase=DaDuyet+ApplyApprovedValuesOnFinalize: mọi LineApprovedAmount ??= ProposedAmounttại choke-point duy nhất + RULE grep-4-site (khuônPurchaseEvaluationWorkflowService.cs:1008). - Port 2 nhánh kết thúc sớm:
AllowApproverFinalize:870-890+finalizeByCcmDelegation+CeoApprovalThreshold:892-929(tổng so ngưỡng =SUM ProposedAmount). - Reject → 4 return-mode per-level (cờ sẵn
ApprovalWorkflowLevel.cs:116-125, logic mirror PEApplyReturnModeAsync:368-547) — tối thiểuTraLaitoàn phần nếu muốn gọn wave đầu.
🔴 Guard trình = CreatedBy==actor ∨ DeptManager ∨ Admin — CỐ Ý 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 constCsp/Csp_List/Csp_Create/Csp_InboxVÀ đưa CẢ 4 vào mảngAll. ✅ Lead verifyMenuKeys.cs:171:OffDeXuat+3 con đều trongAll; cònPe_*sinh bằng factory:150/:154NẰM NGOÀIAll⇒ per-action policy không tồn tại. Theo khuônOff_DeXuat, KHÔNG theoPe_*. ⇒ 2 row canonicaldocs/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 (:2383— grep MỌI site seed-permission, S155 ghi nhận có 2 site cho vài nhóm). Dark-launch được bằngIsVisible=0rồi bật sau. - FE ×2 app (duplicate CÓ CHỦ ĐÍCH), mỗi app 4 chỗ:
App.tsxroutes ·Layout.tsxstaticMap ·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 PEblockedByV2Level). - Designer admin nhận type 10: fe-admin
ApprovalWorkflowsV2Page.tsxmap typeCode→int:143+ các site BEApprovalWorkflowV2AdminFeatures.cs. ⚠️ Invest tự khai KHÔNG verify được pathfe-usermatrix-view (WorkflowMatrixViewPage.tsxgrep 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Đ | ✅ có |
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· reviewdatabase-agent(design-review migration trước apply). - File: 7 entity +
ContractSigningPlanPhase.cs+ 7 EF Configuration +ApplicationDbContext/IApplicationDbContextDbSets + Mig 69 (3-file rule) +ApprovalWorkflow.csenum +slot 10 +MenuKeys.cs+4 const vàoAll+DbInitializermenu 4 dòngIsVisible=0+ admin-permission. - Acceptance ĐO ĐƯỢC:
dotnet ef migrations list→69_AddContractSigningPlansở cuối.sqlcmd -Q "SELECT COUNT(*) FROM sys.tables"→ 89 → 96 (+7).dotnet test SolutionErp.slnx→ 562 PASS giữ nguyên, 0 fail (chưa có test mới).sqlcmdđếm policy:|MenuKeys.All| × 4tăng đúng +16; grepContractSigningPlans.Readcó trong list policy.- Menu KHÔNG hiện trên cả 2 FE (IsVisible=0) — screenshot hoặc
GET /api/menuskhông chứaCsp. 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.csngay 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ỗ ·DbInitializerbậtIsVisible=1. - Acceptance ĐO ĐƯỢC:
POST /api/contract-signing-planstừ PEDaDuyet→ 201, response cóid; DB có N Lines = số winner của PE đó (sqlcmdđếm đối chiếuPurchaseEvaluationSuppliers WHERE IsWinner=1).PeReferenceAmountmỗi Line ==SUM(Quotes WHERE IsSelected)của đúng NCC đó (sqlcmd2 câu, so khớp).- Tạo lần 2 trên cùng PE → 409 Conflict.
- Tạo từ PE chưa
DaDuyet→ 400/409 (không phải 500). - 🔴 Authz 2 tầng: user thiếu quyền gọi
POST→ 403 (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ỏ.) - FE 2 app:
npm run buildPASS ×2 · SHA256 3 file mirror IDENTICAL. - 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ếuWHERE 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ốtApprovedAmount. - 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(copyApproveV2Async:217-394) ·transitionsendpoint · inbox V2 precompute · notify · FE workflow panel + banner "Đến lượt bạn". - Acceptance ĐO ĐƯỢC:
- 🔴 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ười → ngườ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.
- Con-trỏ đôi: workflow 3 Bước → sau mỗi lượt duyệt,
CurrentWorkflowStepIndexvàCurrentApprovalLevelOrderđều đổi đúng (sqlcmdđọc 2 cột sau mỗi bước). - 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ớiDaDuyet(thường ·AllowApproverFinalize·CeoApprovalThreshold) — grep 4-site RULE. - Inbox: approver Cấp đang chờ gọi
GET /inbox→ thấy phiếu; approver cấp khác → không thấy. (Phép chặn đúng lỗ Contract đang mắc.) LevelOpinionsUPSERT: duyệt 2 lần cùng cấp → DB 1 row (không đúp),SignedByUserIdđúng người thật.- Trả lại →
TraLai, sửa rồi gửi lại chạy từ Cấp 1 Bước 1. - Notify: approver Cấp kế nhận thông báo (không chỉ Drafter).
dotnet test→ 562 + 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 đủ
TvgsDuyet→ cả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Đ =
ApprovedAmountcủa KH (mặc định thiết kế: KH thắng + audit lệch) hay vẫnSUM-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
DaDuyet→GiaTri == SUM(ApprovedAmount); changelog có ContextNote lệch; HĐ pin được workflow V2 (không cònConflictExceptionkẹ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-66Reject-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 (investwc/greptrự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ập ⬅ spec này chọn | 7 (5 nếu cắt) | ~1.100–1.400 | ~2.000–2.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 | ~600–800 | ~900–1.200 | Rẻ hơn, nhưng: PE DaDuyet là terminal 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 | ~700–900 | ~1.100–1.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).