Files
solution-erp/.claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/spec-khkk-ui-mirror-pe-04-08-2026.md
2026-08-04 18:53:50 +07:00

42 KiB
Raw Blame History

spec — KHKK UI mirror Duyệt NCC (3 panel đồng-nhất)

Pipeline H21: engine propose → lead verifylead ghi (file này) → worker thực-thi. Nguồn: /fable-real investigator-codebase (Fable 5) + /fable-clone reviewer ×3 lăng-kính (Opus). Trạng-thái review: feasibility 8 HELD/6 BROKE · regression 8 HELD/4 BROKE/10 bỏ-sót · fidelity ~12 HELD/6 BROKE/3 bỏ-sót (2 mục cuối lane fidelity chết dở — verdict do lead derive từ §7 bảng của chính nó, KHÔNG phải lane tự kết; phép đo §6 còn thiếu thì lead đã tự chạy, kết-quả ở ②-D dưới). Đọc kèm: sub-investigator-codebase-invest-s171.md (bản-đồ đầy-đủ) · 3 file sub-reviewer-lens-*.md (phản-biện).


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

Đưa màn Kế hoạch Ký kết HĐ (KHKK — giai-đoạn 2) về cùng bố-cục 3 panel với Duyệt NCC (PE — giai-đoạn 1).

Owner (verbatim):

"điều chỉnh lại giao diện chỗ KKHĐ lại nhé, lấy của Duyệt NCC làm mẫu: Panel 1: Hình gửi 1 · Panel 3: Duyệt và History tương tự (Hình 2) · Panel 2 - Giữa: Cấu trúc tương tự như duyệt NCC (Phần content nhập phía trên, duyệt ghi chú phía dưới) - Hình 3." "cách bố trí toàn diện cho tất cả, giống nhất với duyệt NCC, chỉ khác đúng phần khác, tương tự thì giống nhau, đồng nhất."

Vì sao việc này chính-đáng (lý-do KHÔNG nằm trong ảnh): GĐ1 (PurchaseEvaluationsListPage.tsx:381,397) và GĐ3 (ContractsListPage.tsx:106,125) đều đã là h-[calc(100vh-4rem)] + 3 cột. GĐ2 là space-y-5 cuộn-body 2 panel (KhkkListPage.tsx:161,197) ⇒ mắt-xích lệch duy nhất trong 4 giai-đoạn. Đây là đổi lớp kiến-trúc, không phải sửa CSS.

Phân-loại cuối (sau phản-biện) — 29 GIỐNG-được · 3 KHÁC-buộc-phải-khác · 2 ĐÃ GIỐNG · 2 CHỜ-ANH

Bản invest báo 6 miễn-trừ. Sau khi áp đúng luật owner ("chỉ khác đúng phần khác" ⇒ mỗi dòng KHÁC là một lần xin miễn-trừ, gánh nặng chứng-minh thuộc người xin), 3 dòng không đứng vững:

Dòng Bản invest phán Chốt cuối Vì sao
P1-4 nhóm N1-N8 KHÁC-buộc 🔄 GIỐNG-được Khác nguồn dữ-liệu, KHÔNG khác bố-trí — chính nó tự viết "CHỖ ĐẶT phải giống". PE để Select quy-trình ở khối filter panel 1; KHKK để Select nhóm đúng ô đó. Dán nhãn KHÁC làm phồng mẫu-số miễn-trừ
P2-7 inline-edit vs readOnly KHÁC-buộc 🔄 GIỐNG-được readOnlyprop do HOST truyền (PurchaseEvaluationsListPage.tsx:663 truyền readOnly={true}; PeDetailTabs.tsx:427 có nhánh mode). Mệnh-đề "muốn giống thì phải dựng workspace" sai — host chỉ cần truyền readOnly={phiếu ≠ Nháp || user ≠ drafter}. Đường sửa KHÔNG bị cắt
P3-12 bốn return-mode KHÁC-buộc 🔄 CHỜ-ANH (hoãn) Tiền-đề "KHKK là 3 trạm ngắn nên lùi-1-cấp ≈ về-đầu"lead đo, SAI: DbInitializer.cs:554 roster = (StepName, DeptCode, Level1[], Level2[], Level2Finalize)3 Bước × tối đa 2 Cấp = tới 6 trạm, và seed :620 tự ghi "đội hình sao từ workflow Duyệt NCC prod" ⇒ hình dạng y hệt PE. Vế còn lại ("đổi state machine BE / ngoài phạm-vi") là lý-do chi-phí, không mua được miễn-trừ ⇒ gọi đúng tên: hoãn, không phải buộc-khác

3 miễn-trừ THẬT còn lại (đều có lý-do nghiệp-vụ, không dòng nào lấy "code đang thế" làm bằng-chứng):

  • P2-3 nội-dung Section: GĐ2 không có báo-giá/so-sánh NCC (đã chốt ở GĐ1), có căn-cứ hồ-sơ TVGS. Ép 5 Section trùng tên PE sẽ đẻ 2 Section rỗng. "Giống nhau" ở đây = giống KHUNG (đánh số · divide-y · ý-kiến dưới cùng), không phải giống chữ.
  • P3-7 badge ✎ NS PRO/CCM: quyền nhập ngân-sách per-role là khái-niệm riêng PE (PeWorkItemBudgets, Mig 50). KHKK không có cột đó ⇒ badge vô nghĩa.
  • P3-13 bộ chọn giá chốt: KHKK chốt giá per-Line ở choke-point BE ApplyApprovedValuesOnFinalize — không có "1 giá" để người duyệt chọn (khác cardinality, không khác ý-thích).

🔴 Miễn-trừ THỨ 7 — KHÔNG ĐƯỢC KHAI (fidelity F-5), phải hỏi anh

§3 bản invest phán P3-16 "empty-state panel 3 (hộp dashed + X + 'Quy trình duyệt sẽ hiện khi chọn phiếu.') = GIỐNG-được, Ảnh 1 có". Nhưng §5 bố-cục đích lại ghi "panel 3 chưa chọn → PipelineTreePanel currentStage={2}", và acceptance A-8e đóng đinh bản thay-thế. ⇒ Một dòng đã phán GIỐNG-được bị đảo âm-thầm giữa §3 và §5, đúng vào khối anh nhìn thấy ở Ảnh 1. Theo luật owner, dạng này nguy nhất: không phải khác — mà là khác mà không nói. ĐÃ ĐÓNG — owner chốt 2026-08-04 (QĐ-2): panel 3 khi chưa chọn = CHỈ hộp dashed y PE, quay về đúng Ảnh 1. Xem bảng 3 QUYẾT-ĐỊNH OWNER cuối file.


