From 706134daa9607b7cf9cfa08fcad8495c8d631da1 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Sat, 8 Aug 2026 12:14:37 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Workflow:=20KHKK=208=20panel=20nh?= =?UTF-8?q?=C3=B3m=20mirror=20DuyetNcc=20+=20BE=20guards=20(0-mig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YC-013 (S182/L13). 8 leaf "Duyệt KHKK — Nhóm 1..8" ra đúng khuôn trang DuyetNcc: slice per-Code + lockedCode + Sửa-tại-chỗ/Tạo-version/Ghim/Xoá. D3=C (YC-014): 6 ô chưa-wire disabled-giữ-state-thật + tooltip trên label (Chromium không hiện title trên input disabled — MAJOR-1); ô #4 thêm chữ nhìn thấy được; #7 giữ; #8 Finalize 0-chạm (prod 8/88 dựa nó); ô CEO disable type-10 + POST null. BE 5 guard (0 migration): - W1a deactivate-per-Code cho type-10 (Code+Type+IsActive — cấm regex); Code normalize Trim().ToUpperInvariant() tại đường ghi + IgnoreCase belt trong GroupCodePattern (MAJOR-2: SQLite test case-sensitive nguoc prod CI_AS collation) - W1b ContractSigningPlanWorkflowGuards (public static, Application) — rào nhóm 3 cửa Create/UpdateDraft/Submit, giữ cho-qua-code-ngoài-họ - W1b2 (D6) pin-check IsActive+IsUserSelectable 2 cửa, 2 message tách - W1c changelog PUT type-10 vào ContractSigningPlanChangelogs - W1d DELETE usage-check 409 đích danh (8 module pin, IgnoreQueryFilters cho PE/Contract/KHKK; 5 module office pin loose-0FK — guard là rào duy nhất chống dangling) + 404 NotFoundException + D5 rào version-cuối nhóm (+ ApplicableType — MINOR-3) Test 645 → 660 (+14 spec + T15 D6-Create MAJOR-3); MINOR-1 pin nguyên câu thứ-tự 1 ca. File test thứ 3 (GroupAndCatalog) đổi 1 assertion theo câu 409 mới của W1b — spec-change, không phải nới bar. Khai accepted (reviewer PASS_WITH_FIXES 0C/3M+6m, đã vá 3M+2m): - MINOR-4: panel nhóm tạo-version-mới ⇒ 6 cờ khoá đóng băng default FE (vô hại — BE không đọc 6 cờ đó cho type-10) - MINOR-5: bỏ ghim/bỏ active bản cuối nhóm = đóng cửa tạo phiếu nhóm đó (răng thật của nút Ghim — không rào đợt này) - race 2-admin cùng Code → 2601/500 thô (UNIQUE đỡ data, chấp nhận) Evidence: .claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/ (3 fable-invest + 2 fable-review + spec FINAL + 4 lane + precheck prod 12 phiếu/pin-lệch-0 + reviewer pre-commit) Co-Authored-By: Claude Fable 5 --- docs/governance/so-yeu-cau-chu-du-an.md | 2 + fe-admin/src/lib/menuKeys.ts | 15 + fe-admin/src/pages/khkk/KhkkCreatePage.tsx | 26 +- .../pages/system/ApprovalWorkflowsV2Page.tsx | 249 ++++++-- fe-user/src/lib/menuKeys.ts | 14 + fe-user/src/pages/khkk/KhkkCreatePage.tsx | 26 +- .../ApprovalWorkflowV2AdminFeatures.cs | 208 ++++++- .../ContractSigningPlanFeatures.cs | 55 +- .../ContractSigningPlanWorkflowGuards.cs | 78 +++ .../ContractSigningPlanWorkflowService.cs | 10 + .../ContractSigningPlanApprovalTests.cs | 326 +++++++++- ...ContractSigningPlanGroupAndCatalogTests.cs | 117 +++- .../Application/UpdateAwDefinitionTests.cs | 567 +++++++++++++++++- 13 files changed, 1604 insertions(+), 89 deletions(-) create mode 100644 src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs diff --git a/docs/governance/so-yeu-cau-chu-du-an.md b/docs/governance/so-yeu-cau-chu-du-an.md index 76c8585..cf44484 100644 --- a/docs/governance/so-yeu-cau-chu-du-an.md +++ b/docs/governance/so-yeu-cau-chu-du-an.md @@ -43,6 +43,8 @@ Dữ-liệu cũ (WAL · HANDOFF · auto-memory · migration-todos) **KHÔNG** đ | `YC-010` | "OK làm hết rồi khép trọn đi" | 2026-08-07 · S181 | đóng | **2** | `WAL` chain W5b/W3b/W6 | Khép trọn gói: W5b + W3b + phần còn lại W6 | `YC-008` | | `YC-011` | "1,2 -> làm đi, OK sau đó /session-end" | 2026-08-07 · S181 | mở | 1 | `WAL` `next:` | (1) push 42 commit · (2) cổng đồng-bộ + báo cáo cuối · rồi đóng phiên | | | `YC-012` | "nâng cap" *(🔸 trích từ `spec-…-07-08-2026.md:64`, cửa-sổ **S180**, KHÔNG bắt trực tiếp ở cửa-sổ này — khai đúng nấc)* | 2026-08-07 · S180 | đóng | 1 | `memory-budget.json` `_cap_raised_S181` | Lệnh dứt khoát nâng `hmw_width.cap` 20→30. 🔴 @S179 lead **tự thu-hẹp** chữ "OK" thành "chưa phải lệnh đổi số" — lần này không tự thu-hẹp | | +| `YC-013` | "Hiện tạo muốn cac quy trình này giống như duyệt NCC. Có thể thêm sửa xóa edit stick tương tự như duyệt NCC. Cho 2 /fable-real invest cách làm BE và Database, 1 /fable-real invest cách cấu hình giao diện front-end đúng nhất về UI lẫn giao diện, ko lệch. -> Ra các wave xử lý chi tiết nhất. cho 2 /fable-real review và tổng hợp các wave xử lý chi tiết. Cuối cùng fan-out cho Opus 5 MAX xử lý nhé." *(kèm 4 ảnh trang `approval-workflows-v2/DuyetNcc` + dialog Sửa-tại-chỗ)* | 2026-08-08 · S182 | **mở** | 1 | `runs/2026-08-08-S182-khkk-awf-mirror-ncc/` | 8 workflow "Duyệt KHKK — Nhóm 1..8" phải có ĐỦ chức năng như trang "Duyệt NCC": xem steps/cấp/NV + per-NV quyền duyệt + Sửa-tại-chỗ + Tạo-phiên-bản-mới + Ghim/Bỏ-ghim + Xoá-version + lịch-sử-versions. UI **không lệch** khuôn DuyetNcc. Pipeline anh chỉ định: 3 `/fable-real investigator-codebase` (BE · DB · FE-UI) → wave-plan chi tiết → 2 `/fable-real reviewer` → hmw fan-out **Opus 5 MAX** execute | | +| `YC-014` | "C — Mờ ô chưa nghe (Recommended)" *(AskUser D3 @S182 — sau khi invest phát hiện 6/9 checkbox KHKK-runtime bỏ qua)* | 2026-08-08 · S182 | đóng | 1 | `spec-khkk-awf-mirror-ncc-DRAFT.md` MỤC 3 D3 | Dialog KHKK giữ layout y khuôn; ô máy-chưa-nghe **mờ + tooltip "chưa áp dụng cho KHKK"** (tiền lệ ô Duyệt-thẳng-Cấp-cuối @K3). KHÔNG port semantics đợt này (W3 hủy); port sau thì gỡ mờ dần | | > 🔴 **MỘT yêu-cầu, nhắc 4 lần.** Đây là dữ-liệu **TC-01** sinh ra để bắt, và nó tố-giác chính lead: hiểu sai ở `YC-002` nên anh phải nói lại 3 lần. **Ghi lại chứ không làm đẹp.** > diff --git a/fe-admin/src/lib/menuKeys.ts b/fe-admin/src/lib/menuKeys.ts index 3714bb6..51b4d2e 100644 --- a/fe-admin/src/lib/menuKeys.ts +++ b/fe-admin/src/lib/menuKeys.ts @@ -30,6 +30,21 @@ export const MenuKeys = { ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', AwV2_DuyetNcc: 'AwV2_DuyetNcc', AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', + // [W2e S182] 8 leaf Designer theo NHÓM DUYỆT của Kế hoạch ký kết HĐ. + // Mirror BE `MenuKeys.cs:127-134` (8 key này VÀO `All` ⇒ 32 policy AwV2_KhkkN{n}.{R,C,U,D}). + // ⚠️ File này là bản chép TAY của BE và hiện chỉ dùng cho `can(menuKey, action)`; + // 8 key dưới đây CHƯA có call-site nào — chép vào để bảng đối chiếu FE↔BE không + // khuyết, tránh lượt sau đọc chỗ này rồi kết luận "BE chưa có key nhóm". + // Đường vào sidebar KHÔNG đi qua đây: sidebar dựng từ `/api/menus/me` rồi + // `Layout.tsx:188` (`/^KhkkN[1-8]$/`) mới đổi key thành đường dẫn. + AwV2_KhkkN1: 'AwV2_KhkkN1', + AwV2_KhkkN2: 'AwV2_KhkkN2', + AwV2_KhkkN3: 'AwV2_KhkkN3', + AwV2_KhkkN4: 'AwV2_KhkkN4', + AwV2_KhkkN5: 'AwV2_KhkkN5', + AwV2_KhkkN6: 'AwV2_KhkkN6', + AwV2_KhkkN7: 'AwV2_KhkkN7', + AwV2_KhkkN8: 'AwV2_KhkkN8', // [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng // "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE. // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26) diff --git a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx index 7647df0..39bab3b 100644 --- a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx @@ -59,6 +59,11 @@ interface WorkflowDto { code: string name: string isUserSelectable: boolean + // [W2g S182] Cùng một mã `KHKK-N{n}` có NHIỀU phiên bản (admin bấm "Tạo phiên bản + // mới" thì bản cũ chuyển sang lưu trữ). `history` mà BE trả về là DANH SÁCH ĐẦY ĐỦ + // — cả bản đang áp dụng lẫn bản đã lưu trữ — nên thiếu cờ này thì màn tạo phiếu + // không phân biệt được, xem thêm chỗ dùng bên dưới. + isActive: boolean } // 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy @@ -110,9 +115,15 @@ export function KhkkCreatePage() { // `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code). // Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán // nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được. + // [W2g S182] 🔴 Phải lọc thêm `isActive`. Danh sách trả về gồm CẢ phiên bản đã lưu + // trữ, mà `find` lấy phần tử ĐẦU TIÊN khớp mã — hễ admin tạo phiên bản mới cho nhóm + // này là phiếu mới có thể bị gắn vào bản CŨ (thứ tự do BE trả, không do màn này + // quyết), đi theo đội hình duyệt đã bị thay. Gắn sai quy trình thì lúc phát hiện + // phiếu đã chạy được vài trạm rồi. Ràng buộc `isUserSelectable` ở query trên vẫn giữ + // (admin ghim bản nào thì user mới được dùng bản đó). const wantWorkflowCode = `KHKK-N${approvalGroup}` const autoWorkflow = workflows.data?.find( - (w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode, + (w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode, ) ?? null // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. @@ -125,7 +136,11 @@ export function KhkkCreatePage() { const create = useMutation({ mutationFn: async () => { if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') - if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`) + // [W2g S182] Câu này nói ĐIỀU KIỆN, không nói "không tồn tại": quy trình có thể + // đang nằm đó nhưng ở phiên bản đã lưu trữ hoặc chưa được ghim. + if (!autoWorkflow) { + throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode} đang áp dụng và được ghim cho user chọn`) + } const body: CreateKhkkInput = { peId: purchaseEvaluationId, // [F-2] BE Command field = PeId approvalWorkflowId: autoWorkflow.id, @@ -349,9 +364,10 @@ export function KhkkCreatePage() { {!workflows.isLoading && !autoWorkflow ? (

- ⚠ Chưa có quy trình mã {wantWorkflowCode} (hoặc chưa được ghim - cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống - KHÔNG tự dùng quy trình của nhóm khác. + ⚠ Chưa dùng được quy trình mã {wantWorkflowCode}: phải có + phiên bản đang áp dụngđã ghim cho user chọn. + Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống KHÔNG tự dùng + phiên bản đã lưu trữ, cũng KHÔNG tự dùng quy trình của nhóm khác.

) : (

diff --git a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx index 964c795..fb7980d 100644 --- a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx +++ b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx @@ -31,6 +31,9 @@ import { Textarea } from '@/components/ui/Textarea' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import type { Department, Paged } from '@/types/master' +// [W2 S182] 2 hàm dùng chung với màn nghiệp vụ (fe-user) — KHÔNG chép chuỗi +// `KHKK-N{n}` / nhãn nhóm vào file này, để 1 nguồn duy nhất khi BE đổi. +import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk' // ===== Types (mirror BE AwAdminOverviewDto) ===== @@ -286,7 +289,21 @@ function isLevelEnabled(step: EditStep, order: LevelOrder): boolean { export function ApprovalWorkflowsV2Page() { const qc = useQueryClient() const { typeCode } = useParams<{ typeCode?: string }>() - const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null + + // [W2a S182] 8 leaf `AwV2_KhkkN1..8` deep-link về CHÍNH trang này với typeCode + // `KhkkN{1..8}` (`Layout.tsx:188` — chỗ-thứ-4 Pattern 16-bis đã nối từ K3 S164). + // `KhkkN{n}` KHÔNG phải một ApplicableType riêng: cả 8 nhóm đều là type 10 + // (Kế hoạch ký kết HĐ), phân biệt nhau bằng MÃ quy trình `KHKK-N{n}`. Nên chỗ này + // tách 2 khái niệm: + // · `khkkGroup` = nhóm duyệt 1..8 (null nếu không vào từ leaf nhóm) + // · `selectedTypeInt` = ApplicableType gửi BE — nhóm nào cũng là 10. + // Hệ quả CÓ CHỦ ĐÍCH: queryKey của 8 panel nhóm + panel `/ContractSigningPlan` + // TRÙNG NHAU ⇒ 1 lần tải dùng chung, và invalidate sau khi lưu làm tươi cả 9 màn. + const khkkGroupMatch = typeCode?.match(/^KhkkN([1-8])$/) ?? null + const khkkGroup = khkkGroupMatch ? Number(khkkGroupMatch[1]) : null + const selectedTypeInt = khkkGroup !== null + ? TYPE_INT_CONTRACT_SIGNING_PLAN + : typeCode ? TYPE_CODE_TO_INT[typeCode] : null const overview = useQuery({ queryKey: ['approval-workflow-v2-overview', selectedTypeInt], @@ -296,25 +313,60 @@ export function ApprovalWorkflowsV2Page() { }, }) - const currentType = selectedTypeInt - ? overview.data?.types.find(t => t.applicableType === selectedTypeInt) + const typeBucket = selectedTypeInt + ? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null : null + // [W2b S182] Lát cắt TỔNG HỢP cho panel nhóm. BE trả 1 bucket cho CẢ type 10 + // (`active` = 1-trong-8 quy trình nhóm, tuỳ bản nào kích hoạt sau cùng), nên + // panel nhóm KHÔNG được đọc `bucket.active` — sẽ hiện nhầm quy trình của nhóm khác. + // Cắt lại từ `history`: + // 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE + // `ApprovalWorkflowV2AdminFeatures.cs:206-211` — không lọc `!IsActive`); + // chính `TypePanel` mới lọc `!isActive` lúc render mục "Lịch sử versions". + // · `active` suy TỪ chính lát cắt (`find(v => v.isActive)`), không mượn ô ngoài. + // Giữ nguyên shape `TypeSummaryDto` ⇒ `TypePanel`/`Designer` không cần biết + // chúng đang xem một nhóm hay cả loại. + const currentType: TypeSummaryDto | null = useMemo(() => { + if (khkkGroup === null) return typeBucket + if (!typeBucket) return null + const wantCode = khkkGroupWorkflowCode(khkkGroup) + const versions = typeBucket.history.filter(d => d.code === wantCode) + return { + applicableType: typeBucket.applicableType, + applicableTypeLabel: typeBucket.applicableTypeLabel, + active: versions.find(v => v.isActive) ?? null, + history: versions, + } + }, [khkkGroup, typeBucket]) + + // [W2c S182] Mã quy trình của panel nhóm là KHOÁ, không phải gợi ý: mỗi nhóm đúng + // MỘT chuỗi phiên bản `KHKK-N{n}`. Gõ mã khác trên panel nhóm = đẻ quy trình mồ côi + // (phiếu nhóm đó vẫn tìm `KHKK-N{n}` — `KhkkCreatePage.tsx:113`) ⇒ khoá thẳng ô Mã. + const lockedCode = khkkGroup !== null ? khkkGroupWorkflowCode(khkkGroup) : null + return (

- {currentType - ? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}` - : 'Quy trình duyệt (Mới)'} + {khkkGroup !== null + ? `Quy trình duyệt (Mới): ${khkkGroupMenuLabel(khkkGroup)}` + : currentType + ? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}` + : 'Quy trình duyệt (Mới)'} } description={ - currentType - ? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.' - : 'Schema mới UAT — chọn loại quy trình từ menu bên trái.' + khkkGroup !== null + ? `${currentType?.applicableTypeLabel ?? 'Kế hoạch ký kết HĐ'} — nhóm duyệt N${khkkGroup}. Mã quy trình khoá theo nhóm: ${lockedCode}; mỗi lần lưu là một phiên bản mới của CHÍNH nhóm này, 7 nhóm còn lại không đổi.` + : currentType + ? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.' + : 'Schema mới UAT — chọn loại quy trình từ menu bên trái.' } /> @@ -344,7 +396,11 @@ export function ApprovalWorkflowsV2Page() { {currentType && ( qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })} /> )} @@ -354,7 +410,17 @@ export function ApprovalWorkflowsV2Page() { // ===== Per-type panel ===== -function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) { +function TypePanel({ + type, + // [W2c S182] `null` = panel theo LOẠI (admin tự đặt mã, hành vi cũ). + // Khác null = panel theo NHÓM KHKK: mã bị khoá, mọi version đều thuộc chuỗi đó. + lockedCode, + onSaved, +}: { + type: TypeSummaryDto + lockedCode: string | null + onSaved: () => void +}) { const [designerOpen, setDesignerOpen] = useState(false) const [cloneFrom, setCloneFrom] = useState(null) // [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id + @@ -448,6 +514,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }} @@ -620,6 +687,7 @@ function DefinitionCard({ function Designer({ applicableType, applicableTypeLabel, + lockedCode, cloneFrom, editTarget, onClose, @@ -627,6 +695,8 @@ function Designer({ }: { applicableType: number applicableTypeLabel: string + // [W2c S182] Mã bị KHOÁ theo nhóm KHKK (`KHKK-N{n}`), null = tự do như cũ. + lockedCode: string | null cloneFrom: DefinitionDto | null // [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom. editTarget: DefinitionDto | null @@ -638,16 +708,38 @@ function Designer({ const isEdit = editTarget !== null const source = editTarget ?? cloneFrom - // [K3 S164 — GỠ KHOÁ] `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN: 2 ô "duyệt sớm" - // nay sống cho MỌI loại quy trình, kể cả Kế hoạch ký kết HĐ (type 10) — BE đã port - // đường finalize ở K3 cùng wave, và owner chốt TP.CCM là người quyết lên CEO hay - // kết thúc. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`). + // [W2d/W2f S182 — D3=C] Kế hoạch ký kết HĐ (type 10, gồm cả 8 panel nhóm) chưa + // dùng tới một phần cờ quyền của khuôn Duyệt NCC. Gom về ĐÚNG MỘT cờ để 8 ô + // (7 ô quyền + ô Ngưỡng CEO) không mỗi chỗ tự viết lại điều kiện rồi lệch nhau. + const isKhkk = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN + // Câu dùng chung cho các ô CHƯA có đường chạy ở KHKK. Ô nào có lý do RIÊNG thì + // viết riêng tại chỗ (xem ô "Trả về Drafter") — trạng thái tick vẫn là THẬT. + // 🔴 Câu nhắc phải gắn `title` trên
@@ -951,12 +1055,17 @@ function Designer({ // 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh // cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng // không gõ vào rồi mới nhận lỗi. Giá trị gửi lên lấy thẳng từ bản đang lưu. - disabled={isEdit} + // [W2f S182] KHKK khoá VÔ ĐIỀU KIỆN (kể cả tạo mới): quy trình KHKK không + // rẽ nhánh theo số tiền — người được uỷ quyền quyết. Tiền lệ cùng cách làm + // là ô "Duyệt thẳng Cấp cuối" bên dưới. + disabled={isEdit || isKhkk} />
- {isEdit - ? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.' - : 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'} + {isKhkk + ? 'KHKK không dùng ngưỡng máy — NĐUQ quyết. Ai được kết thúc quy trình thì tick ô "Duyệt là KẾT THÚC" ở đúng NV đó bên dưới.' + : isEdit + ? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.' + : 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
@@ -984,6 +1093,17 @@ function Designer({
ⓘ Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới (Trả lại / Edit Section 2 / Edit Budget / Duyệt thẳng Cấp cuối). + {/* [W2d S182 — D3=C] Nói thẳng lý do các ô bị mờ, thay vì để admin tự đoán + là "mất quyền" hay "lỗi". Ô mờ vẫn hiện ĐÚNG giá trị đang lưu trong DB. */} + {isKhkk && ( +
+ Riêng Kế hoạch ký kết HĐ: một số ô bị khoá vì luồng duyệt KHKK chưa dùng tới + (ô vẫn hiển thị đúng giá trị đang lưu, không bị đổi). Hai ô đang có tác dụng + thật là "Trả về Drafter" (KHKK luôn trả về + người soạn) và "Duyệt là KẾT THÚC" — tick cho NV + được uỷ quyền quyết dừng hay trình tiếp CEO. +
+ )}
@@ -1228,57 +1348,107 @@ function Designer({
Quyền duyệt {usersList.data?.find(u => u.id === entry.approverUserId)?.fullName ?? 'Chưa chọn NV'}
+ {/* [W2d S182 — D3=C] Với KHKK (type 10, gồm 8 panel nhóm) các ô + dưới đây bị KHOÁ vì luồng KHKK chưa chạy tới chúng. + 🔴 HAI điều KHÔNG được làm, vì đã có tiền lệ hỏng: + (1) KHÔNG ép `checked={false}`. Ô phải nói đúng thứ ĐANG NẰM + TRONG DB — bản seed có cờ nào bật thì phải thấy nó bật, + nếu không admin sẽ tưởng cờ đã tắt trong khi BE vẫn đọc. + (2) KHÔNG động vào payload (`levelFlagsPayload`): nó tiếp tục + echo NGUYÊN giá trị đang lưu. Khoá là chuyện MÀN HÌNH, + không phải chuyện dữ liệu — khoá bằng cách ghi đè dữ liệu + thì mở lại sau này không còn gì để mở. + Ô nào KHKK có chạy thì để nguyên: "Trả về Drafter" (hành vi thật + là LUÔN trả về người soạn) và "Duyệt là KẾT THÚC" (#8, sống). */}
-
{!workflows.isLoading && !autoWorkflow ? (

- ⚠ Chưa có quy trình mã {wantWorkflowCode} (hoặc chưa được ghim - cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống - KHÔNG tự dùng quy trình của nhóm khác. + ⚠ Chưa dùng được quy trình mã {wantWorkflowCode}: phải có + phiên bản đang áp dụngđã ghim cho user chọn. + Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống KHÔNG tự dùng + phiên bản đã lưu trữ, cũng KHÔNG tự dùng quy trình của nhóm khác.

) : (

diff --git a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs index 895eb05..f53fd6a 100644 --- a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs @@ -4,7 +4,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.ContractSigningPlans; // [S182 W1d] ContractSigningPlanWorkflowGuards.ParseGroup using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.ContractSigningPlans; // [S182 W1c] changelog + phase KHKK using SolutionErp.Domain.Identity; using SolutionErp.Domain.PurchaseEvaluations; @@ -330,21 +332,53 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db) { var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType; + // 🔴 [S182 MAJOR-2] CHUẨN HOÁ `Code` TẠI ĐƯỜNG GHI — nguồn duy nhất cho cả handler. + // + // Trước bản này BA thành phần hỏi cùng câu "code này thuộc nhóm nào" bằng BA luật khác nhau: + // • `ContractSigningPlanWorkflowGuards.GroupCodePattern` — regex .NET, case-SENSITIVE; + // • `w.Code == request.Code` — EF → SQL Server, theo collation `*_CI_AS` = case-INSENSITIVE; + // • FE `KhkkCreatePage` — `.trim().toUpperCase()` = case-INSENSITIVE. + // ⇒ admin gõ `khkk-n1` ở panel type-10 gộp (ô Mã KHÔNG khoá vì `lockedCode = null`): + // DB coi là trùng ⇒ hạ `IsActive` của `KHKK-N1` THẬT · FE vẫn auto-pin sang bản mới · + // nhưng regex trả `null` ⇒ rào nhóm 3-site và D5 IM LẶNG TẮT cho toàn bộ phiếu nhóm 1. + // Không lỗi nào báo. + // 🔴 Test KHÔNG thể bắt: 14 ca chạy SQLite, nơi `=` trên TEXT là BINARY (case-SENSITIVE) + // ⇒ ngữ nghĩa NGƯỢC prod. Mù theo CẤU TRÚC, không assertion nào bịt được ⇒ phải chặn + // bằng chuẩn-hoá, đừng chặn bằng test. + var code = request.Code.Trim().ToUpperInvariant(); + // Auto-increment version theo Code (cùng Code = cùng "logical" workflow) var nextVersion = await db.ApprovalWorkflows - .Where(w => w.Code == request.Code) + .Where(w => w.Code == code) .MaxAsync(w => (int?)w.Version, ct) ?? 0; nextVersion++; - // Deactivate active version cho ApplicableType này (only ONE active per type) - var actives = await db.ApprovalWorkflows - .Where(w => w.ApplicableType == typeEnum && w.IsActive) - .ToListAsync(ct); + // Tắt bản đang active — PHẠM VI phụ thuộc loại (bản trước: LUÔN per-type). + // + // • type-10 `ContractSigningPlan` (KHKK): **per-CODE**. Loại này có 8 quy trình + // SỐNG SONG SONG cùng type — mỗi nhóm duyệt N1..N8 một `Code` riêng + // (`DbInitializer.SeedKhkkGroupWorkflowsAsync:609`, seed cả 8 với IsActive=1). + // Tắt theo type ⇒ tạo version mới cho MỘT nhóm sẽ hạ IsActive của CẢ 7 nhóm còn + // lại (mỗi nhóm mất quy trình active ⇒ FE hết auto-pin ⇒ không tạo được phiếu). + // • các loại khác: **per-TYPE** như cũ — 1 active/type là bất biến của chúng. + // + // 🔴 [S182 R-6] Vị ngữ type-10 KHOÁ đúng 3 vế `Code == request.Code && + // ApplicableType == typeEnum && IsActive`. CẤM thay bằng biến-thể regex + // `^KHKK-N[1-8]$`: `Code` type-10 nằm ngoài họ đó (vd `QT-KHKK-V2-001`) sẽ rơi + // lại nhánh per-type và nuke đúng 8 nhóm mà rào này sinh ra để cứu. + // (`nextVersion` `:334-337` vốn đã tính per-Code ⇒ nhánh này làm 2 vế khớp nhau.) + var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan + ? await db.ApprovalWorkflows + .Where(w => w.Code == code && w.ApplicableType == typeEnum && w.IsActive) + .ToListAsync(ct) + : await db.ApprovalWorkflows + .Where(w => w.ApplicableType == typeEnum && w.IsActive) + .ToListAsync(ct); foreach (var old in actives) old.IsActive = false; var def = new ApprovalWorkflow { - Code = request.Code, + Code = code, // [S182 MAJOR-2] bản đã chuẩn hoá, KHÔNG dùng request.Code thô Version = nextVersion, ApplicableType = typeEnum, Name = request.Name, @@ -856,10 +890,17 @@ public class UpdateAwDefinitionCommandHandler( // được "quy trình vừa bị sửa lúc nào, ai sửa, vì sao". // ⚠️ KHAI GIỚI HẠN: repo CHƯA có bảng changelog cho quy trình duyệt, mà // đợt 1 bị chốt "no new table" ⇒ vết cho workflow thuộc 5 module Văn - // phòng số (Proposal/Leave/Ot/Travel/Vehicle) + KHKK (W1 S161 — reviewer F-6b: - // `ContractSigningPlanChangelogs` là changelog PHIẾU, không phải changelog QUY-TRÌNH; - // wire nếu cần ở W2/W3) hiện CHỈ có UpdatedAt/ + // phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/ // UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng. + // 🔄 [S182 W1c] KHKK (type-10) ĐÃ WIRE — xem nhánh `else if` bên dưới. Câu cũ ở + // đây xếp KHKK chung rọ "chỉ có UpdatedAt/UpdatedBy" và hẹn "wire nếu cần ở W2/W3"; + // đợt này wire thật nên câu đó gỡ, không để lại neo thối. + var note = string.Join(" | ", changes); + if (!string.IsNullOrWhiteSpace(request.Reason)) + note = $"Lý do: {request.Reason.Trim()} | {note}"; + var summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)"; + var actorName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống"; + if (changes.Count > 0 && def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc or ApprovalWorkflowApplicableType.DuyetNccPhuongAn) @@ -871,10 +912,6 @@ public class UpdateAwDefinitionCommandHandler( .Select(p => new { p.Id, p.Phase }) .ToListAsync(ct); - var note = string.Join(" | ", changes); - if (!string.IsNullOrWhiteSpace(request.Reason)) - note = $"Lý do: {request.Reason.Trim()} | {note}"; - foreach (var pe in affected) { db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog @@ -885,8 +922,48 @@ public class UpdateAwDefinitionCommandHandler( EntityId = def.Id, PhaseAtChange = pe.Phase, UserId = currentUser.UserId, - UserName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống", - Summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)", + UserName = actorName, + Summary = summary, + ContextNote = note, + }); + } + } + // [S182 W1c] KHKK (type-10) — mirror khối PE ngay trên, cùng shape 8 field. + // + // VÌ SAO CẦN: dialog PUT hứa nguyên văn với admin "ghi vào lịch sử phiếu đang chạy" + // (`ApprovalWorkflowsV2Page.tsx:963-977`). Với type-10 lời hứa đó trước nay là RỖNG — + // sửa quy trình xong lịch sử phiếu KHKK không có dòng nào. Đây là fix cho lời hứa + // thành thật, không phải tính năng mới. + // + // 🔴 Vị ngữ phiếu "đang chịu ảnh hưởng" = CHƯA kết thúc: loại 2 phase TERMINAL + // (`DaDuyet` chốt giá + `TuChoi`). `TraLai` KHÔNG loại — phiếu trả lại sẽ được + // trình LẠI và chạy đúng quy trình vừa sửa ⇒ người soạn cần thấy vết. + // 🔴 CỐ Ý KHÔNG `IgnoreQueryFilters()`: `ContractSigningPlans` có global filter + // (`ContractSigningPlanConfiguration.cs:55`) ⇒ phiếu XOÁ MỀM tự rụng khỏi tập. + // Đúng ý muốn: ghi changelog cho phiếu đã xoá là đẻ rác không ai đọc. (Ngược với + // rào DELETE bên dưới — ở đó xoá mềm VẪN phải chặn vì FK còn thật.) + else if (changes.Count > 0 && + def.ApplicableType is ApprovalWorkflowApplicableType.ContractSigningPlan) + { + var affectedPlans = await db.ContractSigningPlans.AsNoTracking() + .Where(p => p.ApprovalWorkflowId == def.Id + && p.Phase != ContractSigningPlanPhase.DaDuyet + && p.Phase != ContractSigningPlanPhase.TuChoi) + .Select(p => new { p.Id, p.Phase }) + .ToListAsync(ct); + + foreach (var plan in affectedPlans) + { + db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog + { + ContractSigningPlanId = plan.Id, + EntityType = ContractSigningPlanEntityType.Workflow, + Action = SolutionErp.Domain.Contracts.ChangelogAction.Update, + EntityId = def.Id, + PhaseAtChange = plan.Phase, + UserId = currentUser.UserId, + UserName = actorName, + Summary = summary, ContextNote = note, }); } @@ -1184,9 +1261,21 @@ public class SetAwUserSelectableCommandHandler(IApplicationDbContext db) } } -// ========== DELETE version (chỉ khi chưa có phiếu pin) ========== -// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT. -// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete. +// ========== DELETE version — CHẶN khi còn phiếu pin / còn chữ ký ========== +// +// 🔴 [S182 W1d] Câu cũ ở đây ("chưa có phiếu nào pin schema mới → unconditional delete OK cho +// UAT") đã HẾT ĐÚNG từ lâu: 8 module pin `ApprovalWorkflowId` thật trên prod. Xoá trần để +// lại 2 hậu quả khác hẳn nhau tuỳ module — và chính vì khác nhau nên rào này có HAI lý do, +// không phải một: +// • PE / Hợp đồng / KHKK có **FK vật lý** ⇒ xoá trần nổ SQL 547 = 500 thô. Rào = đổi +// 500 khó hiểu thành 409 đọc được. (UX) +// • Proposal / Leave / Ot / Travel / Vehicle pin **LOOSE, 0-FK** ⇒ DB không đỡ gì cả, +// xoá trần đi lọt ÊM và để lại con trỏ treo. Rào này là **hàng phòng thủ DUY NHẤT**. +// (TOÀN VẸN DỮ LIỆU — nặng hơn vế trên) +// +// Thứ tự kiểm: usage TRƯỚC (i+ii), D5 SAU (iv). Cả hai đều 409; usage đứng trước vì khi cả hai +// cùng đúng thì "5 phiếu đang gắn" là câu admin xử lý được, còn "nhóm phải còn ≥1 version" là +// luật chính sách — nói cái hành-động-được trước. public record DeleteAwDefinitionCommand(Guid Id) : IRequest; @@ -1195,9 +1284,90 @@ public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db) { public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct) { + // (iii) `NotFoundException` thay `KeyNotFoundException`: BCL exception KHÔNG được + // `GlobalExceptionMiddleware` map ⇒ id lạ trước nay ra 500 thô. Nay 404. var def = await db.ApprovalWorkflows .FirstOrDefaultAsync(d => d.Id == request.Id, ct) - ?? throw new KeyNotFoundException($"ApprovalWorkflow {request.Id} không tồn tại."); + ?? throw new NotFoundException("ApprovalWorkflow", request.Id); + + // ---- (i) Đếm phiếu ĐANG PIN, quét đủ 8 module có cột `ApprovalWorkflowId`. + // + // 🔴 `IgnoreQueryFilters()` cho PE / Contract / KHKK — 3 entity DUY NHẤT có global + // filter `!IsDeleted` (đã grep `HasQueryFilter` trong Configurations để chốt danh + // sách, không suy từ trí nhớ). Phiếu XOÁ MỀM vẫn CÒN ROW ⇒ FK vẫn thật ⇒ bỏ filter + // thì ta đếm ra 0 rồi xoá và nổ 547 đúng lúc chạy. 5 module Văn phòng số không có + // filter nên đếm thẳng. + var pinned = 0; + pinned += await db.PurchaseEvaluations.IgnoreQueryFilters() + .CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + pinned += await db.Contracts.IgnoreQueryFilters() + .CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + pinned += await db.ContractSigningPlans.IgnoreQueryFilters() + .CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + pinned += await db.Proposals.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + pinned += await db.LeaveRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + pinned += await db.OtRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + pinned += await db.TravelRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + pinned += await db.VehicleBookings.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct); + + // ---- (ii) Đếm CHỮ KÝ còn trỏ vào các dòng Cấp của quy trình này — 8 bảng + // `*LevelOpinion`. Phiếu có thể đã bỏ pin mà chữ ký vẫn còn (FK Restrict về + // `ApprovalWorkflowLevels`) ⇒ đếm pin thôi là chưa đủ để dám xoá. + // `IgnoreQueryFilters()` cả 8 cho ĐỒNG HÌNH — bảng nào không có filter thì gọi + // vô hại, bảng có filter mà quên gọi thì đếm hụt. + var levelIds = await db.ApprovalWorkflowLevels.AsNoTracking().IgnoreQueryFilters() + .Where(l => l.Step!.ApprovalWorkflowId == def.Id) + .Select(l => l.Id) + .ToListAsync(ct); + + var signatures = 0; + if (levelIds.Count > 0) + { + signatures += await db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + signatures += await db.ContractLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + signatures += await db.ContractSigningPlanLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + signatures += await db.ProposalLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + signatures += await db.LeaveRequestLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + signatures += await db.OtRequestLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + signatures += await db.TravelRequestLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + signatures += await db.VehicleBookingLevelOpinions.IgnoreQueryFilters() + .CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct); + } + + if (pinned > 0 || signatures > 0) + throw new ConflictException( + $"Quy trình '{def.Code}' v{def.Version} đang được dùng: {pinned} hồ sơ đang gắn " + + $"+ {signatures} chữ ký đã lưu — không xoá được. " + + "Muốn ngừng dùng thì bỏ kích hoạt (hoặc bỏ ghim) thay vì xoá."); + + // ---- (iv) [D5] Nhóm duyệt KHKK phải LUÔN còn ít nhất 1 version. + // + // Seeder `SeedKhkkGroupWorkflowsAsync` idempotent PER-CODE (`DbInitializer.cs:610`: + // `if (Any(w.Code == code)) continue;`) ⇒ xoá SẠCH một mã `KHKK-N{n}` thì lần khởi + // động sau seeder đẻ LẠI bản v01 mặc định — roster + cờ mà admin đã chỉnh biến mất + // âm thầm, không có lỗi nào báo. Rào ở đây đóng đường UI dẫn tới ca đó. + // (Version 0-usage mà KHÔNG phải bản cuối của mã ⇒ vẫn xoá bình thường.) + if (ContractSigningPlanWorkflowGuards.ParseGroup(def.Code) is int group) + { + // [S182 MINOR-3] Lọc CẢ `ApplicableType`: UNIQUE là `(Code, Version)` chứ không kèm + // type ⇒ một row `KHKK-N{n}` ở type KHÁC (Version khác) sẽ bị tính là "anh em" và + // cho phép xoá bản type-10 CUỐI CÙNG — đúng ca mà D5 sinh ra để chặn. + var siblings = await db.ApprovalWorkflows + .CountAsync(w => w.Code == def.Code + && w.ApplicableType == def.ApplicableType + && w.Id != def.Id, ct); + if (siblings == 0) + throw new ConflictException( + $"Nhóm N{group} phải còn ≥1 version — đây là version cuối cùng của quy trình " + + $"'{def.Code}'. Tạo version mới trước, rồi mới xoá bản này."); + } db.ApprovalWorkflows.Remove(def); await db.SaveChangesAsync(ct); diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index 1ade403..c1082de 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -317,6 +317,34 @@ internal static class ContractSigningPlanScope throw new ConflictException("Quy trình duyệt không thuộc loại Kế hoạch ký kết HĐ."); } + /// [S182 W1b2 — D6] Quy trình có còn được phép PIN MỚI không: `IsActive` ∧ `IsUserSelectable`. + /// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147`. + /// + /// 🔴 CHỈ gọi ở đường ĐẶT PIN MỚI (tạo phiếu / sửa nháp có ĐỔI quy trình). CỐ Ý KHÔNG gọi + /// khi giữ nguyên pin cũ: phiếu lỡ pin bản đã archived vẫn phải sửa được GhiChu/HoSoLink, + /// nếu không thì admin ngừng một quy trình là khoá cứng mọi phiếu nháp đang trỏ vào nó. + /// Cũng KHÔNG gọi ở đường TRÌNH duyệt vì lý do đó (D6 chốt 2 site). + /// + /// 2 cờ ném 2 message TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): "đã ngừng + /// hoạt động" và "chưa ghim" sửa ở 2 nút khác nhau trong Designer — gộp thì người đọc lỗi + /// không biết bấm nút nào. + internal static async Task EnsureWorkflowPinnableAsync( + IApplicationDbContext db, Guid approvalWorkflowId, CancellationToken ct) + { + var wf = await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Id == approvalWorkflowId) + .Select(w => new { w.Code, w.IsActive, w.IsUserSelectable }) + .FirstOrDefaultAsync(ct); + if (wf is null) + throw new NotFoundException("ApprovalWorkflow", approvalWorkflowId); + if (!wf.IsActive) + throw new ConflictException( + $"Quy trình '{wf.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động."); + if (!wf.IsUserSelectable) + throw new ConflictException( + $"Quy trình '{wf.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác."); + } + /// Precompute ID phiếu mà `userId` là approver của Cấp ĐANG CHỜ — mirror /// `PurchaseEvaluationFeatures.cs:817-852 ResolveV2InboxIdsAsync`. /// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list @@ -472,18 +500,20 @@ public class CreateContractSigningPlanCommandHandler( // ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`. await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct); + // ---- Rào (iv-bis) [S182 W1b2 — D6] Quy trình phải CÒN DÙNG ĐƯỢC lúc pin. + // Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147` (cùng lỗ, đã bịt cho + // type-3): FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì pin được bản đã + // archived ⇒ phiếu trình xong treo ở quy trình không ai bảo trì. + await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, request.ApprovalWorkflowId, ct); + // ---- Rào (v) [K3 S165 — QĐ-3 "K3 chỉ ĐỌC ApprovalGroup"] Workflow họ nhóm // `KHKK-N{n}` ⇒ n PHẢI khớp nhóm duyệt của phiếu (phiếu nhóm 3 pin quy trình nhóm 5 // = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho // 8 workflow nhóm seed @K3. - var wfCode = await db.ApprovalWorkflows.AsNoTracking() - .Where(w => w.Id == request.ApprovalWorkflowId) - .Select(w => w.Code).FirstOrDefaultAsync(ct); - var groupMatch = System.Text.RegularExpressions.Regex.Match(wfCode ?? string.Empty, "^KHKK-N([1-8])$"); - if (groupMatch.Success && int.Parse(groupMatch.Groups[1].Value) != approvalGroup) - throw new ConflictException( - $"Quy trình '{wfCode}' thuộc nhóm duyệt N{groupMatch.Groups[1].Value}, " + - $"không khớp nhóm duyệt N{approvalGroup} của phiếu — chọn đúng quy trình của nhóm."); + // [S182 W1b] Thân rào DỜI sang `ContractSigningPlanWorkflowGuards` — cùng luật này + // nay chặn ở CẢ 3 cửa (tạo / sửa nháp re-pin / trình duyệt), trước chỉ có cửa này. + await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync( + db, request.ApprovalWorkflowId, approvalGroup, ct); // 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT // (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE + @@ -600,9 +630,18 @@ public class UpdateContractSigningPlanDraftCommandHandler( ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser); // Re-pin quy trình: guard type LẠI (workflow có thể bị admin đổi loại giữa chừng). + // + // [S182 W1b/W1b2] Cửa này trước KHÔNG có rào nhóm ⇒ tạo phiếu đúng nhóm rồi sửa nháp + // pin sang quy trình nhóm khác là lọt. Nay đủ 3 rào như đường TẠO. + // 🔴 Cả 3 nằm TRONG nhánh `awId != plan.ApprovalWorkflowId` (đường ĐỔI pin) — CỐ Ý: + // client gửi lại đúng workflow cũ (form "Sửa" echo nguyên field) không được biến + // thành 409, nếu không thì sửa GhiChu của phiếu pin bản archived sẽ hỏng (D6). if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId) { await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, awId, ct); + await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, awId, ct); + await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync( + db, awId, plan.ApprovalGroup, ct); plan.ApprovalWorkflowId = awId; } diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs new file mode 100644 index 0000000..3f4638f --- /dev/null +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanWorkflowGuards.cs @@ -0,0 +1,78 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; + +namespace SolutionErp.Application.ContractSigningPlans; + +// [S182 W1b — YC-013] Rào DÙNG CHUNG cho quan hệ "nhóm duyệt của phiếu" ⟷ "nhóm duyệt của +// quy trình đã pin". Trước đợt này logic chỉ nằm ở MỘT chỗ (đường TẠO phiếu, +// `ContractSigningPlanFeatures.cs` rào (v) — K3 S165) ⇒ hai cửa còn lại (sửa nháp re-pin, +// và TRÌNH duyệt) đi lọt: phiếu nhóm 3 vẫn trình được bằng quy trình nhóm 5. +// +// 🔴 VÌ SAO `public static class` ở tầng Application chứ không nhét vào +// `ContractSigningPlanScope` (internal, cùng file Features): +// site thứ 3 là `ContractSigningPlanWorkflowService.SubmitAsync` nằm ở **Infrastructure** +// ⇒ không thấy `internal` của Application. Ba lối đã cân nhắc và loại: +// • nới `Scope` thành public → mở TOÀN BỘ helper nội bộ ra ngoài, chỉ để dùng 1 method; +// • `InternalsVisibleTo(Infrastructure)` → mở cả assembly, cùng bệnh, còn khó soi hơn; +// • copy-inline sang Service → 2 bản luật ĐI LỆCH NHAU theo thời gian (đúng bug-class +// đang chữa: một chỗ có rào, chỗ kia không). +// ⇒ tách file riêng, đúng một method public, ba site cùng gọi. +public static class ContractSigningPlanWorkflowGuards +{ + // Họ mã quy trình nhóm duyệt: `KHKK-N1` … `KHKK-N8`, seed ở + // `DbInitializer.SeedKhkkGroupWorkflowsAsync` (`DbInitializer.cs:609` — `$"KHKK-N{n}"`). + // + // 🔴 ĐÂY LÀ NHÀ DUY NHẤT của mẫu này phía Application. Trước đợt này mẫu được gõ tay + // trong `ContractSigningPlanFeatures.cs:482`; W1d cần lại đúng mẫu đó ở + // `ApprovalWorkflowV2AdminFeatures` (rào xoá version cuối) ⇒ nếu chép lần nữa thì có + // 3 bản chuỗi rời nhau, sửa 1 quên 2. Ai cần "code này thuộc nhóm mấy" thì gọi + // `ParseGroup`, đừng viết lại regex. + // + // ⚠️ ĐỪNG nhầm với rào W1a bên `ApprovalWorkflowV2AdminFeatures` (deactivate-per-Code): + // ở ĐÓ regex bị CẤM (R-6) vì phạm vi tắt-active phải phủ MỌI code type-10, kể cả code + // ngoài họ. Ở ĐÂY regex mới là đúng việc: mục đích của rào là NHẬN DIỆN họ `KHKK-N`, + // và code ngoài họ được cho qua CÓ CHỦ ĐÍCH (xem `EnsureWorkflowGroupMatchAsync`). + // 🔴 [S182 MAJOR-2] `IgnoreCase` = BELT cho dữ-liệu CŨ. Đường ghi nay đã chuẩn hoá + // `Code` về HOA (`CreateAwDefinitionCommandHandler` — `request.Code.Trim().ToUpperInvariant()`), + // nhưng row có TRƯỚC bản vá thì chưa qua cửa đó. Thiếu cờ này, một `khkk-n1` tồn dư + // sẽ trả `null` ⇒ rào nhóm 3-site + D5 im lặng TẮT cho cả nhóm 1, trong khi SQL Server + // (collation `*_CI_AS`) vẫn coi nó trùng `KHKK-N1` ⇒ hai tầng nói ngược nhau. + // Suspenders ⟂ belt: chuẩn-hoá chặn dữ-liệu MỚI, cờ này tha-thứ dữ-liệu CŨ. + private static readonly Regex GroupCodePattern = + new(@"^KHKK-N([1-8])$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + /// Số nhóm duyệt suy ra từ mã quy trình, hoặc `null` nếu mã KHÔNG thuộc họ `KHKK-N[1-8]`. + public static int? ParseGroup(string? workflowCode) + { + var m = GroupCodePattern.Match(workflowCode ?? string.Empty); + return m.Success ? int.Parse(m.Groups[1].Value) : null; + } + + /// Quy trình thuộc họ nhóm `KHKK-N{k}` ⇒ `k` PHẢI khớp nhóm duyệt của phiếu, nếu không → 409. + /// + /// 🔴 GIỮ NGUYÊN semantics "cho qua code NGOÀI họ" của bản gốc + /// (`ContractSigningPlanFeatures.cs:477-478` — chủ đích, không phải sót): ràng buộc này + /// chỉ nói về 8 workflow nhóm seed @K3. Quy trình type-10 đặt tay (vd `QT-KHKK-V2-001`) + /// không mang thông tin nhóm trong mã ⇒ không có gì để đối chiếu, chặn nó là bịa ràng + /// buộc. Rào loại (`EnsureWorkflowTypeAsync`) mới là chỗ chặn quy trình sai LOẠI. + /// + /// Mã không tồn tại (`awId` rác) cũng rơi vào nhánh cho-qua: guard NotFound là việc của + /// `EnsureWorkflowTypeAsync` (Create/UpdateDraft) — chồng thêm ở đây sẽ đẻ 2 nguồn 404 + /// cho cùng một nguyên nhân. + public static async Task EnsureWorkflowGroupMatchAsync( + IApplicationDbContext db, Guid awId, int approvalGroup, CancellationToken ct) + { + var code = await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Id == awId) + .Select(w => w.Code) + .FirstOrDefaultAsync(ct); + + var wfGroup = ParseGroup(code); + if (wfGroup is int k && k != approvalGroup) + throw new ConflictException( + $"Phiếu nhóm N{approvalGroup} đang gắn quy trình N{k} — " + + "vào phiếu (Nháp/Trả lại) đổi lại quy trình đúng nhóm trước khi trình."); + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs index c4037d6..039d062 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.ContractSigningPlans; // ContractSigningPlanWorkflowGuards (rào nhóm dùng chung 3 site) using SolutionErp.Application.ContractSigningPlans.Services; using SolutionErp.Application.Notifications; using SolutionErp.Domain.ApprovalWorkflowsV2; @@ -186,6 +187,15 @@ public class ContractSigningPlanWorkflowService( throw new ConflictException( $"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}."); + // ── [S182 W1b] Rào NHÓM ⟷ QUY TRÌNH, site 3/3 (2 site kia ở đường tạo/sửa nháp). + // Cửa này là chốt CUỐI và là cửa DUY NHẤT bắt được phiếu pin lệch có TỪ TRƯỚC: hai + // rào kia chỉ chặn lúc GHI, nên phiếu tạo trước khi có rào (hoặc trước khi admin đổi + // mã quy trình) vẫn nằm sẵn trong DB với nhóm lệch. Đứng ngay trên `LoadStepsAsync` + // để phiếu lệch không kịp bước vào ChoDuyet rồi mới lộ. + // Thân luật + lý do "cho qua code ngoài họ": `ContractSigningPlanWorkflowGuards`. + await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync( + db, awId, plan.ApprovalGroup, ct); + var steps = await LoadStepsAsync(awId, ct); var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList(); if (firstLevelGroup.Count == 0) diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs index 190b4db..85d3a5f 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs @@ -131,7 +131,13 @@ public class ContractSigningPlanApprovalTests string code, Guid[][][] steps, int stepOrderStart = 1, - int stepOrderStride = 1) + int stepOrderStride = 1, + // [S182 W1 T7] 3 tham số đuôi cho rào D6 (pin-check IsActive ∧ IsUserSelectable) và + // cho việc dựng 2 version CÙNG Code (UNIQUE là (Code, Version)). Optional trailing ⇒ + // mọi call-site cũ giữ nguyên chữ. + int version = 1, + bool isActive = true, + bool isUserSelectable = true) { await using var db = f.NewDb(TestCurrentUser.System()); @@ -139,13 +145,13 @@ public class ContractSigningPlanApprovalTests { Id = Guid.NewGuid(), Code = code, - Version = 1, + Version = version, Name = "Quy trình duyệt Kế hoạch ký kết", // Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create // của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`). ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan, - IsActive = true, - IsUserSelectable = true, + IsActive = isActive, + IsUserSelectable = isUserSelectable, }; db.ApprovalWorkflows.Add(wf); @@ -193,21 +199,29 @@ public class ContractSigningPlanApprovalTests ContractSigningPlanPhase phase, int? stepIndex, int? levelOrder, - LineSeed[] lines) + LineSeed[] lines, + // [S182 W1 T4-T7] Nhóm duyệt của phiếu. Hạng mục fixture ĐI THEO nhóm này (cùng giá trị) + // ⇒ rào hạng mục `…WorkflowService.cs:182-187` luôn cho qua, và mọi 409 quan sát được + // trong T5 chỉ có thể đến từ rào NHÓM-QUY-TRÌNH. Trailing optional ⇒ 12 call-site cũ giữ nguyên. + int approvalGroup = 1) { // Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)). await using var db = f.NewDb(drafter); // [K2 S164 — vá-5] SubmitAsync nay CHẶN dòng chưa gán hạng mục + hạng mục khác nhóm. - // Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu (entity default ApprovalGroup=1) - // — mirror phiếu "đã gán đủ" trước trình; Code random né unique-filtered giữa các plan. + // Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu — mirror phiếu "đã gán đủ" + // trước trình; Code random né unique-filtered giữa các plan. + // 🔴 [S182 W1] Hạng mục ĐI THEO `approvalGroup` của phiếu (không còn hằng 1): T5 seed + // phiếu nhóm 3, nếu hạng mục vẫn nhóm 1 thì `SubmitAsync` ném 409 của rào HẠNG MỤC + // (`ContractSigningPlanWorkflowService.cs:182-187`) và test sẽ "xanh vì nhầm cửa" — + // đo đúng chữ 409 nhưng sai hoàn toàn cơ chế. var catalog = new ContractCatalogEntry { Id = Guid.NewGuid(), Code = "T-" + Guid.NewGuid().ToString("N")[..8], TenVi = "Hạng mục test W3", GroupCode = "B1", - ApprovalGroup = 1, + ApprovalGroup = approvalGroup, SignerRole = "TP.PRO", IsActive = true, SortOrder = 1, @@ -225,6 +239,7 @@ public class ContractSigningPlanApprovalTests DepartmentId = Guid.NewGuid(), DrafterUserId = drafter.UserId!.Value, Phase = phase, + ApprovalGroup = approvalGroup, ApprovalWorkflowId = workflowId, CurrentWorkflowStepIndex = stepIndex, CurrentApprovalLevelOrder = levelOrder, @@ -1138,4 +1153,299 @@ public class ContractSigningPlanApprovalTests .Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize)) .ToListAsync(CancellationToken.None); } + + // ################################################################### + // [S182 W1 — YC-013, 2026-08-08] T4-T7: rào NHÓM-QUY-TRÌNH 3-site + pin-check D6. + // SPEC: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/ + // spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1 fix (b) + (b2). + // + // 🔴 VẤN ĐỀ: rào "phiếu nhóm N{g} chỉ pin được quy trình `KHKK-N{g}`" hôm nay chỉ đứng ở + // ĐÚNG MỘT CỬA — `CreateContractSigningPlanCommandHandler` (`Features.cs:479-486`). + // Hai cửa còn lại đi thẳng: UpdateDraft `:603-607` chỉ gọi `EnsureWorkflowTypeAsync`, + // và `SubmitAsync` (`ContractSigningPlanWorkflowService.cs:176-189`) không hỏi câu này + // bao giờ. Hệ quả không phải "409 muộn" mà là KÝ SAI DÂY CHUYỀN: phiếu nhóm 3 chạy trạm + // của nhóm 5 ⇒ hồ sơ tới tay người không có thẩm quyền ký, im lặng, đúng luồng UI. + // W1b gom logic vào `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync` + // và cắm đủ 3 cửa. + // + // ⛔ KHÔNG nhân bản cửa Create ở đây. Cửa đó ĐÃ có lưới đủ răng từ K3/S164 — + // `ContractSigningPlanGroupAndCatalogTests.Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa` + // (chặn sai-nhóm + cho-qua-ngoài-họ + không-đốt-mã), đang XANH ở baseline. Nó chính là + // chứng-nhân "phép dời logic sang class dùng chung KHÔNG đổi ngữ nghĩa cửa cũ"; chép lại + // 60 dòng seed PE vào file này chỉ đẻ bản sao đo cùng một thứ. T4-T7 chỉ viết phần nó + // KHÔNG đo: 2 cửa mới + rào D6. + // + // 🔎 Chữ trong thông điệp 409: spec đưa RA HAI bản (giữ nguyên câu Create `:483-486`, hoặc + // câu mới W1b "Phiếu nhóm N{g} đang gắn quy trình N{k}…"). Cả hai đều hợp lệ ⇒ test pin + // cái BẤT BIẾN giữa hai bản: thông điệp phải nêu CẢ nhóm-của-phiếu LẪN nhóm-của-quy-trình, + // vì thiếu một trong hai thì người dùng đọc xong vẫn không biết phải đổi cái gì. + // (Nếu BE chọn câu mới nguyên văn ⇒ nó BỎ mã quy trình và làm ĐỎ assert `Contain("KHKK-N5")` + // của test K3 nói trên — đã báo lead ở §8 artifact, KHÔNG tự nới test cũ.) + // ################################################################### + + private static async Task UpdateDraftAsync( + KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, + Guid? awId = null, string? ghiChu = null) + { + await using var db = f.NewDb(actor); + var handler = new UpdateContractSigningPlanDraftCommandHandler(db, actor); + await handler.Handle( + new UpdateContractSigningPlanDraftCommand(planId, GhiChu: ghiChu, HoSoLink: null, + ApprovalWorkflowId: awId), + CancellationToken.None); + } + + /// Đọc pin TỪ ĐĨA (context mới). Mọi vế "409 rồi thì không đổi gì" phải đo ở đây chứ không + /// đo trên entity trong bộ nhớ — handler gán `plan.ApprovalWorkflowId` TRƯỚC khi + /// `SaveChangesAsync` chạy, nên bản in-memory nói dối được. + private static async Task ReadPinAsync(KhkkWorkflowFixture f, Guid planId) + { + await using var db = f.NewDb(TestCurrentUser.System()); + return await db.ContractSigningPlans.AsNoTracking() + .Where(p => p.Id == planId) + .Select(p => p.ApprovalWorkflowId) + .SingleAsync(CancellationToken.None); + } + + private static async Task ChangelogCountAsync(KhkkWorkflowFixture f, Guid planId) + { + await using var db = f.NewDb(TestCurrentUser.System()); + return await db.ContractSigningPlanChangelogs.AsNoTracking() + .CountAsync(c => c.ContractSigningPlanId == planId, CancellationToken.None); + } + + private static async Task ArchiveWorkflowAsync(KhkkWorkflowFixture f, Guid workflowId) + { + await using var db = f.NewDb(TestCurrentUser.System()); + var wf = await db.ApprovalWorkflows.SingleAsync(w => w.Id == workflowId, CancellationToken.None); + wf.IsActive = false; + await db.SaveChangesAsync(CancellationToken.None); + } + + // =================================================================== + // (T4) Cửa UpdateDraft — re-pin sang quy trình LỆCH NHÓM ⇒ 409. + // + // Đây là cửa NGUY HIỂM NHẤT trong ba: Create bị chặn thì người dùng thấy ngay và chọn lại; + // còn ở đây phiếu ĐÃ hợp lệ rồi mới bị đổi ruột — lần sau nhìn vào chỉ thấy một phiếu Nháp + // bình thường, không có dấu vết nào nói nó vừa bị chuyển dây chuyền ký. + // + // Ba vế, mỗi vế bịt một kiểu xanh-giả: + // (1) chặn + thông điệp nêu ĐỦ hai nhóm; + // (2) ĐĨA bất động: pin cũ còn nguyên VÀ 0 dòng changelog — handler gán pin rồi mới + // `SaveChanges` ở cuối, nên chỉ assert "có ném" là không phân biệt được "chặn kịp" + // với "chặn sau khi đã ghi"; + // (3) CONTROL DƯƠNG cùng lượt: re-pin sang bản v02 CÙNG NHÓM ⇒ qua, và changelog nhảy + // 0 → 1. Thiếu vế này thì một rào chặn-tuốt (hoặc lỗi seed làm mọi UpdateDraft ném) + // vẫn cho (1)+(2) xanh. + // =================================================================== + [Fact] + public async Task T4_UpdateDraft_RePinQuyTrinhLechNhom_ThiBiChan_VaGiuNguyenPinCu() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt"); + + var n3v1 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]); + var n3v2 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]], version: 2); + var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]); + + var planId = await SeedPlanAsync(f, drafter, n3v1, "KHKK/2026/301", + ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)], + approvalGroup: 3); + + // ---- (1) Re-pin sang quy trình của nhóm KHÁC. + var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n5); + var ex = await act.Should().ThrowAsync( + "sửa nháp là cửa thứ hai vào cùng một chỗ — rào chỉ đứng ở Create thì nó vô hiệu " + + "bằng đúng một request PUT hợp lệ về mọi mặt khác"); + ex.Which.Message.Should().Contain("N5").And.Contain("N3", + "thông điệp phải nêu CẢ nhóm của quy trình lẫn nhóm của phiếu — thiếu một vế thì " + + "người dùng biết mình sai nhưng không biết sai ở đâu"); + + // ---- (2) Đĩa bất động. + (await ReadPinAsync(f, planId)).Should().Be(n3v1, + "409 mà pin đã đổi = rào ném SAU khi ghi; phiếu vẫn chạy sai dây chuyền, chỉ thêm một lỗi đỏ"); + (await ChangelogCountAsync(f, planId)).Should().Be(0, + "lệnh bị chặn không được để lại dòng 'Cập nhật thông tin kế hoạch ký kết' — vết ma " + + "làm người đọc lịch sử tin rằng đã có thay đổi"); + + // ---- (3) CONTROL DƯƠNG: đổi đúng MỘT biến (nhóm của quy trình đích) ⇒ kết quả trái ngược. + await UpdateDraftAsync(f, drafter, planId, awId: n3v2, ghiChu: "Đổi sang bản v02 cùng nhóm"); + (await ReadPinAsync(f, planId)).Should().Be(n3v2, + "cùng cửa, cùng người, cùng phiếu — chỉ khác nhóm của quy trình đích ⇒ rào SO KHỚP " + + "chứ không phải chặn mọi lệnh re-pin"); + (await ChangelogCountAsync(f, planId)).Should().Be(1, + "và phép đếm changelog ở (2) là phép đo SỐNG: nó biết tăng khi lệnh thật sự chạy"); + } + + // =================================================================== + // (T5) Cửa Submit — phiếu pin lệch nhóm TỪ TRƯỚC (data cũ / lách API) ⇒ 409 lúc trình. + // + // Vì sao cửa này bắt buộc phải có dù đã chặn ở Create + UpdateDraft: prod HÔM NAY có thể + // đang mang phiếu pin lệch (spec W0 §0.4 chạy hẳn một query audit để đếm trước khi vá). + // Rào ở hai cửa GHI không dọn được thứ đã nằm sẵn trong bảng — chỉ cửa TRÌNH mới chặn được + // phiếu cũ trước khi nó bước vào dây chuyền ký sai. + // Seed thẳng DbContext là CỐ Ý: đó là cách duy nhất dựng lại trạng thái "đã lệch từ trước" + // sau khi rào Create đóng. + // + // 🔴 Điều kiện để phép đo có nghĩa: `SubmitAsync` có SẴN 3 cửa 409 khác (phase, hạng mục + // chưa gán, hạng mục sai nhóm) ⇒ bắt được `ConflictException` là chưa nói lên gì. + // Fixture đã kéo hạng mục theo `approvalGroup` của phiếu, nên hai rào hạng mục im; + // và phiếu CHỨNG NHÂN pin đúng nhóm trình LỌT trong cùng một lượt chứng minh phần còn + // lại của đường trình vẫn thông. + // =================================================================== + [Fact] + public async Task T5_Submit_PhieuPinLechNhomTuTruoc_ThiBiChan_ChuKhongTrinhQua() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt"); + + var n3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]); + var n5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]); + + // Phiếu nhóm 3 nhưng pin quy trình nhóm 5 — dựng thẳng, mô phỏng phiếu có từ trước rào. + var lech = await SeedPlanAsync(f, drafter, n5, "KHKK/2026/310", + ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)], + approvalGroup: 3); + // Chứng nhân: y hệt, khác đúng một biến — pin ĐÚNG nhóm. + var dung = await SeedPlanAsync(f, drafter, n3, "KHKK/2026/311", + ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)], + approvalGroup: 3); + + var act = async () => await ActAsync(f, drafter, lech, "submit"); + var ex = await act.Should().ThrowAsync( + "phiếu lệch nhóm phải bị chặn Ở CỬA TRÌNH — hai rào kia không với tới dữ liệu đã nằm sẵn trong bảng"); + ex.Which.Message.Should().Contain("N5").And.Contain("N3", + "thông điệp phải phân biệt được với 3 câu 409 khác của SubmitAsync (phase / hạng mục " + + "chưa gán / hạng mục sai nhóm), nếu không người dùng đi sửa nhầm chỗ"); + + var blocked = await ReadPlanAsync(f, lech); + ShouldBeAt(blocked, ContractSigningPlanPhase.DangSoanThao, null, null, + "bị chặn thì phiếu phải Ở NGUYÊN nháp — sang ChoDuyet nửa vời là phiếu treo trong " + + "hộp thư của người không có thẩm quyền ký"); + + // CHỨNG NHÂN cùng lượt: đường trình vẫn thông cho phiếu pin đúng nhóm ⇒ 409 ở trên là + // của rào NHÓM, không phải một rào khác của SubmitAsync vô tình nổ. + await ActAsync(f, drafter, dung, "submit"); + var ok = await ReadPlanAsync(f, dung); + ShouldBeAt(ok, ContractSigningPlanPhase.ChoDuyet, 0, 1, + "cùng fixture, cùng hạng mục, cùng quy trình 1-Bước-1-Cấp — chỉ khác nhóm của quy " + + "trình được pin ⇒ hai kết quả trái ngược"); + } + + // =================================================================== + // (T6) Vế ÂM — mã NGOÀI họ `KHKK-N[1-8]` (quy trình chung/legacy type-10) vẫn đi lọt ở + // CẢ HAI cửa mới. Ngữ nghĩa này là CHỦ ĐÍCH của rào cũ (`Features.cs:477-478`) và spec W1b + // ra lệnh giữ nguyên khi dời sang class dùng chung — siết thêm ở đây là khoá cứng mọi phiếu + // đang pin quy trình chung, mà đường sửa duy nhất lại chính là cửa vừa bị khoá. + // + // 🔴 Vế âm một mình là XANH-MÙ: hôm nay rào chưa tồn tại nên "đi lọt" đúng ở cả thế giới + // chưa-vá. Vì vậy chân (2) — CÙNG cửa, CÙNG phiếu, chỉ đổi mã đích sang trong-họ ⇒ 409 — + // là thứ chứng minh rào ĐANG CHẠY trong chính lượt này; thiếu nó thì T6 không đo gì. + // =================================================================== + [Fact] + public async Task T6_MaNgoaiHoNhom_UpdateDraftVaSubmit_VanDiLot() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt"); + + var chung01 = await SeedWorkflowAsync(f, "QT-KHKK-V2-001", steps: [[[approver.Id]]]); + var chung02 = await SeedWorkflowAsync(f, "QT-KHKK-V2-002", steps: [[[approver.Id]]]); + var n7 = await SeedWorkflowAsync(f, "KHKK-N7", steps: [[[approver.Id]]]); + + var planId = await SeedPlanAsync(f, drafter, chung01, "KHKK/2026/320", + ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)], + approvalGroup: 4); + + // ---- (1) Cửa UpdateDraft: mã ngoài họ ⇒ KHÔNG bị hỏi nhóm. + await UpdateDraftAsync(f, drafter, planId, awId: chung02); + (await ReadPinAsync(f, planId)).Should().Be(chung02, + "quy trình chung không mang số nhóm trong mã ⇒ không có gì để so khớp; chặn ở đây là " + + "siết oan và phiếu mất luôn đường tự sửa"); + + // ---- (2) CHỨNG NHÂN rào đang sống: cùng cửa, cùng phiếu, chỉ đổi mã đích sang TRONG họ. + var act = async () => await UpdateDraftAsync(f, drafter, planId, awId: n7); + await act.Should().ThrowAsync( + "phiếu nhóm 4 pin `KHKK-N7` phải bị chặn — nếu vế này cũng đi lọt thì (1) và (3) chỉ " + + "đang mô tả một thế giới CHƯA CÓ rào, không chứng minh ngữ nghĩa cho-qua nào cả"); + (await ReadPinAsync(f, planId)).Should().Be(chung02, "409 thì pin giữ nguyên"); + + // ---- (3) Cửa Submit: vẫn mã ngoài họ ⇒ trình lọt. + await ActAsync(f, drafter, planId, "submit"); + var after = await ReadPlanAsync(f, planId); + ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 1, + "rào mới cắm vào SubmitAsync không được nuốt phiếu đang chạy quy trình chung"); + } + + // =================================================================== + // (T7) D6 — re-pin sang bản ĐÃ NGƯNG HOẠT ĐỘNG / CHƯA GHIM ⇒ 409 (mirror vá-3 + // `CreateContractFromSigningPlanFeatures.cs:141-147`, CÙNG một lỗ ở bridge type-3). + // + // Vì sao đáng chặn: sau W1a, "tạo phiên bản mới" hạ `IsActive` bản cũ. Phiếu pin bản đã + // nghỉ vẫn chạy được (service resolve theo Id, không đọc cờ) nên sai KHÔNG lộ ra — nó lộ ở + // chỗ khác: người duyệt của bản cũ đã bị thay trong bản mới. + // + // Bốn chân trên CÙNG một bộ seed (cả 4 bản đều mã `KHKK-N2` ⇒ rào nhóm im, mọi 409 quan + // sát được chỉ có thể đến từ D6): + // (1) IsActive=false ⇒ 409; + // (2) IsUserSelectable=false ⇒ 409, và THÔNG ĐIỆP PHẢI KHÁC (1) — spec đòi 2 câu tách, + // vì hai nguyên nhân này sửa ở hai nút khác nhau trong Designer; gộp thành một câu + // `!IsActive || !IsUserSelectable` là admin đi bấm nhầm nút; + // (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ re-pin qua; + // (4) 🔴 GIỮ-NGUYÊN-PIN KHÔNG BỊ HỎI: phiếu cũ đang pin bản đã nghỉ vẫn phải sửa được + // GhiChu. Đây là vế phân biệt "check trong nhánh đổi-pin" với "check mọi lần PUT" — + // bản vá đặt guard ngoài nhánh sẽ khoá cứng đúng những phiếu cần cứu nhất. + // =================================================================== + [Fact] + public async Task T7_RePinBanDaNgungHoacChuaGhim_ThiBiChan_ConGiuNguyenPinThiVanSuaDuoc() + { + using var f = new KhkkWorkflowFixture(); + var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt"); + + var dangDung = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]]); + var daNghi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]], + version: 2, isActive: false); + var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]], + version: 3, isUserSelectable: false); + var banMoi = await SeedWorkflowAsync(f, "KHKK-N2", steps: [[[approver.Id]]], version: 4); + + var planId = await SeedPlanAsync(f, drafter, dangDung, "KHKK/2026/330", + ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 90m)], + approvalGroup: 2); + + // ---- (1) Bản đã ngưng hoạt động. + var actNghi = async () => await UpdateDraftAsync(f, drafter, planId, awId: daNghi); + var exNghi = await actNghi.Should().ThrowAsync( + "pin bản đã nghỉ = phiếu chạy dàn người duyệt của một phiên bản không còn hiệu lực"); + (await ReadPinAsync(f, planId)).Should().Be(dangDung); + + // ---- (2) Bản chưa ghim cho người dùng chọn. + var actGhim = async () => await UpdateDraftAsync(f, drafter, planId, awId: chuaGhim); + var exGhim = await actGhim.Should().ThrowAsync( + "cờ ghim là cách Admin quyết quy trình nào người dùng được chọn — bỏ qua nó ở đường " + + "PUT thì nút Ghim/Bỏ ghim chỉ còn là trang trí"); + (await ReadPinAsync(f, planId)).Should().Be(dangDung); + + exGhim.Which.Message.Should().NotBe(exNghi.Which.Message, + "hai nguyên nhân sửa ở hai nút khác nhau ⇒ phải hai câu khác nhau; một câu gộp " + + "`!IsActive || !IsUserSelectable` là đẩy admin đi mò"); + + // ---- (3) CONTROL DƯƠNG: bản active + đã ghim. + await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Chuyển sang bản v04"); + (await ReadPinAsync(f, planId)).Should().Be(banMoi, + "khác đúng một biến (2 cờ trạng thái của quy trình đích) ⇒ hai kết quả trái ngược"); + + // ---- (4) Bản đang pin bị cho nghỉ SAU đó ⇒ phiếu vẫn phải sửa được nội dung. + await ArchiveWorkflowAsync(f, banMoi); + await UpdateDraftAsync(f, drafter, planId, awId: banMoi, ghiChu: "Ghi chú sau khi quy trình nghỉ"); + + var plan = await ReadPlanAsync(f, planId); + plan.GhiChu.Should().Be("Ghi chú sau khi quy trình nghỉ", + "GIỮ NGUYÊN pin thì không có gì để kiểm — đặt guard ngoài nhánh `awId != pin hiện tại` " + + "là khoá cứng mọi phiếu đang pin bản đã nghỉ, đúng nhóm phiếu cần sửa nhất"); + plan.ApprovalWorkflowId.Should().Be(banMoi, "và pin không được tự đổi trong lượt sửa GhiChu"); + } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs index 0510d0b..f1a052d 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs @@ -142,18 +142,25 @@ public class ContractSigningPlanGroupAndCatalogTests KhkkGroupFixture f, string code, Guid[][][] steps, - ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan) + ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan, + // [S182 vá MAJOR-3] 3 tham số ĐUÔI cho rào D6 (`EnsureWorkflowPinnableAsync`): cần + // dựng nhiều version CÙNG một Code (UNIQUE là (Code, Version) — + // `ApprovalWorkflowConfiguration.cs:23`) rồi lật TỪNG cờ một. Optional TRAILING ⇒ mọi + // call-site cũ giữ nguyên chữ. Cùng khuôn `ContractSigningPlanApprovalTests.cs:135-140`. + int version = 1, + bool isActive = true, + bool isUserSelectable = true) { await using var db = f.NewDb(TestCurrentUser.System()); var wf = new ApprovalWorkflow { Id = Guid.NewGuid(), Code = code, - Version = 1, + Version = version, Name = "Quy trình duyệt Kế hoạch ký kết", ApplicableType = type, - IsActive = true, - IsUserSelectable = true, + IsActive = isActive, + IsUserSelectable = isUserSelectable, }; db.ApprovalWorkflows.Add(wf); @@ -855,9 +862,21 @@ public class ContractSigningPlanGroupAndCatalogTests var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3); var ex = await wrongGroup.Should().ThrowAsync( "quy trình họ KHKK-N{n} chỉ dùng được cho phiếu đúng nhóm n — sai nhóm là ký sai dây chuyền"); - ex.Which.Message.Should().Contain("KHKK-N5").And.Contain("N3", - "thông báo phải chỉ ra CẢ quy trình đang pin lẫn nhóm của phiếu, nếu không người dùng " - + "chỉ thấy '409' và không biết đổi cái gì"); + // [S182 W1b — spec change, KHÔNG phải nới bar] Thân rào dời sang + // `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync` dùng chung cho 3 cửa + // (tạo / sửa nháp / trình), và spec chốt MỘT câu 409 duy nhất cho cả ba: + // "Phiếu nhóm N{g} đang gắn quy trình N{k} — vào phiếu (Nháp/Trả lại) đổi lại quy trình + // đúng nhóm trước khi trình." + // Câu mới bỏ MÃ quy trình, giữ SỐ NHÓM. Không mất thông tin: rào chỉ nổ cho mã TRONG họ + // `KHKK-N[1-8]` nên "N5" xác định duy nhất mã "KHKK-N5" (mã ngoài họ đi thẳng, vế (3)). + // ⇒ pin cái BẤT BIẾN qua cả hai bản chữ: thông điệp phải nêu ĐỦ HAI nhóm. + // [S182 MINOR-1] Pin NGUYÊN CỤM có thứ tự ở ĐÚNG 1 ca này (các ca khác giữ Contain 2 vế): + // `Contain(N5) ∧ Contain(N3)` vẫn xanh nếu ai đó ĐẢO 2 đối số ở call-site + // (`EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)` — 2 tham số kề nhau, + // 3 call-site). Cụm đầy đủ dưới đây chết ngay khi đảo ⇒ bất-biến thứ-tự có chứng nhân. + ex.Which.Message.Should().Contain("Phiếu nhóm N3 đang gắn quy trình N5", + "thông báo phải nêu ĐỦ và ĐÚNG CHIỀU: nhóm của phiếu (N3) trước, nhóm của quy trình " + + "đang pin (N5) sau — đảo đối số ở bất kỳ call-site nào là câu này đổi chiều"); // Bị chặn thì KHÔNG được để lại dấu vết nào. (await ReadPlanScopesAsync(f)).Should().BeEmpty("rào ném trước khi `Add` ⇒ không có phiếu nửa vời"); @@ -901,6 +920,90 @@ public class ContractSigningPlanGroupAndCatalogTests .ToListAsync(CancellationToken.None); } + // =================================================================== + // [S182 — vá MAJOR-3 reviewer pre-commit] Rào D6 Ở ĐƯỜNG TẠO — + // `ContractSigningPlanFeatures.cs:507` gọi `ContractSigningPlanScope + // .EnsureWorkflowPinnableAsync` (thân `:331-346`). + // + // Vì sao ca này phải tồn tại RIÊNG: D6 cắm ở HAI site (Create `:507` + UpdateDraft `:642`), + // nhưng ca duy nhất phủ D6 — T7 `ContractSigningPlanApprovalTests.cs:1402` — gọi + // `UpdateDraftAsync` ở CẢ BỐN chân. Tức là write-path mà MỌI phiếu KHKK đi qua đang có 0 + // răng đo: ai dời/đảo/gỡ guard khỏi Create thì cả suite vẫn xanh. + // + // Ba chân trên CÙNG một bộ seed. Ba quy trình khác nhau ĐÚNG ở Version + MỘT cờ; Code đều + // là `KHKK-N1` và phiếu cũng nhóm 1 ⇒ rào (v) `:515` khớp nên IM LẶNG, mọi 409 quan sát + // được chỉ có thể đến từ D6: + // (1) IsActive=false ⇒ 409 "… đã ngừng hoạt động"; + // (2) IsUserSelectable=false ⇒ 409 "… chưa được ghim", và THÔNG ĐIỆP PHẢI KHÁC (1) — đó + // đúng là lý do prod tách 2 câu (`:328-330`: hai nguyên nhân sửa ở hai nút khác nhau + // trong Designer). 🔴 Bản này BUỘC giữ IsActive=true, nếu không vế `!IsActive` nổ + // trước và chân (2) đang đo lại chân (1) — hai cờ cùng false = phép đo rỗng. + // (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ tạo được, VÀ mã ra `001`. Hai lần 409 ở trên + // đứng TRƯỚC `codeGen.GenerateAsync` (`:524`), mà codegen TỰ COMMIT trong transaction + // riêng (`ContractSigningPlanCodeGenerator.cs:23-43`) ⇒ đảo thứ tự hai bước này là số + // thứ tự bốc hơi vĩnh viễn (mã sẽ ra `003`) và dải mã KHKK thủng lỗ. + // =================================================================== + [Fact] + public async Task Create_PinQuyTrinhDaNgungHoacChuaGhim_ThiBiChan_VaKhongDotMa() + { + using var f = new KhkkGroupFixture(); + var bch = As(await AddUserAsync(f, "BCH lập kế hoạch")); + var approver = await AddUserAsync(f, "NV duyệt"); + var pe = await SeedPeForCreateAsync(f); + + var dangDung = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]); + var daNghi = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]], + version: 2, isActive: false); + // IsActive VẪN true — xem ghi chú chân (2) ở khối trên. + var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]], + version: 3, isUserSelectable: false); + + // ---- (1) Pin bản ĐÃ NGƯNG HOẠT ĐỘNG. + var actNghi = async () => await CreateAsync(f, bch, pe.PeId, daNghi, approvalGroup: 1); + var exNghi = await actNghi.Should().ThrowAsync( + "'tạo phiên bản mới' hạ IsActive bản cũ; pin được bản đã nghỉ thì phiếu chạy dàn " + + "người duyệt của một phiên bản không còn hiệu lực — sai IM LẶNG, chỉ lộ khi hồ sơ " + + "tới tay người đã bị thay trong bản mới"); + exNghi.Which.Message.Should().Contain("ngừng hoạt động"); + + // ---- (2) Pin bản CHƯA GHIM cho người dùng chọn. + var actGhim = async () => await CreateAsync(f, bch, pe.PeId, chuaGhim, approvalGroup: 1); + var exGhim = await actGhim.Should().ThrowAsync( + "FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì bỏ qua FE ⇒ cờ ghim phải " + + "có răng ở BE, không thì nút Ghim/Bỏ ghim của Admin chỉ là trang trí"); + exGhim.Which.Message.Should().Contain("ghim"); + + exGhim.Which.Message.Should().NotBe(exNghi.Which.Message, + "hai nguyên nhân sửa ở HAI NÚT khác nhau trong Designer ⇒ phải hai câu khác nhau; " + + "gộp thành một câu `!IsActive || !IsUserSelectable` là đẩy admin đi mò"); + + // ---- Bị chặn thì KHÔNG để lại gì: không phiếu nửa vời, và mã CHƯA TỪNG được cấp. + (await ReadPlanScopesAsync(f)).Should().BeEmpty( + "D6 ném trước `db.ContractSigningPlans.Add` (`:577`) ⇒ không phiếu nào ra đời"); + await using (var probe = f.NewDb(bch)) + { + probe.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026").Should().BeFalse( + "hai lần 409 xảy ra TRƯỚC codegen ⇒ hàng sequence còn chưa được tạo lần nào"); + } + + // ---- (3) CONTROL DƯƠNG: cùng bộ seed, chỉ đổi trạng thái 2 cờ của quy trình đích. + var ok = await CreateAsync(f, bch, pe.PeId, dangDung, approvalGroup: 1); + ok.MaKeHoach.Should().Be("KHKK/2026/001", + "ra 002/003 tức là hai lần bị chặn đã TIÊU số thứ tự cho phiếu chưa từng tồn tại — " + + "codegen tự commit nên số đó mất vĩnh viễn, không rollback theo request"); + ok.LineCount.Should().Be(1, "PE seed có đúng 1 NCC trúng thầu ⇒ 1 dòng"); + + (await ReadPlanScopesAsync(f)).Should().BeEquivalentTo(new[] + { + new PlanScope("KHKK/2026/001", 1, dangDung), + }, "đúng MỘT phiếu ra đời và pin đúng bản đang dùng — nếu D6 chặn-tuốt thì chân này đỏ, " + + "còn nếu D6 bị gỡ khỏi Create thì hai chân trên xanh giả; cần cả ba mới phân biệt được"); + + await using var db = f.NewDb(bch); + db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq.Should().Be(1, + "đúng một lần cấp mã cho đúng một phiếu"); + } + // =================================================================== // [S168 K8-blocker] Lines-editor `UpdateContractSigningPlanLineCommand` — mảnh comment // auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs index c2748df..7d0a7a3 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs @@ -4,9 +4,10 @@ using Microsoft.Extensions.DependencyInjection; using SolutionErp.Application.ApprovalWorkflowsV2; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.ContractSigningPlans; // [S182 W1] KHKK plan + changelog (T1-T3, T8-T14) using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (shared HĐ/PE) using SolutionErp.Domain.Identity; -using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius) +using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius) + LeaveRequest (T12) using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Infrastructure.Services; using SolutionErp.Infrastructure.Tests.Common; @@ -67,12 +68,15 @@ public class UpdateAwDefinitionTests ApprovalWorkflowApplicableType type, (int LevelOrder, Guid User)[][] stepSpecs, decimal? ceoThreshold = null, - string code = "QT-UPD-V2") + string code = "QT-UPD-V2", + // [S182 W1] T14 cần 2 version CÙNG Code (UNIQUE là (Code, Version) — + // `ApprovalWorkflowConfiguration.cs:26`). Optional trailing ⇒ 14 call-site cũ không đổi. + int version = 1) { var wf = new ApprovalWorkflow { Code = code, - Version = 1, + Version = version, ApplicableType = type, Name = "QT test sửa tại chỗ", IsActive = true, @@ -969,4 +973,561 @@ public class UpdateAwDefinitionTests "vết phải đọc được bằng TÊN, không phải GUID"); } } + + // ################################################################### + // [S182 W1 — YC-013, 2026-08-08] 8 panel "Duyệt KHKK — Nhóm 1..8" mirror trang Duyệt NCC. + // SPEC FINAL: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/ + // spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1. + // + // 10 lưới dưới đây (T1-T3 + T8-T14) đo 3 fix của W1 trên `ApprovalWorkflowV2AdminFeatures.cs`. + // Viết TRƯỚC khi BE land (test-before cho a/b, test-after cho c/d) ⇒ ca nào ĐỎ hôm nay là + // ĐỎ HỢP LỆ; hình dạng "đỏ vì chưa làm" đã ghi trong `sub-test-specialist-2.md §7`. + // + // 🔴 MỆNH ĐỀ TRUNG TÂM (R-6, spec `:14`): với `ApplicableType = ContractSigningPlan` (10), + // tập bị tắt khi tạo version mới là **THEO Code**, không theo Type: + // Where(w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive) + // Hôm nay `:340-343` lọc THEO TYPE ⇒ POST một nhóm = tắt IsActive của CẢ 8 nhóm prod. + // T1/T3 là hai nửa của cùng một mệnh đề; T2 giữ cho bản vá KHÔNG nới sang type khác. + // ⚠️ CẤM biến-thể regex `^KHKK-N[1-8]$` (R-6): mã type-10 ngoài họ vẫn phải per-Code — + // vế đó chính là chân thứ hai của T3. + // ################################################################### + + private const ApprovalWorkflowApplicableType Khkk = ApprovalWorkflowApplicableType.ContractSigningPlan; + + /// 8 quy trình nhóm `KHKK-N1..N8` (type-10, v01, IsActive) — hình dạng prod SAU seeder K3. + /// Một người duyệt dùng chung cho cả 8: rào trùng chỉ áp trong CÙNG một Cấp của CÙNG một Bước. + private static async Task> SeedEightKhkkGroupsAsync( + TestApplicationDbContext db, Guid approverId) + { + var map = new Dictionary(); + for (var n = 1; n <= 8; n++) + { + var wf = await SeedWorkflowAsync(db, Khkk, [[(1, approverId)]], code: $"KHKK-N{n}"); + map[$"KHKK-N{n}"] = wf.Id; + } + return map; + } + + /// Lệnh POST "Tạo phiên bản mới" tối thiểu-nhưng-hợp-lệ (1 Bước × 1 Cấp). + private static CreateAwDefinitionCommand NewVersionCommand( + ApprovalWorkflowApplicableType type, string code, Guid approverId) + => new( + ApplicableType: (int)type, + Code: code, + Name: $"Quy trình {code}", + Description: null, + Steps: [new CreateAwStepInput(1, "Bước 1", null, [new CreateAwLevelInput(1, "Cấp 1", approverId)])]); + + private sealed record WfRow(string Code, int Version, bool IsActive); + + private static Task> ReadWorkflowsAsync( + TestApplicationDbContext db, ApprovalWorkflowApplicableType type) + => db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.ApplicableType == type) + .Select(w => new WfRow(w.Code, w.Version, w.IsActive)) + .ToListAsync(CancellationToken.None); + + /// 🔴 ĐƠN VỊ CỦA SỰ CỐ THẬT: "bao nhiêu NHÓM còn cửa tạo phiếu". FE auto-pin quy trình cho + /// phiếu mới bằng `find(w => w.isActive && code-match)` (spec W2g) ⇒ nhóm mất bản active là + /// nhóm KHÔNG lập được phiếu nữa. Đếm bằng đơn vị này thì thông điệp lỗi nói đúng thứ vỡ. + private static async Task KhkkGroupsWithActiveVersionAsync(TestApplicationDbContext db) + { + var rows = await ReadWorkflowsAsync(db, Khkk); + return rows.Where(r => r.IsActive) + .Select(r => r.Code) + .Where(c => Enumerable.Range(1, 8).Any(n => c == $"KHKK-N{n}")) + .Distinct().Count(); + } + + private static async Task SeedKhkkPlanAsync( + TestApplicationDbContext db, + Guid workflowId, + Guid drafterId, + string maKeHoach, + ContractSigningPlanPhase phase = ContractSigningPlanPhase.ChoDuyet, + int approvalGroup = 1, + bool isDeleted = false) + { + var plan = new ContractSigningPlan + { + Id = Guid.NewGuid(), + MaKeHoach = maKeHoach, + // 4 cột loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`). + PurchaseEvaluationId = Guid.NewGuid(), + ProjectId = Guid.NewGuid(), + DepartmentId = Guid.NewGuid(), + DrafterUserId = drafterId, + Phase = phase, + ApprovalGroup = approvalGroup, + ApprovalWorkflowId = workflowId, + CurrentWorkflowStepIndex = phase == ContractSigningPlanPhase.ChoDuyet ? 0 : null, + CurrentApprovalLevelOrder = phase == ContractSigningPlanPhase.ChoDuyet ? 1 : null, + IsDeleted = isDeleted, + DeletedAt = isDeleted ? new DateTime(2026, 8, 7, 0, 0, 0, DateTimeKind.Utc) : null, + }; + db.ContractSigningPlans.Add(plan); + await db.SaveChangesAsync(CancellationToken.None); + return plan; + } + + /// Đơn nghỉ phép — module Văn phòng số pin **LOOSE, KHÔNG FK** (`WorkflowAppsConfiguration.cs:12-26` + /// không khai `HasOne`; `LeaveTypeId` cũng chỉ là Guid trần). + private static async Task SeedLeaveRequestAsync( + TestApplicationDbContext db, Guid workflowId, Guid requesterId, string maDonTu) + { + var leave = new LeaveRequest + { + Id = Guid.NewGuid(), + MaDonTu = maDonTu, + RequesterUserId = requesterId, + RequesterFullName = "Người xin nghỉ", + LeaveTypeId = Guid.NewGuid(), + StartDate = new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc), + EndDate = new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc), + NumDays = 2m, + Reason = "Việc gia đình", + Status = WorkflowAppStatus.DaGuiDuyet, + ApprovalWorkflowId = workflowId, + CurrentApprovalLevelOrder = 1, + }; + db.LeaveRequests.Add(leave); + await db.SaveChangesAsync(CancellationToken.None); + return leave; + } + + private static Task DeleteWorkflowAsync(Harness h, Guid id) + => new DeleteAwDefinitionCommandHandler(h.Db).Handle( + new DeleteAwDefinitionCommand(id), CancellationToken.None); + + private static Task WorkflowRowCountAsync(Harness h, Guid id) + => h.Db.ApprovalWorkflows.AsNoTracking().CountAsync(w => w.Id == id, CancellationToken.None); + + // =================================================================== + // (T1) 🔴 BOM ĐANG ARMED — tạo v02 cho MỘT nhóm KHÔNG được tắt 7 nhóm còn lại. + // + // Đo bằng đơn vị của sự cố: số NHÓM còn bản active. Hôm nay `:340-343` tắt theo TYPE ⇒ + // sau một cú bấm "Tạo phiên bản mới" trên panel N1, con số đó tụt 8 → 1 và 7 folder KHKK + // mất cửa lập phiếu — im lặng, không lỗi, không có endpoint re-activate (spec R-2). + // =================================================================== + [Fact] + public async Task T1_CreateNewVersionForOneKhkkGroup_DeactivatesOnlyThatCode_OtherSevenGroupsStayActive() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + await SeedEightKhkkGroupsAsync(h.Db, alice.Id); + + // Tiền đề đo: 8 nhóm, TẤT CẢ đang có bản active. Thiếu chốt này thì một seed hỏng + // (vd 1 nhóm đã tắt sẵn) vẫn cho assert phía dưới xanh ở thế giới sai. + (await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, "tiền đề: seeder K3 để cả 8 nhóm active"); + + await new CreateAwDefinitionCommandHandler(h.Db).Handle( + NewVersionCommand(Khkk, "KHKK-N1", alice.Id), CancellationToken.None); + + var rows = await ReadWorkflowsAsync(h.Db, Khkk); + rows.Should().HaveCount(9, "POST đẻ ĐÚNG 1 dòng mới (v02 của N1) — không xoá/không thêm dòng nào khác"); + + rows.Single(r => r is { Code: "KHKK-N1", Version: 1 }).IsActive.Should().BeFalse( + "cùng Code thì bản cũ PHẢI nhường chỗ — đây là vế 'ratchet một chiều' của chính nhóm N1"); + rows.Single(r => r is { Code: "KHKK-N1", Version: 2 }).IsActive.Should().BeTrue( + "version vừa tạo là bản đang dùng của nhóm N1"); + + rows.Where(r => r.Code != "KHKK-N1").Should().OnlyContain(r => r.IsActive, + "🔴 R-6: tập bị tắt lọc theo CODE, không theo TYPE. Lọc theo type thì một cú bấm trên " + + "panel N1 tắt luôn N2..N8 — 7 nhóm mất bản active, và KHÔNG có endpoint bật lại"); + + (await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, + "số NHÓM còn cửa lập phiếu phải BẤT BIẾN qua thao tác của một nhóm khác; tụt xuống 1 " + + "là đúng hình vụ nổ mà spec R-1 gọi là 'bom đã armed'"); + } + } + + // =================================================================== + // (T2) HỒI QUY cho các loại KHÁC: bản vá W1a chỉ được đổi hành vi của type-10. + // Type-1 (Duyệt NCC) tạo quy trình mã MỚI vẫn phải tắt mã CŨ cùng type (1-active-per-type). + // Kèm chứng nhân xuyên-type: 8 nhóm KHKK không được suy suyển vì một lệnh của type-1. + // =================================================================== + [Fact] + public async Task T2_CreateNewCodeForType1_StillDeactivatesOldCodeOfSameType_AndLeavesKhkkGroupsAlone() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc, + [[(1, alice.Id)]], code: "QT-DN-V2-001"); + await SeedEightKhkkGroupsAsync(h.Db, alice.Id); + + // Mã KHÁC, cùng type-1 — đúng ca mà per-Code (nếu áp cho mọi type) sẽ để LỌT 2 bản active. + await new CreateAwDefinitionCommandHandler(h.Db).Handle( + NewVersionCommand(ApprovalWorkflowApplicableType.DuyetNcc, "QT-DN-V2-002", alice.Id), + CancellationToken.None); + + var type1 = await ReadWorkflowsAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc); + type1.Single(r => r.Code == "QT-DN-V2-001").IsActive.Should().BeFalse( + "type-1 GIỮ ngữ nghĩa 1-active-per-type: mã cũ phải tắt khi có quy trình mới của cùng loại"); + type1.Single(r => r.Code == "QT-DN-V2-002").IsActive.Should().BeTrue(); + type1.Count(r => r.IsActive).Should().Be(1, + "vá W1a mà nới per-Code cho MỌI type thì trang Duyệt NCC có 2 quy trình cùng active — " + + "phiếu mới không biết pin cái nào"); + + (await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, + "chứng nhân xuyên-type: lệnh của type-1 không được chạm 8 nhóm type-10"); + } + } + + // =================================================================== + // (T3) F4 — mã type-10 NGOÀI họ `KHKK-N[1-8]`. Hai chân, mỗi chân giết một bản vá sai: + // (a) tạo `QT-KHKK-V2-001` ⇒ 8 nhóm BẤT BIẾN → giết bản vá "giữ per-type cho code lạ" + // (đúng cái mà công thức regex FE-invest sẽ làm — R-6 CẤM); + // (b) tạo LẠI CHÍNH mã đó ⇒ v01 phải tắt → giết bản vá lười "type-10 thì đừng tắt gì cả" + // (chân (a) một mình sẽ XANH với bản vá đó). + // =================================================================== + [Fact] + public async Task T3_CreateType10_WithOutOfFamilyCode_LeavesEightGroupsActive_AndStillRatchetsItsOwnCode() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + await SeedEightKhkkGroupsAsync(h.Db, alice.Id); + var handler = new CreateAwDefinitionCommandHandler(h.Db); + + // ---- (a) mã type-10 chưa từng có ⇒ KHÔNG có bản cũ nào để tắt. + await handler.Handle(NewVersionCommand(Khkk, "QT-KHKK-V2-001", alice.Id), CancellationToken.None); + + (await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, + "🔴 F4: quy trình type-10 mã ngoài họ (legacy/chung) tạo mới KHÔNG được đụng 8 nhóm. " + + "Công thức regex `^KHKK-N[1-8]$` cho mã này rơi về nhánh per-type ⇒ NUKE cả 8 (R-6 cấm)"); + + var afterA = await ReadWorkflowsAsync(h.Db, Khkk); + afterA.Single(r => r.Code == "QT-KHKK-V2-001").IsActive.Should().BeTrue(); + afterA.Count(r => r.IsActive).Should().Be(9, "8 nhóm + 1 quy trình chung, cùng sống song song"); + + // ---- (b) CHÍNH mã đó, lần thứ hai ⇒ ratchet per-Code vẫn phải chạy. + await handler.Handle(NewVersionCommand(Khkk, "QT-KHKK-V2-001", alice.Id), CancellationToken.None); + + var afterB = await ReadWorkflowsAsync(h.Db, Khkk); + afterB.Single(r => r is { Code: "QT-KHKK-V2-001", Version: 1 }).IsActive.Should().BeFalse( + "per-Code áp cho MỌI mã type-10, kể cả ngoài họ — 'type-10 thì không tắt gì' là bản vá " + + "làm 2 version cùng mã cùng active, FE auto-pin bốc ngẫu nhiên"); + afterB.Single(r => r is { Code: "QT-KHKK-V2-001", Version: 2 }).IsActive.Should().BeTrue(); + (await KhkkGroupsWithActiveVersionAsync(h.Db)).Should().Be(8, "8 nhóm vẫn bất biến sau lượt 2"); + } + } + + // =================================================================== + // (T8) Fix (c) — PUT sửa-tại-chỗ quy trình type-10 phải ghi VẾT vào phiếu KHKK đang treo. + // Dialog PUT đang HỨA nguyên văn "ghi vào lịch sử phiếu đang chạy" (`ApprovalWorkflowsV2Page.tsx:963-977`) + // trong khi cổng `:863-865` chỉ mở cho DuyetNcc/DuyetNccPhuongAn ⇒ lời hứa rỗng cho KHKK. + // + // Đo SHAPE chứ không chỉ đếm: một dòng vết thiếu `EntityId`/`PhaseAtChange` thì màn lịch sử + // phiếu hiện một dòng "ai đó sửa gì đó" — vô dụng đúng lúc cần nhất. + // =================================================================== + [Fact] + public async Task T8_UpdateType10Workflow_WithPendingKhkkPlan_WritesExactlyOneChangelogRowWithFullShape() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []); + var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]); + var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]); + + var wf = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N3"); + var plan = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/101", + ContractSigningPlanPhase.ChoDuyet, approvalGroup: 3); + + var lvlAlice = wf.Steps.Single().Levels.Single(); + var cmd = WithLevels(BuildEchoCommand(wf, reason: "Anh Chương nghỉ phép — bổ sung người ký thay"), 0, + [EchoLevel(lvlAlice), NewLevel(1, bob.Id)]); + + await NewHandler(h, admin).Handle(cmd, CancellationToken.None); + + // Sanity chống rỗng-nghĩa: lệnh THẬT SỰ đổi cấu hình (⇒ `changes` khác rỗng). + var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id); + live.Steps.Single().Levels.Should().HaveCount(2, "không có thay đổi thì không có vết để đo"); + + // Đọc bằng IgnoreQueryFilters: đọc THIẾU trông y hệt "chưa ghi" (bài S155). + var logs = await h.Db.ContractSigningPlanChangelogs.IgnoreQueryFilters().AsNoTracking() + .ToListAsync(CancellationToken.None); + logs.Should().ContainSingle("đúng 1 phiếu đang treo ⇒ đúng 1 dòng vết"); + + var log = logs.Single(); + log.ContractSigningPlanId.Should().Be(plan.Id); + log.EntityType.Should().Be(ContractSigningPlanEntityType.Workflow, "enum slot 4 đã có sẵn — không đẻ slot mới"); + log.Action.Should().Be(ChangelogAction.Update); + log.EntityId.Should().Be(wf.Id, "trỏ về quy trình bị sửa — thiếu thì không lần ngược được"); + log.PhaseAtChange.Should().Be(ContractSigningPlanPhase.ChoDuyet); + log.UserId.Should().Be(admin.Id); + log.UserName.Should().Be("Quản trị hệ thống", "người đọc lịch sử cần TÊN, không phải GUID"); + log.Summary.Should().Contain("KHKK-N3").And.Contain("giữ nguyên phiên bản", + "phân biệt 'sửa tại chỗ' với 'tạo version mới' — hai việc khác hẳn nhau về hệ quả"); + log.ContextNote.Should().Contain("Lý do: Anh Chương nghỉ phép"); + log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình", + "nội dung đổi phải đọc được bằng tên người, mirror khối PE `:874-891`"); + + (await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync(CancellationToken.None)) + .Should().Be(0, "quy trình type-10 KHÔNG được rải vết sang changelog của module PE"); + } + } + + // =================================================================== + // (T9) Vế ÂM của cùng cơ chế — và có CONTROL DƯƠNG TRONG CÙNG MỘT LƯỢT. + // Nếu chỉ seed phiếu đã kết thúc rồi assert "0 dòng" thì ca này XANH cả trong thế giới + // fix (c) chưa từng được viết. Vì vậy 4 phiếu cùng một quy trình: + // • ChoDuyet SỐNG → PHẢI có vết (chứng cơ chế đang chạy) + // • DaDuyet / TuChoi → KHÔNG (đã đóng, sửa quy trình không còn ảnh hưởng) + // • ChoDuyet XOÁ MỀM → KHÔNG (spec `:34`: query-filter tự loại — vết cho phiếu đã xoá là rác) + // =================================================================== + [Fact] + public async Task T9_UpdateType10Workflow_ChangelogGoesOnlyToLivePendingPlan_NotFinishedNorSoftDeleted() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []); + var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]); + var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]); + + var wf = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N5"); + var pending = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/110", + ContractSigningPlanPhase.ChoDuyet, approvalGroup: 5); + var approved = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/111", + ContractSigningPlanPhase.DaDuyet, approvalGroup: 5); + var rejected = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/112", + ContractSigningPlanPhase.TuChoi, approvalGroup: 5); + var softDeleted = await SeedKhkkPlanAsync(h.Db, wf.Id, drafter.Id, "KHKK/2026/113", + ContractSigningPlanPhase.ChoDuyet, approvalGroup: 5, isDeleted: true); + + var lvlAlice = wf.Steps.Single().Levels.Single(); + var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người ký"), 0, + [EchoLevel(lvlAlice), NewLevel(1, bob.Id)]); + + await NewHandler(h, admin).Handle(cmd, CancellationToken.None); + + var logs = await h.Db.ContractSigningPlanChangelogs.IgnoreQueryFilters().AsNoTracking() + .ToListAsync(CancellationToken.None); + logs.Select(l => l.ContractSigningPlanId).Should().BeEquivalentTo(new[] { pending.Id }, + $"chỉ phiếu CHƯA kết thúc và CHƯA xoá mới chịu ảnh hưởng — DaDuyet {approved.MaKeHoach}, " + + $"TuChoi {rejected.MaKeHoach} và phiếu xoá mềm {softDeleted.MaKeHoach} đều phải bị loại; " + + "riêng dòng của phiếu ChoDuyet sống là CONTROL DƯƠNG chứng cơ chế đang chạy thật"); + } + } + + // =================================================================== + // (T10) Fix (d)(i) — XOÁ quy trình còn phiếu KHKK pin ⇒ 409 LỊCH SỰ, không phải 500 thô. + // Mã dùng ở đây là NGOÀI họ `KHKK-N{n}` để rào D5 (T14) không xen vào: 409 ở đây chỉ có thể + // do usage-check. Kèm 2 vế bắt buộc: + // • CONTROL DƯƠNG cùng lượt: quy trình 0-usage vẫn xoá được ⇒ guard không phải "chặn tuốt"; + // • CHỨNG-NHÂN: FK Restrict là thật — không có guard thì người dùng nhận DbUpdateException (547). + // =================================================================== + [Fact] + public async Task T10_DeleteWorkflow_WithPinnedKhkkPlan_Blocked409_WhileUnusedOneStillDeletes() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]); + + var used = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001"); + var free = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-002"); + await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/120"); + + var act = async () => await DeleteWorkflowAsync(h, used.Id); + var ex = (await act.Should().ThrowAsync( + "còn phiếu pin thì phải chặn LỊCH SỰ — để rơi xuống FK là người dùng nhận 500 không đọc được")).Which; + ex.Message.Should().Contain("QT-KHKK-V2-001", + "phải gọi ĐÍCH DANH quy trình: admin bấm xoá từ một danh sách nhiều dòng, câu 409 " + + "không nêu tên thì không biết dòng nào vừa bị chặn"); + (await WorkflowRowCountAsync(h, used.Id)).Should().Be(1, "409 rồi thì quy trình phải còn nguyên"); + + // Thông điệp phải MANG SỐ ĐẾM THẬT (rào quét 8 module + 8 bảng chữ ký), không phải + // một câu hằng. Đo bằng VI-SAI để không pin chữ: thêm đúng 1 hồ sơ pin nữa rồi gọi + // LẠI CÙNG một lệnh — câu trả về phải KHÁC. Cách này miễn nhiễm với việc BE đổi + // "phiếu"/"hồ sơ"/"bản ghi" (rào phủ cả đơn nghỉ phép lẫn hợp đồng, "phiếu" là chữ + // của riêng KHKK), nhưng vẫn giết được bản vá trả câu cố định. + await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/121"); + var ex2 = (await act.Should().ThrowAsync()).Which; + ex2.Message.Should().NotBe(ex.Message, + "1 hồ sơ đang gắn và 2 hồ sơ đang gắn mà ra CÙNG một câu ⇒ con số trong câu là " + + "chữ chết; admin không biết còn bao nhiêu thứ phải gỡ trước khi xoá được"); + + // CONTROL DƯƠNG cùng lượt — cùng loại, cùng hình dạng, khác đúng một biến: có/không phiếu pin. + await DeleteWorkflowAsync(h, free.Id); + (await WorkflowRowCountAsync(h, free.Id)).Should().Be(0, + "quy trình 0-usage vẫn phải xoá được — nếu ca này cũng 409 thì guard đang chặn tuốt " + + "và ca trên không chứng minh gì"); + + // CHỨNG-NHÂN: đây là thứ người dùng nhận nếu KHÔNG có guard (FK Restrict + // `ContractSigningPlanConfiguration.cs:50-53`). Dọn tracker trước — bài F-14. + h.Db.ChangeTracker.Clear(); + var stillTracked = await h.Db.ApprovalWorkflows.FirstAsync(w => w.Id == used.Id, CancellationToken.None); + h.Db.ApprovalWorkflows.Remove(stillTracked); + var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None); + await raw.Should().ThrowAsync( + "guard 409 đang đỡ đúng quả nổ này — không có nó thì UI hiện 500 và admin đoán mò"); + } + } + + // =================================================================== + // (T11) F2 — phiếu KHKK ĐÃ XOÁ MỀM vẫn phải chặn xoá quy trình. + // Xoá mềm = row VẪN NẰM TRONG BẢNG ⇒ FK Restrict vẫn nổ; nhưng guard đọc qua global filter + // (`ContractSigningPlanConfiguration.cs:55`) sẽ ĐẾM ĐƯỢC 0 và cho đi tiếp — "vắng mặt đọc + // thành sạch". Vậy nên guard BẮT BUỘC `IgnoreQueryFilters()`. + // 🔴 Nếu ca này ĐỎ với `DbUpdateException` thay vì `ConflictException` thì đó ĐÚNG là lỗ + // F2 — BÁO guard thiếu, KHÔNG nới test. + // =================================================================== + [Fact] + public async Task T11_DeleteWorkflow_PinnedOnlyBySoftDeletedKhkkPlan_StillBlocked409_NotFkCrash() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + var drafter = await h.Fix.CreateUserAsync("bch@t.vn", "BCH lập kế hoạch", null, [AppRoles.Drafter]); + + var used = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001"); + var free = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-002"); + await SeedKhkkPlanAsync(h.Db, used.Id, drafter.Id, "KHKK/2026/130", isDeleted: true); + + // Tiền đề: phiếu KHÔNG còn nhìn thấy qua đường thường, nhưng VẪN nằm trong bảng. + (await h.Db.ContractSigningPlans.AsNoTracking() + .CountAsync(p => p.ApprovalWorkflowId == used.Id, CancellationToken.None)) + .Should().Be(0, "global filter che phiếu xoá mềm — đây chính là cái bẫy"); + (await h.Db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking() + .CountAsync(p => p.ApprovalWorkflowId == used.Id, CancellationToken.None)) + .Should().Be(1, "row vẫn còn ⇒ FK Restrict vẫn sẽ nổ nếu guard bỏ qua nó"); + + var act = async () => await DeleteWorkflowAsync(h, used.Id); + await act.Should().ThrowAsync( + "F2: phiếu xoá mềm là phiếu KHÔI PHỤC ĐƯỢC (màn 'Đã xoá') — guard phải đọc bằng " + + "IgnoreQueryFilters. Nhận DbUpdateException ở đây = guard đang mù trước soft-delete"); + (await WorkflowRowCountAsync(h, used.Id)).Should().Be(1); + + await DeleteWorkflowAsync(h, free.Id); + (await WorkflowRowCountAsync(h, free.Id)).Should().Be(0, + "control dương: khác đúng một biến (có/không phiếu xoá-mềm pin) ⇒ hai kết quả trái ngược"); + } + } + + // =================================================================== + // (T12) F3 — phiếu Văn phòng số (nghỉ phép) pin quy trình ⇒ cũng phải 409. + // 🔴 Đây là ca DATA-INTEGRITY, không phải UX: 5 module Office pin **LOOSE, 0 FK** ⇒ hôm nay + // DELETE trả 200 và đơn nghỉ phép trỏ vào một quy trình KHÔNG CÒN TỒN TẠI (dangling, im lặng). + // Guard usage-check là RÀO DUY NHẤT — chứng minh bằng chính DB ở cuối test. + // =================================================================== + [Fact] + public async Task T12_DeleteWorkflow_PinnedByLeaveRequest_Blocked409_BecauseGuardIsTheOnlyFence() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + var staff = await h.Fix.CreateUserAsync("nv@t.vn", "Nhân viên xin nghỉ", null, []); + + var used = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.LeaveRequest, + [[(1, alice.Id)]], code: "QT-NP-V2-001"); + var witness = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.LeaveRequest, + [[(1, alice.Id)]], code: "QT-NP-V2-002"); + var leave = await SeedLeaveRequestAsync(h.Db, used.Id, staff.Id, "DT/LR/2026/001"); + var leaveWitness = await SeedLeaveRequestAsync(h.Db, witness.Id, staff.Id, "DT/LR/2026/002"); + + var act = async () => await DeleteWorkflowAsync(h, used.Id); + await act.Should().ThrowAsync( + "đơn nghỉ phép đang pin quy trình này — xoá là đơn treo trỏ vào hư không"); + (await WorkflowRowCountAsync(h, used.Id)).Should().Be(1); + (await h.Db.LeaveRequests.AsNoTracking().FirstAsync(x => x.Id == leave.Id, CancellationToken.None)) + .ApprovalWorkflowId.Should().Be(used.Id, "đơn vẫn pin nguyên — 409 không được đụng dữ liệu"); + + // 🔴 CHỨNG-NHÂN "rào duy nhất": xoá THẲNG quy trình thứ hai qua DbContext. + // Với PE/HĐ/KHKK thì câu này nổ FK; với module Office nó ĐI LỌT — và đơn nghỉ phép + // thành mồ côi. Đó là lý do guard này là DATA-INTEGRITY chứ không phải làm đẹp UX. + h.Db.ChangeTracker.Clear(); + var tracked = await h.Db.ApprovalWorkflows.FirstAsync(w => w.Id == witness.Id, CancellationToken.None); + h.Db.ApprovalWorkflows.Remove(tracked); + await h.Db.SaveChangesAsync(CancellationToken.None); // KHÔNG ném — 0 FK bảo vệ + + (await WorkflowRowCountAsync(h, witness.Id)).Should().Be(0); + var orphan = await h.Db.LeaveRequests.AsNoTracking() + .FirstAsync(x => x.Id == leaveWitness.Id, CancellationToken.None); + orphan.ApprovalWorkflowId.Should().Be(witness.Id, + "đơn vẫn giữ Id của quy trình đã biến mất = dangling pointer THẬT trong DB — " + + "đúng thứ mà guard 409 ở trên tồn tại để chặn"); + } + } + + // =================================================================== + // (T13) Fix (d)(iii) — id không tồn tại phải là 404, không phải 500 thô. + // `KeyNotFoundException` là BCL, `GlobalExceptionMiddleware` KHÔNG map ⇒ rơi 500. + // Control dương cùng lượt: id THẬT (0 usage, ngoài họ) xoá trơn ⇒ 404 là do id, không phải + // do handler hỏng. + // =================================================================== + [Fact] + public async Task T13_DeleteWorkflow_UnknownId_ThrowsNotFound_NotRawBclKeyNotFound() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + var real = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001"); + + var act = async () => await DeleteWorkflowAsync(h, Guid.NewGuid()); + await act.Should().ThrowAsync( + "middleware chỉ map NotFoundException→404; KeyNotFoundException (BCL) rơi ra 500 thô"); + + await DeleteWorkflowAsync(h, real.Id); + (await WorkflowRowCountAsync(h, real.Id)).Should().Be(0, + "control dương: đường xoá vẫn chạy bình thường ⇒ ngoại lệ trên là VỀ ID, không phải handler chết"); + } + } + + // =================================================================== + // (T14) D5 — nhóm `KHKK-N{n}` phải luôn còn ≥1 version. + // Xoá bản CUỐI của một nhóm = đóng cửa lập phiếu nhóm đó, và đường mở lại duy nhất là + // seeder chạy khi boot (M-1) — tức người dùng không tự cứu được từ UI. + // Ba chân trên CÙNG một bộ seed, mỗi chân bịt một kiểu vá sai: + // (1) xoá v01 khi còn v02 ⇒ OK (vá "cấm xoá mọi bản của nhóm" sẽ ĐỎ ở đây) + // (2) xoá v02 khi nó là bản cuối ⇒ 409 + // (3) xoá quy trình NGOÀI họ chỉ có 1 version ⇒ OK (vá "cấm xoá bản-duy-nhất" sẽ ĐỎ ở đây) + // =================================================================== + [Fact] + public async Task T14_DeleteLastVersionOfKhkkGroup_Blocked409_ButNonLastAndOutOfFamilyDeleteFine() + { + var h = NewHarness(); + using (h.Fix) + { + var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []); + + var n2v1 = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N2", version: 1); + var n2v2 = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "KHKK-N2", version: 2); + var legacy = await SeedWorkflowAsync(h.Db, Khkk, [[(1, alice.Id)]], code: "QT-KHKK-V2-001"); + + // Tiền đề: KHÔNG phiếu nào pin ⇒ 409 (nếu có) chỉ có thể đến từ D5, không phải usage-check. + (await h.Db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking() + .CountAsync(CancellationToken.None)).Should().Be(0, "0 usage ⇒ tách bạch D5 với rào (i)"); + + // ---- (1) còn v02 đứng sau ⇒ xoá v01 hợp lệ. + await DeleteWorkflowAsync(h, n2v1.Id); + (await WorkflowRowCountAsync(h, n2v1.Id)).Should().Be(0, + "version cũ 0-usage KHÔNG phải bản cuối thì vẫn dọn được — rào D5 không được nuốt luôn ca này"); + (await WorkflowRowCountAsync(h, n2v2.Id)).Should().Be(1); + + // ---- (2) nay v02 là bản CUỐI của nhóm N2 ⇒ chặn. + var act = async () => await DeleteWorkflowAsync(h, n2v2.Id); + var ex = (await act.Should().ThrowAsync( + "xoá bản cuối = nhóm N2 mất quy trình, mà đường tái sinh duy nhất là seeder lúc boot")).Which; + ex.Message.Should().Contain("N2", "thông điệp phải chỉ đích danh nhóm đang bị đóng cửa"); + (await WorkflowRowCountAsync(h, n2v2.Id)).Should().Be(1, "409 thì bản cuối phải còn nguyên"); + + // ---- (3) ngoài họ, chỉ 1 version ⇒ KHÔNG dính D5. + await DeleteWorkflowAsync(h, legacy.Id); + (await WorkflowRowCountAsync(h, legacy.Id)).Should().Be(0, + "D5 chỉ áp cho họ KHKK-N[1-8]; chặn cả 'bản duy nhất' của quy trình chung là siết oan"); + } + } }