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 và đã 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.
+
+ {/* [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 và đã 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");
+ }
+ }
}