② CÁCH IMPLEMENT

②-A. Quyết-định kiến-trúc: Phương-án A (tạo components/khkk/, chép khuôn) — GIỮ, nhưng THAY LÝ-DO

Bản invest bác phương-án B (generic-hoá component PE dùng chung) bằng lập-luận: "phải hoặc ép BE KHKK precompute status, hoặc nhồi adapter 2 chiều — **cả hai đều đụng đường duyệt PE đang chạy prod".

🔴 Lập-luận này BROKE (feasibility §5.2) — nhị-phân giả. Có đường (iii) nó không xét: KHKK đã có sẵn hàm suy status 15 dòng thuần-tuý không side-effect (KhkkWorkflowPanel.tsx:106-120); chỉ cần KHKK tự chuẩn-hoá cây thô → shape có status trước khi truyền vào component generic — adapter MỘT chiều, nằm TRỌN trong nhánh KHKK, 0 dòng PE bị sửa.

Nhưng QUYẾT-ĐỊNH A vẫn đứng, vì một lý-do khác mạnh hơn: trích StepOpinionsBox ra khỏi PeDetailTabs.tsx:705-774 buộc phải sửa file trong components/pe/ — mâu-thuẫn trực-tiếp với acceptance A-10 của chính bản đó ("git diff --name-only → 0 path khớp components/pe/"). Cộng ≥6 nhánh riêng của PE (NS PRO/CCM · price-picker · 4 return-mode · CCM-done · skip-to-final · DeptApprovals legacy) ⇒ generic sẽ đầy if (module === …).

🔴 Ghi vào đầu mỗi file components/khkk/ (điều-kiện thoát, chống nợ im-lặng): "Khối ý-kiến cấp duyệt là bản thứ 3 của cùng một hình (PE PeDetailTabs.tsx:705-774 · HĐ components/contracts/WorkflowHistoryPanel.tsx). Gộp được khi và chỉ khi BE 3 module thống-nhất shape status precompute."

②-B. 🔴 Panel 1 = PipelineTreePanel — OWNER CHỐT 2026-08-04 (bác đề-xuất đảo của lead)

QUYẾT-ĐỊNH OWNER (ratified): anh chọn giữ đúng đề-xuất bản invest — panel 1 = cây toàn-trình PipelineTreePanel currentStage={2}. Lead đã trình phương-án đảo (danh-sách riêng KHKK) kèm đủ 4 rủi-ro đo được ngay trên thẻ chọn; anh đọc và vẫn chọn cây. ⇒ Đây là quyết-định có thông-tin đầy-đủ, thi-hành nguyên vẹn.

🔴 NHƯNG: chọn HÌNH ≠ chấp-nhận VỠ. 4 rủi-ro dưới đây không biến mất vì owner chọn cây — chúng chuyển từ "lý-do đổi thiết-kế" thành việc kỹ-thuật BẮT BUỘC phải xử. Mỗi cái có mitigation cưỡng-hành + acceptance riêng. CẤM thi-hành cây mà bỏ mitigation — làm thế là biến quyết-định của owner thành cái cớ cho một hồi-quy im-lặng.

4 rủi-ro + mitigation CƯỠNG-HÀNH (đi kèm, không tách rời)

Rủi-ro Bằng-chứng 🔧 Mitigation BẮT BUỘC
M-1 🔴 CAO — canPe khoá cây ⇒ user chỉ có quyền KHKK (VD người duyệt nhóm N5) thấy panel 1 TRỐNG, trong khi API GET /contract-signing-plans gate KeHoachKyKet.Read vẫn cho họ đọc. Display-layer khoá authz-layer đang mở — mặt còn lại gotcha #82 usePipelineStages.ts:55,57,83-84 (canPe = 3 key PE) · PipelineTreePanel.tsx:117 enabled:canPe · :222-226 in "Bạn chưa có quyền xem phiếu Duyệt NCC…" 🔴 FALLBACK !canPe → render DANH-SÁCH KHKK phẳng (card, cùng khuôn, không cây) trong đúng ô panel 1, nguồn GET /contract-signing-plans. Cây là lớp làm giàu, KHÔNG phải điều-kiện để thấy phiếu. ⇒ acceptance C-5 (runtime, tài-khoản KHKK-only)
R-11 trần 200 phiếu ⇒ gói thầu rơi khỏi 200 mới nhất thì mọi phiếu KHKK của gói đó biến mất, và cây không có "trang 2" PipelineTreePanel.tsx:22 PE_INDEX_PAGE_SIZE=200 · :112 · cảnh-báo :252-256 🔴 Giữ ô search + Select trạng-thái server-side trong khối filter panel 1 → khi lọc, đi thẳng GET /contract-signing-plans (không qua cây) ⇒ luôn có đường tới phiếu cũ. dòng cảnh-báo :252-256 PHẢI hiện, không được nuốt
M-2 🔴 CAO — cây tự văng khỏi màn 3-panel: bấm node KHKK ⇒ navigate('/khkk/'+id) rời trang vừa dựng usePipelineStages.ts:211 (×2 app) 🔴 Đổi onOpen thành callback do host truyền: onOpen: opts?.onSelectKhkk ?? (() => navigate(…)) — host /khkk/list truyền id => setParam({id}). Giữ nguyên hành-vi cũ cho mọi call-site khác (mặc-định navigate) ⇒ 0 hồi-quy trang PE/GĐ3
B1 🔴 content nuốt groups im lặng: PipelineStageFolders.tsx:285-287 đặt content TRƯỚC groups trong chuỗi tam-nguyên ⇒ truyền stage2Content thì 8 ngăn N1-N8 + ngăn "(chưa phân nhóm)" biến mất, không lỗi, không cảnh-báo; :95-97 quên stage2Count ⇒ badge về 0 tự đo grep -n -C4 🔴 KHAI TRADE-OFF, không im: trong cây của KHKK, folder GĐ2 hiện card phiếu thay 8 ngăn nhóm — chấp nhận được Select nhóm N1-N8 đã có ở khối filter panel 1 (P1-4). BẮT BUỘC truyền stage2Count cùng lúc. Additive: PurchaseEvaluationsListPage không truyền stage2Contenttrang PE giữ nguyên 8 ngăn ⇒ acceptance E-2

🔸 Ghi lại cho người đọc sau (bối-cảnh quyết-định, KHÔNG phải để lật lại)

Lead từng đề-xuất đảo sang "panel 1 = danh-sách riêng của KHKK" với lập-luận: panel 1 của PE không phải PipelineTreePanel mà là danh-sách của chính PE tự gom cây Năm > Dự án > Hạng mục từ yearGroups (PurchaseEvaluationsListPage.tsx:220-262, nguồn = query pe-list), tầng 4 mới gọi buildStages(wg.items, {stage1Content}) (:524). Owner đã nghe và chọn cây — nên hướng thi-hành là cây + 4 mitigation trên. Đoạn này giữ để người sau hiểu vì sao có mitigation, đừng đọc thành lời mời mở lại tranh-luận.


(Phần dưới giữ nguyên phân-tích gốc để tra-cứu — 4 rủi-ro đã có mitigation ở bảng trên.)

Nếu panel 1 = PipelineTreePanel (bản invest) Hậu-quả đo được
M-1 🔴 canPe khoá câyusePipelineStages.ts:55,57,83-84: canPe = ['PurchaseEvaluations','Pe_DuyetNcc_List','Pe_DuyetNccPhuongAn_List'].some(can); PipelineTreePanel.tsx:117 enabled:canPe, :222-226 in "Bạn chưa có quyền xem phiếu Duyệt NCC…" Người chỉ có quyền KHKK (VD người duyệt nhóm N5) ⇒ canPe=falsemất TRẮNG danh-sách phiếu của chính mình, trong khi API GET /contract-signing-plans gate KeHoachKyKet.Read vẫn cho phép. Đây là display-layer khoá cái authz-layer đang mở — mặt còn lại của gotcha #82. Mất chức-năng, không phải mất thẩm-mỹ.
R-11 trần 200PipelineTreePanel.tsx:22 PE_INDEX_PAGE_SIZE=200, cây dựng từ chỉ-mục PE Gói thầu rơi khỏi 200 ⇒ mọi phiếu KHKK của gói đó biến mất khỏi panel 1. Hôm nay vô hại vì panel 1 là bảng có phân-trang + tìm-kiếm server-side (KhkkListPage.tsx:112-129, total thật từ server). Đổi sang cây = thay danh-sách-có-phân-trang bằng cây-cắt-cụt; dòng cảnh-báo 10px xám không có "trang 2"
M-2 🔴 cây tự văng khỏi mànusePipelineStages.ts:211 onOpen: () => navigate('/khkk/'+k.id) (×2 app) Bấm node KHKK trong panel 1 ⇒ rời màn 3-panel vừa dựng, thay vì chọn vào panel 2
B1 🔴 content nuốt groupsPipelineStageFolders.tsx:285-287 chuỗi tam-nguyên đặt content TRƯỚC groups; :95-97 stageCount khi có content đọc count ?? 0 Truyền stage2Content8 ngăn N1-N8 + ngăn "(chưa phân nhóm)" biến mất, không lỗi, không cảnh-báo; quên stage2Count ⇒ badge về 0 trong khi folder đầy phiếu

🔴 Nhưng đọc kỹ mẫu PE thì hoá ra bản invest chép nhầm ĐỐI-TƯỢNG: panel 1 của PE KHÔNG phải PipelineTreePanel. Nó là danh-sách CỦA CHÍNH PE, tự gom cây Năm > Dự án > Hạng mục từ yearGroups (PurchaseEvaluationsListPage.tsx:220-262, nguồn = query pe-list), rồi tầng 4 mới gọi buildStages(wg.items, {stage1Content: <ul>…</ul>}) (:524) để re-parent phiếu vào folder GĐ1.

Mirror TRUNG-THÀNH = KHKK panel 1 dựng cây từ DANH-SÁCH CỦA CHÍNH KHKK, đổ card vào folder GĐ2 — đúng đối-xứng với PE đổ vào GĐ1. Và làm đúng thì M-1 · R-11 · M-2 tan hết, vì nguồn dữ-liệu là GET /contract-signing-plans (đã gate KeHoachKyKet.Read, có phân-trang server, có total thật), không đụng canPe, không đụng trần 200, không có navigate của hook.

Về B1 (content nuốt groups) — trade-off PHẢI KHAI, không được để im:

  • Thêm stage2Content/stage2Count vào BuildStagesOptionsadditive: PurchaseEvaluationsListPage không truyền nên giữ nguyên 8 ngăntrang PE KHÔNG bị đụng.
  • Trong cây của chính KHKK, truyền stage2Content ⇒ folder GĐ2 hiện card phiếu thay cho 8 ngăn nhóm. Chấp nhận được vì bộ lọc nhóm N1-N8 đã có mặt dạng Select ở khối filter panel 1 (P1-4) — không mất đường tới nhóm nào.
  • 🔴 BẮT BUỘC: truyền stage2Count cùng lúc (nếu không badge = 0), và acceptance A-10 phải mở rộng path (xem ③).

②-C. Bố-cục đích

/khkk/list  ← host 3-panel; khuôn ContractsListPage.tsx (GĐ3, 255 dòng) — KHÔNG chép
              PurchaseEvaluationsListPage.tsx (807 dòng, mang theo deleted/pendingMe/awId/overlay)
              🔸 CẢNH-BÁO: ContractsListPage.tsx CHỈ TỒN-TẠI Ở fe-admin (regression M-6).
                 Port sang fe-user = VIẾT MỚI, không phải copy.

├─ Panel 1  400px   components/khkk/KhkkListPanel.tsx      ← OWNER CHỐT: cây PipelineTreePanel
│     khối filter: search icon-trong-ô · Select trạng-thái · Select nhóm N1-N8
│                  (search/trạng-thái đi SERVER-SIDE, KHÔNG qua cây — mitigation R-11)
│     cây: PipelineTreePanel currentStage={2}
│          📅 Năm > 📁 Dự án > 🧱 Hạng mục > folder GĐ2 = card phiếu KHKK
│          onOpen = callback host (KHÔNG navigate — mitigation M-2)
│          stage2Content + stage2Count truyền CẶP (mitigation B1)
│     🔴 FALLBACK !canPe → danh-sách KHKK phẳng cùng ô (mitigation M-1)
│     🔴 GIỮ dòng cảnh-báo "Cây tải 200 phiếu mới nhất…" (PipelineTreePanel.tsx:252-256)
│     card 3 hàng: [chip ⚑ Kết thúc·Phòng N] + tên gói thầu + badge trạng-thái
│                  / mã font-mono + timestamp / 👤 người soạn + Σ tiền
│
├─ Panel 2  1fr     components/khkk/KhkkDetailContent.tsx
│     chưa chọn → EmptyState "Chọn phiếu ở danh sách"   🔴 F-1, xem ②-E
│     <PePipelineStrip current={2} />
│     <div className="divide-y divide-slate-200">
│       Section 1. Thông tin kế hoạch
│       Section 2. Dòng giá per-NCC trúng thầu (N NCC)
│       Section 3. Căn cứ hồ sơ (b.8-9)
│       Section 4. File đính kèm
│       Section 5. Ý KIẾN CẤP DUYỆT (sign-off theo workflow)   ← Ảnh 3 khoanh đỏ
│     readOnly = prop TỪ HOST (P2-7 đã lật): true khi phiếu ≠ Nháp HOẶC user ≠ drafter
│
└─ Panel 3  360px   components/khkk/KhkkWorkflowPanel.tsx   (dời từ pages/)
      chưa chọn → ✅ OWNER CHỐT: **CHỈ hộp dashed y PE** (khuôn PurchaseEvaluationsListPage.tsx:669-676
                  — border-dashed + icon X + "Quy trình duyệt sẽ hiện khi chọn phiếu.")
                  🔴 KHÔNG nhét PipelineTreePanel vào đây (khác GĐ3 — CÓ CHỦ ĐÍCH, vì cây
                     đã sống ở panel 1 theo QĐ owner câu 1 ⇒ nhét lần 2 = trùng)
                  ⇒ miễn-trừ-thứ-7 mà bản invest không khai nay đã ĐÓNG: quay về đúng Ảnh 1
      đã chọn  → 1 "Quy trình duyệt" + meta {code} v{NN} · {name}
                 2 Banner kết-thúc-sớm "… không qua CEO"
                 3 Sơ-đồ Bước → Cấp → NV  (+chip phòng · +⚑ Duyệt thay CEO
                                            · +mờ sau finalize · +liệt-kê NV 🔴 F-3)
                 4 Nút Duyệt / Trả lại / Từ chối + Dialog + nút Xóa
                 5 📎 File đính kèm khi duyệt        (🔴 F-6 — xem ②-E)
                 6 Lịch sử duyệt (N)                 (🔴 B4 — xem ②-E)
                 7 Lịch sử thay đổi

/khkk/:id GIỮ NGUYÊN route (deep-link · bookmark · fallback <lg), chỉ đổi ruột thành KhkkDetailContent + KhkkWorkflowPanel xếp dọc — khuôn PurchaseEvaluationDetailPage (:798-805).

②-D. Việc BE (nhỏ hơn tưởng, nhưng KHÔNG phải "+3 field")

🟢 Tin tốt đã được verify độc-lập: dữ-liệu lịch-sử ĐÃ ĐƯỢC GHI ĐẦY ĐỦ TỪ ĐẦU. Lane feasibility vào để lật claim này và không lật được: LogSigningPlanTransitionAsync6 call-site phủ TRỌN mọi transition — :204 Pending (gửi duyệt) · :283 :296 :311 :325 Approve · :393 Trả lại VÀ Từ chối (ApprovalDecision.Reject). db.ContractSigningPlanApprovals.Add(...):534 nằm thẳng trong thân hàm, không if nào bọc. ⇒ Entry "Trả lại → Lùi về Cấp 2" của Ảnh 2 SẼ CÓ dữ-liệu. Đừng cắt ngân-sách mục này vì nghi ngờ.

🔴 Đường-dẫn đúng (bản invest ghi thiếu lớp, người sau sẽ mở trượt): src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.csKHÔNG phải Application/ContractSigningPlans/.

# Việc Ghi-chú bắt buộc
BE-1 ContractSigningPlanDetailDto (ContractSigningPlanFeatures.cs:145-181, record positional 37 tham-số) +: WorkflowVersion · EndedByLevelFinalize · FinalizeStepName · FinalizeLevelName · List<ContractSigningPlanApprovalDto> Approvals 🟢 Chỉ 1 call-site new ContractSigningPlanDetailDto (:784) ⇒ append RẺ
BE-1b 🔴 FinalizeStepName/FinalizeLevelName KHÔNG phải "field append"ContractSigningPlan.cs:51 chỉ có bool EndedByLevelFinalize, không có cột nào lưu Bước/Cấp nào đã kết-thúc ⇒ phải DẪN XUẤT: port ~20 dòng projection có 3 subquery + nhánh 3-ngả từ PurchaseEvaluationFeatures.cs:665-683 Bản invest ước "+3 field" = sai chiều RẺ. Nó tự cảnh-báo "dễ ước-lượng sai đắt nhất theo cả hai chiều" rồi dính chiều còn lại
BE-2 🔴 ContractSigningPlanApprovalDto CHƯA TỒN-TẠI (grep0 hit) ⇒ đẻ record mới + projection + mirror types/khkk.ts ×2 app A-1 viết như thể chỉ "thêm field"
BE-3 ApprovedByUserId không kèm tên (:534-544 8 field, không có tên) trong khi hàng changelog ngay dưới (:546-557) UserName denorm 🔸 Rẻ hơn join Users: 2 bảng ghi cặp 1-1 cùng unit-of-work cùng now ⇒ lấy tên từ changelog cùng transition, hoặc denorm thêm cột tên vào Approval cho khớp anh-em
BE-4 ContractSigningPlanWorkflowStepDto +DepartmentName (join Departments) Nuôi 2 chỗ: chip phòng panel 3 (P3-4) header nhóm Section 5 (P2-5)
BE-5 GET /contract-signing-plans/{id}/changelogs 🔴 [Authorize(Policy = "KeHoachKyKet.Read")] — gotcha #85 (đúng key endpoint, KHÔNG OR key con)
BE-6 .Include/join ApprovalWorkflowLevels cho Approvals nếu muốn hiện "Cấp nào" (Ảnh 2 có "Lùi về Cấp 2") — ApprovalWorkflowLevelId là Guid trần (:541)
BE-7 OWNER CHỐT — file đính kèm khi duyệt, LÀM THẬT: ContractSigningPlanAttachment.cs:9-12 +ApprovalAttachment = 3 (append-only, KHÔNG đánh số lại Other=99) + endpoint upload gắn vào luồng dialog duyệt + lưu purpose=ApprovalAttachment 🔴 Endpoint mới ⇒ [Authorize(Policy = "KeHoachKyKet.Update")] (ghi, không phải Read) — gotcha #85 dùng ĐÚNG key của endpoint, KHÔNG OR key con. 🔴 KHÔNG chép khuôn authz của PurchaseEvaluationsController (xem cảnh-báo BE-5)

🔴 BE-5 — CẢNH-BÁO TẠI CHỖ, không chỉ ở checklist (regression M-5): mẫu PE mà lệnh "mirror PE" trỏ tới CHÍNH LÀ một lỗ #82: PurchaseEvaluationsController.cs:15 [Authorize] trần (any-auth) + :281 [HttpGet("{id:guid}/changelogs")] KHÔNG có [Authorize(Policy=…)] ⇒ endpoint changelogs của PE hiện chỉ cần đăng-nhập bất-kỳ là đọc được lịch-sử phiếu. Implementer trung-thành sẽ chép nguyên — đó là cách #82 lan sang KHKK. ĐỪNG CHÉP. (Vào sổ riêng: PE changelogs = ứng-viên #82 thứ hai bên cạnh POST /api/contracts đã treo.)

②-E. 5 khối bị bỏ sót / bẫy hiển-thị — PHẢI làm, không được im

# Việc Vì sao
F-1 🔴 EmptyState panel 2 "Chọn phiếu ở danh sách" + 2 nhánh Đang tải… / Không tìm thấy phiếu. — mẫu PurchaseEvaluationsListPage.tsx:644-656 Bản invest ghi sự-thật này ở §1.2 rồi đánh rơi: 0 dòng §3, 0 chỗ §5, 0 acceptance §7. Host 3-panel mà panel 2 trống trơn = màn hình trắng giữa, sai Ảnh 1 ở đúng trạng-thái ĐẦU TIÊN người dùng thấy
F-2 🔴 Chip ⚑ Kết thúc · Phòng N trên card panel 1 (PeFinalizeChip) Anh nhìn thấy nó trong Ảnh 3; P1-5 liệt-kê card thiếu đúng con chip này. Và nó 0 công BE — cột EndedByLevelFinalize đã có (Mig 71). Bỏ sót khối vừa-thấy-trong-ảnh vừa-rẻ-nhất
F-3 🔴 Liệt-kê NV trong mỗi Cấp: người duyệt thật in đậm, NV cùng cấp còn lại / Tên mờ — mẫu PeWorkflowPanel.tsx:453-465 Ảnh 2 mục 3 có. §3 chỉ có dòng cho badge vai, không có dòng cho bản-thân danh-sách NV ⇒ panel 3 sẽ không giống Ảnh 2 dù A-8a/b/c/d PASS hết
F-6 📎 File đính kèm khi duyệt — OWNER CHỐT: LÀM THẬT, cho upload.BE-7 (dưới): ContractSigningPlanAttachment.cs:9-12 +ApprovalAttachment = 3 (enum append-only, đúng comment nhà "Append-only khi cần thêm mục đích mới") + endpoint upload trong dialog duyệt + FE khối đọc purpose=ApprovalAttachment, tự-ẩn khi rỗng (khuôn PeWorkflowPanel.tsx:928-967) Chọn đường này ⇒ khối SỐNG THẬT trên UAT, và bẫy "UI chết mà acceptance vẫn xanh" tự tan — vì nay có đường ghi. Acceptance D-9 đổi sang runtime: upload 1 file lúc duyệt ⇒ khối phải HIỆN
B4 🔴 Decision = Pending của hành-vi GỬI duyệt (:204-207) nằm THẲNG trong Approvals ⇒ "Lịch sử duyệt (N)" của KHKK sẽ có nhiều dòng hơn số lần thật sự bấm Duyệt/Trả/Từ-chối. PE chống bằng merge + dedupe 5s-bucket + synthetic-reject (PeDetailTabs.tsx:3152-3192) Không xử ⇒ số (N) của KHKK không cùng nghĩa với số (N) của PE — trong khi cả đề-bài là "cho đồng nhất". Phải quyết: lọc Pending (giống PE) hay hiện đủ (nhật-ký). Khuyến-nghị: lọc, cho khớp mẫu
B3 Changelog ghi 10 chỗ (bản invest kể 8). Chỗ bị bỏ đáng kể: CreateContractFromSigningPlanFeatures.cs:226 = bắc-cầu KHKK→HĐ (K7) ⇒ "Lịch sử thay đổi" sẽ hiện entry tạo Hợp đồng — loại dòng FE chưa có nhãn. Thiếu ⇒ hiện chuỗi thô hoặc rơi im lặng

②-F. 4 bẫy hồi-quy — làm sai là vỡ cái đang chạy

# Bẫy Luật
M-4 🔴 A-6 của bản invest kiểm SAI KHOÁ. Cơ-chế thật: Layout.tsx:350 TRANSIENT_QUERY_KEYS = {id,q,editHeader,page,phase,awId}; :369 ignorable = transient && !pinnedByTarget. KHKK đọc URL đúng 3 khoá: filter(:74) · view(:76) · group(:79) — phase KHÔNG hề nằm trên URL Ghi filter/view/group vào URL ⇒ hồi-sinh bug S155 (đứng leaf ?group=1 bấm chip "Đã duyệt" → URL khớp CHÍNH XÁC leaf "Đã duyệt" ⇒ sidebar nhảy). Ghi phasevô hại (transient, 0 leaf ghim). ⇒ Acceptance phải cấm filter/view/group, KHÔNG phải phase — bản cũ chặn cái vô hại, cho lọt cái nguy hiểm
M-2 🔴 usePipelineStages.ts:211 onOpen: () => navigate('/khkk/'+k.id) (×2 app) Nếu còn dùng hook cho panel 1 ⇒ đổi thành callback do host truyền (onSelect(id)?id=), nếu không cây tự văng khỏi màn 3-panel
M-8 budgetFrozen nằm ở DetailDto, KHÔNG cóKhkkListItemDto (types/khkk.ts:296-301"CỜ HIỂN THỊ dẫn xuất từ phase, KHÔNG phải cột DB") Card panel 1 in một số Σ tiền ⇒ phải suy từ phase === DaDuyet (đúng như BE suy), đừng thêm field vào ListItemDto. Bảng cũ né được vì in cả hai cột (vá-8 K2 :246-251); chọn sai = tái-hiện đúng lỗi đã sửa
M-7 Bảng 8 cột (:241-252: Mã · Gói thầu · Dự án · Trạng thái · Tổng đề xuất · Tổng duyệt · Người soạn · Ngày tạo) + 6 KpiCard + phân-trang ép vào card 400px Card 3 hàng chứa ~4-5 mẩu tinPHẢI có bảng "cột nào chết, chuyển đi đâu" trong PR. Không có bảng đó = mất-chức-năng-im-lặng
M-10 KhkkDetailPage.tsx:258 xóa xong navigate('/khkk/list') rụng ?group= Ném người dùng về "tất cả nhóm". Giữ ?group= khi quay lại
R-5 :97-101set-state-trong-render CÓ CHỦ ĐÍCH (khuôn React "reset state khi input đổi"), không phải code lỗi Ai refactor cho "sạch" thành useEffect sẽ tái-hiện vá-5 (phải F5 mới đúng). ĐỪNG ĐỘNG
R-8 KHKK không có phiếu V1-legacy (module sinh 2026-07-29) KHÔNG port nhánh fallback V1 của PE (PeDetailTabs.tsx:434-436, PeWorkflowPanel.tsx:481-485) — code chết ngay ngày đầu

③ CHECKLIST (vai · deliverable · acceptance ĐO ĐƯỢC)

Vai thực-thi: implementer-backend (nhóm 1) → implementer-frontend (nhóm 2-3) → reviewer (gate) → cicd-monitor (deploy). APP ∈ {fe-admin, fe-user} — mọi mục FE phải PASS CẢ HAI.

🔴 4 acceptance của bản invest đã bị bẻ gãy khi CHẠY THẬT — dùng bản sửa dưới đây, KHÔNG dùng bản gốc. Bài học: chưa ai chạy thử chính các lệnh acceptance đó; hai cái được viết riêng để canh hai rủi-ro nguy nhất thì một cái kiểm nhầm khoá, một cái báo đỏ trên file sạch.

Nhóm 0 — trước khi viết dòng code đầu tiên

# Việc Acceptance
A-0 🟢 GỠ CHẶN — feasibility đã trả lời dứt. usePipelineLinkage() đã có sẵn query khkkIndex + khkkByPeId: Map<peId, KhkkListItemDto[]> (usePipelineStages.ts:123-134,147); buildStages gom bằng :204; toKhkkLeaf :205-212; stage2 :265-273 Không cần query mới, không cần endpoint mới cho panel 1. 🔸 Nếu ai vẫn đọc theo A-0 gốc: khoảng đúng là :100-320, không phải :150-320 — nguồn thật ở :123-134 nằm NGOÀI khoảng cũ

Nhóm 1 — BE

# Acceptance
B-1 grep -n "record ContractSigningPlanDetailDto" -A 80 src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs → thấy đủ 5 tên mới
B-2 grep -c "ContractSigningPlanApprovalDto" src/Backend ≥ 2 (định-nghĩa + call-site) — hôm nay là 0
B-3 🔴 RUNTIME, không phải đọc code: gọi thật 1 phiếu DaDuyetapprovals[]≥1 phần tử Decision=Reject (chứng chuỗi Trả lại được ghi) approvedByFullName ≠ null ∀ phần tử
B-4 grep -c "DepartmentName" …WorkflowStepDto ≥ 1
B-5 grep -n "changelogs" src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs → 1 hit, dòng [Authorize(Policy = "KeHoachKyKet.Read")] đứng ngay trên
B-6 🔴 A-13 THAY BẢN GỐC (bản gốc vỡ cả hai đầu: :15comment chứa "Authorize"+"Policy" nên bơm tử-số; và :17 :236 :241comment nhắc tên [Authorize] trần nên vế "0 dòng trần" FAIL trên file 100% sạch — citation-trap). Bản sửa neo đầu dòng, loại comment:
grep -cE '^\s*\[Http(Get|Post|Put|Delete|Patch)' <file> = R
grep -cE '^\s*\[Authorize\(Policy' <file> phải = 1 + R (hiện 19 = 1+18)
grep -cE '^\s*\[Authorize\]\s*$' <file> phải = 0
🧪 Chứng thước có RĂNG: cắm tạm 1 action [Authorize] trần vào cây tạm ⇒ số thứ 3 phải nhảy lên 1. Không nhảy = thước mù

Nhóm 2 — FE cấu-trúc

# Acceptance
C-1 ls $APP/src/components/khkk/≥3 file; grep -rn "pages/khkk/KhkkWorkflowPanel" $APP/src0 hit
C-2 🔴 A-4 THAY BẢN GỐC (gốc chấp mọi con số kể cả [200px_1fr_200px] ⇒ đo "có 3 panel", không đo "giống PE"; và FAIL nếu ai dùng rem như KHKK đang dùng 19rem/21rem). Bản sửa ghim literal: grep -c 'lg:grid-cols-\[400px_1fr_360px\]' $APP/src/pages/khkk/KhkkListPage.tsx = 1, grep -c 'h-\[calc(100vh-4rem)\]' = 1
🔸 Giải mâu-thuẫn F-7: §5 bản invest ghi 400px (số PE) nhưng Điều-kiện-1 bảo chép GĐ3 = 340px. Hiến-pháp owner là "giống nhất với duyệt NCC"chốt 400px (số PE), khuôn code lấy GĐ3
C-3 grep -c "matchMedia('(min-width: 1024px)')" $APP/src/pages/khkk/KhkkListPage.tsx ≥1 · grep -c 'path="/khkk/:id"' $APP/src/App.tsx = 1 (route CÒN SỐNG)
C-4 🔴 A-6 THAY BẢN GỐC (gốc cấm 'phase'/'group'phase không phải khoá URL của KHKKvô hại; filter/view mới là 2 khoá làm vỡ thì gốc không nhắc chữ nàosetSearchParams({group, filter:'DaDuyet'}) = đúng bug S155 vẫn PASS). Bản sửa: 0 hit 'filter' / 'view' / 'group' bên trong mọi lời gọi setSearchParams/setSp; chỉ 'id'/'q'/'page' được phép (∈ TRANSIENT_QUERY_KEYS, Layout.tsx:350)
🧪 Negative-control: cắm tạm setSearchParams({filter:'DaDuyet'}) ⇒ acceptance PHẢI TRƯỢT
C-5 🔴 Mitigation M-1 — acceptance QUAN-TRỌNG NHẤT của cả spec. Owner chốt panel 1 = cây ⇒ canPe sẽ khoá fetch cho user KHKK-only ⇒ BẮT BUỘC có fallback.
🔴 RUNTIME: đăng-nhập tài-khoản chỉ có KeHoachKyKet.* (KHÔNG có PurchaseEvaluations) ⇒ panel 1 vẫn liệt ĐỦ phiếu (dạng danh-sách phẳng, không cây).
🧪 Negative-control bắt buộc: gỡ tạm fallback ⇒ acceptance PHẢI TRƯỢT (panel 1 trống). Không trượt = thước mù, vì canPe của tài-khoản test có thể đang true do thừa quyền
C-6 🔴 Mitigation B1 — truyền stage2Content thì BẮT BUỘC kèm stage2Count (PipelineStageFolders.tsx:95-97 — thiếu ⇒ badge 0 trong khi folder đầy phiếu). Và khai trade-off trong PR: folder GĐ2 của cây KHKK hiện card phiếu thay 8 ngăn N1-N8 (lối vào nhóm giữ ở Select panel 1)
C-7 🔴 Mitigation M-2grep -c "navigate(\/khkk/" $APP/src/hooks/usePipelineStages.tsphải cho thấyonOpennhận **callback tuỳ chọn từ host**, mặc-định giữnavigatecũ. <br>🧪 **Chứng 0 hồi-quy:** mở **trang PE** và **trang GĐ3**, bấm node KHKK trên cây ⇒ **vẫn navigate như cũ** (chỉ host/khkk/listmới chặn lại thành?id=`)
C-8 🔴 Mitigation R-11 — ô search + Select trạng-thái panel 1 đi server-side thẳng GET /contract-signing-plans, KHÔNG lọc trên cây: grep -c "PE_INDEX_PAGE_SIZE|200" …/KhkkListPanel.tsx không được là đường duy nhất tới phiếu. dòng cảnh-báo PipelineTreePanel.tsx:252-256 "Cây tải 200 phiếu mới nhất…" PHẢI còn hiện (grep -c "200 phiếu mới nhất" ≥1) — nuốt cảnh-báo = giấu giới-hạn

Nhóm 3 — FE nội-dung (từng khối của ảnh)

# Acceptance
D-1 grep -c "PePipelineStrip" …/KhkkDetailContent.tsx = 1 kèm current={2}
D-2 🔴 A-7b THAY BẢN GỐC (gốc đòi 5 hit title="N. …" nhưng chạy trên chính mẫu PE chỉ ra 3 — vì Section 2 dùng template-literal title={`2. …(${n} hạng mục)`} và Section 4 là <section> trần ⇒ implementer viết ĐÚNG sẽ FAIL, và cách duy nhất để PASS là bỏ số đếm động cho vừa regex = Goodhart thuần). Bản sửa chấp cả literal lẫn template: grep -c '<Section title=' file = 5 grep -oE 'title=[{"]?[1-5].' file | wc -l` = 5
D-3 🔴 A-7d THAY BẢN GỐC (gốc grep -c "Ý kiến cấp duyệt" …WorkflowPanel.tsx = 0 ⇒ đổi tiêu-đề thành "Ý kiến duyệt" là PASS trong khi khối vẫn nằm nguyên panel 3 — đo chuỗi chữ, mục-tiêu là vị-trí khối). Bản sửa đo luồng dữ-liệu: grep -c "opinions" …/KhkkWorkflowPanel.tsx = 0 grep -c "levelOpinions|opinions" …/KhkkDetailContent.tsx1
D-4 Section 5 nhóm BƯỚC→PHÒNG, lưới 2 cột, meta x/y cấp · N NV: grep -c "md:grid-cols-2" ≥1 · grep -c "cấp đã duyệt" ≥1 · grep -c "NV tham gia" ≥1
D-5 🔴 F-1grep -c "Chọn phiếu ở danh sách" $APP/src/pages/khkk/KhkkListPage.tsx1 (+ 2 nhánh Đang tải… / Không tìm thấy phiếu.)
D-6 🔴 F-2grep -c "PeFinalizeChip|Kết thúc" …/KhkkListPanel.tsx1
D-7 🔴 F-3 — panel 3 liệt-kê NV mỗi Cấp, người duyệt in đậm + NV khác / Tên mờ: grep -c "font-semibold" …/KhkkWorkflowPanel.tsx ≥1 kèm đối-chiếu mắt với Ảnh 2
D-8 grep -c "không qua CEO" ≥1 · grep -c "departmentName" ≥1 · grep -c "Duyệt thay CEO" ≥1 · grep -c "opacity-50" ≥1
D-9 🔴 A-8c THAY BẢN GỐC (gốc grep-trên-source ⇒ khối UI CHẾT vẫn PASS 100%). Owner đã chốt làm thật ⇒ bẫy đó tan, nhưng thước vẫn phải RUNTIME: duyệt 1 phiếu có upload kèm 1 file ⇒ khối 📎 File đính kèm khi duyệt HIỆN trên màn với đúng file đó (tên · size · nút xem · nút tải).
🧪 Và kiểm vế tự-ẩn: phiếu duyệt không kèm file ⇒ khối KHÔNG hiện (khuôn PE :928-967)
D-10 grep -c "Lịch sử duyệt" ≥1 · grep -c "Lịch sử thay đổi" ≥1 grep -c "/changelogs" ≥1 (khối vô nghĩa nếu không fetch)
D-11 🔴 B4Approvals lọc bỏ Decision=Pending (hoặc khai rõ quyết-định ngược lại): số (N) của KHKK phải cùng nghĩa với số (N) của PE

Nhóm 4 — chống hồi-quy

# Acceptance
E-1 Mirror 2 app: sha256sum fe-admin/src/<p> = sha256sum fe-user/src/<p> ∀ file mới/sửa → PASS 100%, không trừ file nào
E-2 🔴 A-10 THAY BẢN GỐC (gốc liệt 4 path components/pe/ pages/pe/ components/contracts/ pages/contracts/thiếu đúng 2 path nguy hiểm: sửa hooks/usePipelineStages.ts hoặc components/pipeline/PipelineStageFolders.tsx có thể làm cây trang PE mất 8 ngăn GĐ2 mà A-10 vẫn PASS 100%). Bản sửa: giữ 4 path cũ = 0 hit; thêm hooks/usePipelineStages.ts + components/pipeline/ với nghĩa "chạm ⇒ BẮT BUỘC mở trang PE và trang HĐ đếm lại folder GĐ2 bằng mắt" (không cấm chạm, nhưng cấm chạm-mà-không-kiểm)
E-3 npm --prefix fe-admin run build npm --prefix fe-user run build → exit 0. 🔴 Chỉ tin build chạy SAU khi mọi agent nền dừng (gotcha #68)
E-4 dotnet test SolutionErp.slnx → 0 failed
E-5 🔴 UAT trên fe-user:8080 (R-1: Layout.tsx:227-231 ADMIN_HIDDEN_KEYS chứa KeHoachKyKetmenu KHKK không tồn tại bên admin). Chụp 3 màn khớp Ảnh 1/2/3, cửa-sổ ẩn-danh (bài S96 #77: cache ≠ deploy)
E-6 fe-admin phải UAT bằng DEEP-LINK http://localhost:8082/khkk/list?group=1 — menu ẩn nên không vào được bằng sidebar. Thiếu câu này ⇒ sẽ có báo-cáo "không thấy menu ⇒ chưa deploy" giả
E-7 PR có bảng "cột nào chết, chuyển đi đâu" (M-7): 8 cột bảng cũ + 6 KpiCard → card 400px

3 QUYẾT-ĐỊNH OWNER — chốt 2026-08-04, THI-HÀNH NGUYÊN VẸN

# Câu hỏi Owner chốt Hệ-quả lên spec
1 Panel 1 lấy nguồn nào? 🔴 PipelineTreePanel (giữ đề-xuất bản invest) — bác phương-án đảo của lead ②-B viết lại: cây + 4 mitigation CƯỠNG-HÀNH (fallback !canPe · search server-side · onOpen callback · stage2Count cặp) + acceptance C-5 · C-6 · C-7 · C-8. 🔴 Lead đã trình đủ 4 rủi-ro ngay trên thẻ chọn; owner đọc và vẫn chọn ⇒ quyết-định có thông-tin đầy-đủ. Nhưng chọn HÌNH ≠ chấp-nhận VỠ — mitigation là bắt buộc, không phải tuỳ chọn
2 Panel 3 khi chưa chọn hiện gì? CHỈ hộp dashed y PE (giống Ảnh 1 nhất) Đóng miễn-trừ-thứ-7 mà bản invest không khai: §5 gốc âm-thầm thay bằng PipelineTreePanel, nay quay về đúng Ảnh 1. Không nhét cây vào panel 3 (cây đã sống ở panel 1 theo QĐ-1 ⇒ nhét lần 2 là trùng). Khác GĐ3 ở điểm này = CÓ CHỦ ĐÍCH, ghi rõ trong PR
3 File đính kèm khi duyệt — hiện hay upload? Cho upload THẬT (thêm enum + endpoint) BE-7 mới: +ApprovalAttachment = 3 (append-only) + endpoint upload trong dialog duyệt, policy KeHoachKyKet.Update. Bẫy "UI chết mà acceptance vẫn xanh" (F-6) tự tan vì nay có đường ghi; acceptance D-9 đổi sang runtime 2 chiều (có file ⇒ hiện · không file ⇒ tự ẩn)

🔸 Còn treo (KHÔNG chặn thi-hành, hỏi khi tới)

P3-12 — 4 return-mode (Cấp / Bước / NV / Drafter). Bản invest xếp "buộc phải khác" với tiền-đề "KHKK là 3 trạm ngắn"; lead đo DbInitializer.cs:5543 Bước × ≤2 Cấp = tới 6 trạm, seed :620 tự ghi "sao từ workflow Duyệt NCC prod"tiền-đề sai, hình dạng y hệt PE. ⇒ Nó là hoãn, không phải buộc-khác. Đợt này giữ 1 mode (về Bước 1 · Cấp 1) như hiện tại; nếu anh muốn đủ 4 mode như PE thì đó là món riêng (đổi state machine BE), không nhét vào đợt UI này.


Nợ kỹ-thuật khai ngay (không giấu)

  • ContractsListPage.tsx (khuôn được chép) CHỈ TỒN-TẠI Ở fe-admin — port sang fe-user = viết mới, không phải copy (M-6).
  • Trích-dẫn hỏng trong bản invest, đã sửa ở spec này: path WorkflowService thiếu lớp Infrastructure/Services/ (B6) · :552 ResolveActorFullNameAsync không trỏ vào gì (call-site thật :485/:554, định-nghĩa ở file khác) · :547 changelog lệch 1 dòng (thật :546) · A-0 sai khoảng dòng (B5).
  • Lane reviewer lens-fidelity chết dở (hết giới-hạn phiên): §6 (phép đo P3-12) và §8 (lỗ-hổng tự khai) còn trống, dòng VERDICT còn ?. Lead đã tự chạy phép đo §6 (kết-quả ở ①, lật P3-12) nhưng §8 thì chưa ai làmdanh-sách "chưa đo được" của lăng-kính fidelity hiện KHÔNG TỒN-TẠI. Đừng đọc bản đó thành "đã soi hết trục trung-thành".
  • Chưa đo: fe-user twin của usePipelineStages.ts / PipelineStageFolders.tsx (feasibility C-1) · số phiếu KHKK thật ở prod để biết trần 200 có cắn không (C-4) · hideEmpty tương-tác với content/groups (C-3).