[CLAUDE] Workflow: KHKK 8 panel nhóm mirror DuyetNcc + BE guards (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s

YC-013 (S182/L13). 8 leaf "Duyệt KHKK — Nhóm 1..8" ra đúng khuôn trang
DuyetNcc: slice per-Code + lockedCode + Sửa-tại-chỗ/Tạo-version/Ghim/Xoá.
D3=C (YC-014): 6 ô chưa-wire disabled-giữ-state-thật + tooltip trên label
(Chromium không hiện title trên input disabled — MAJOR-1); ô #4 thêm chữ
nhìn thấy được; #7 giữ; #8 Finalize 0-chạm (prod 8/88 dựa nó); ô CEO
disable type-10 + POST null.

BE 5 guard (0 migration):
- W1a deactivate-per-Code cho type-10 (Code+Type+IsActive — cấm regex);
  Code normalize Trim().ToUpperInvariant() tại đường ghi + IgnoreCase belt
  trong GroupCodePattern (MAJOR-2: SQLite test case-sensitive nguoc prod
  CI_AS collation)
- W1b ContractSigningPlanWorkflowGuards (public static, Application) —
  rào nhóm 3 cửa Create/UpdateDraft/Submit, giữ cho-qua-code-ngoài-họ
- W1b2 (D6) pin-check IsActive+IsUserSelectable 2 cửa, 2 message tách
- W1c changelog PUT type-10 vào ContractSigningPlanChangelogs
- W1d DELETE usage-check 409 đích danh (8 module pin, IgnoreQueryFilters
  cho PE/Contract/KHKK; 5 module office pin loose-0FK — guard là rào duy
  nhất chống dangling) + 404 NotFoundException + D5 rào version-cuối nhóm
  (+ ApplicableType — MINOR-3)

Test 645 → 660 (+14 spec + T15 D6-Create MAJOR-3); MINOR-1 pin nguyên câu
thứ-tự 1 ca. File test thứ 3 (GroupAndCatalog) đổi 1 assertion theo câu
409 mới của W1b — spec-change, không phải nới bar.

Khai accepted (reviewer PASS_WITH_FIXES 0C/3M+6m, đã vá 3M+2m):
- MINOR-4: panel nhóm tạo-version-mới ⇒ 6 cờ khoá đóng băng default FE
  (vô hại — BE không đọc 6 cờ đó cho type-10)
- MINOR-5: bỏ ghim/bỏ active bản cuối nhóm = đóng cửa tạo phiếu nhóm đó
  (răng thật của nút Ghim — không rào đợt này)
- race 2-admin cùng Code → 2601/500 thô (UNIQUE đỡ data, chấp nhận)

Evidence: .claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
(3 fable-invest + 2 fable-review + spec FINAL + 4 lane + precheck prod
12 phiếu/pin-lệch-0 + reviewer pre-commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-08 12:14:37 +07:00
parent 18f8f76bac
commit 706134daa9
13 changed files with 1604 additions and 89 deletions

View File

@ -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-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-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-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.** > 🔴 **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.**
> >

View File

@ -30,6 +30,21 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc', AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', 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 // [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. // "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) // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)

View File

@ -59,6 +59,11 @@ interface WorkflowDto {
code: string code: string
name: string name: string
isUserSelectable: boolean 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 // 🧊 [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). // `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 // 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. // 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 wantWorkflowCode = `KHKK-N${approvalGroup}`
const autoWorkflow = workflows.data?.find( const autoWorkflow = workflows.data?.find(
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode, (w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
) ?? null ) ?? null
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. // 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({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') 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 = { const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId: autoWorkflow.id, approvalWorkflowId: autoWorkflow.id,
@ -349,9 +364,10 @@ export function KhkkCreatePage() {
</div> </div>
{!workflows.isLoading && !autoWorkflow ? ( {!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa có quy trình <strong>{wantWorkflowCode}</strong> (hoặc chưa đưc ghim Chưa dùng đưc quy trình <strong>{wantWorkflowCode}</strong>: phải
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống phiên bản <strong>đang áp dụng</strong> <strong>đã ghim</strong> cho user chọn.
KHÔNG tự ng quy trình của nhóm khác. 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.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">

View File

@ -31,6 +31,9 @@ import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import type { Department, Paged } from '@/types/master' 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) ===== // ===== Types (mirror BE AwAdminOverviewDto) =====
@ -286,7 +289,21 @@ function isLevelEnabled(step: EditStep, order: LevelOrder): boolean {
export function ApprovalWorkflowsV2Page() { export function ApprovalWorkflowsV2Page() {
const qc = useQueryClient() const qc = useQueryClient()
const { typeCode } = useParams<{ typeCode?: string }>() 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({ const overview = useQuery({
queryKey: ['approval-workflow-v2-overview', selectedTypeInt], queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
@ -296,25 +313,60 @@ export function ApprovalWorkflowsV2Page() {
}, },
}) })
const currentType = selectedTypeInt const typeBucket = selectedTypeInt
? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null
: 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 ( return (
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
// [W2h/D4 S182] Vào từ leaf nhóm thì tiêu đề phải nói ĐÚNG nhóm nào —
// `applicableTypeLabel` của BE chỉ có một chuỗi chung cho cả 8 nhóm
// ("Kế hoạch ký kết HĐ"), 8 panel sẽ trông y hệt nhau.
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Workflow className="h-5 w-5" /> <Workflow className="h-5 w-5" />
{currentType {khkkGroup !== null
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}` ? `Quy trình duyệt (Mới): ${khkkGroupMenuLabel(khkkGroup)}`
: 'Quy trình duyệt (Mới)'} : currentType
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
: 'Quy trình duyệt (Mới)'}
</span> </span>
} }
description={ description={
currentType khkkGroup !== null
? '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ế.' ? `${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.`
: 'Schema mới UAT — chọn loại quy trình từ menu bên trá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 && ( {currentType && (
<TypePanel <TypePanel
// [W2b S182] Panel nhóm và panel loại dùng CHUNG component: khác nhau duy
// nhất ở lát cắt truyền vào + `lockedCode` (null = panel loại, tự do đặt mã).
key={typeCode ?? 'all'}
type={currentType} type={currentType}
lockedCode={lockedCode}
onSaved={() => qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })} onSaved={() => qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })}
/> />
)} )}
@ -354,7 +410,17 @@ export function ApprovalWorkflowsV2Page() {
// ===== Per-type panel ===== // ===== 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 [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null) const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id + // [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
<Designer <Designer
applicableType={type.applicableType} applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel} applicableTypeLabel={type.applicableTypeLabel}
lockedCode={lockedCode}
cloneFrom={cloneFrom} cloneFrom={cloneFrom}
editTarget={editTarget} editTarget={editTarget}
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }} onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
@ -620,6 +687,7 @@ function DefinitionCard({
function Designer({ function Designer({
applicableType, applicableType,
applicableTypeLabel, applicableTypeLabel,
lockedCode,
cloneFrom, cloneFrom,
editTarget, editTarget,
onClose, onClose,
@ -627,6 +695,8 @@ function Designer({
}: { }: {
applicableType: number applicableType: number
applicableTypeLabel: string 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 cloneFrom: DefinitionDto | null
// [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom. // [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom.
editTarget: DefinitionDto | null editTarget: DefinitionDto | null
@ -638,16 +708,38 @@ function Designer({
const isEdit = editTarget !== null const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom const source = editTarget ?? cloneFrom
// [K3 S164GỠ KHOÁ] `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN: 2 ô "duyệt sớm" // [W2d/W2f S182D3=C] Kế hoạch ký kết HĐ (type 10, gồm cả 8 panel nhóm) chưa
// nay sống cho MỌI loại quy trình, kể cả Kế hoạch ký kết HĐ (type 10) — BE đã port // 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 ô
// đường finalize ở K3 cùng wave, và owner chốt TP.CCM là người quyết lên CEO hay // (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.
// kết thúc. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`). 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 <label>, TUYỆT ĐỐI KHÔNG trên <input disabled>:
// Chromium (Chrome/Edge — trình duyệt đích) nuốt sự kiện chuột của form-control
// disabled ⇒ tooltip KHÔNG bao giờ bật, admin chỉ còn thấy mấy ô xám vô cớ. Label
// không disabled nên rê vào chữ là hiện. Ai dời ngược lên input = làm câm lại.
const khkkNotYetHint = 'Chưa áp dụng cho KHKK'
// Ô "Trả về Drafter" (#4) khác 6 ô kia về NGHĨA, nên câu riêng — và câu này còn
// hiện thành chữ NHÌN THẤY ĐƯỢC ngay dưới ô: đây là thông tin PHÂN BIỆT #4 với 6 ô
// "chưa áp dụng", không được để nó phụ thuộc vào việc người dùng có rê chuột hay không.
// Một chuỗi dùng cho cả tooltip lẫn dòng chữ, để hai chỗ không trôi lệch nhau.
const khkkDrafterHint = 'KHKK hiện luôn trả về người soạn khi Trả lại — cờ chưa phân nhánh'
// [K3 S164 — GỠ KHOÁ · vá chữ W2h S182] Cờ `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN
// và ô "Duyệt là KẾT THÚC" (#8) nay SỐNG cho mọi loại — BE đã port đường finalize ở K3
// cùng wave, owner chốt TP.CCM là người quyết lên CEO hay kết thúc.
// 🔴 Đính chính câu cũ ("2 ô duyệt sớm sống cho MỌI loại"): chỉ có MỘT trong hai ô đó
// sống cho KHKK. Ô còn lại — "Duyệt thẳng Cấp cuối" (#7) — vẫn KHOÁ cho type 10 từ K3
// S165 (service KHKK chưa port nhánh SkipToFinal, BE Create còn ép-false), xem ngay
// tại ô đó. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`).
const initialSteps: EditStep[] = useMemo( const initialSteps: EditStep[] = useMemo(
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]), () => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source], [source],
) )
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001' // Panel nhóm: mã đã khoá thì ô Mã phải hiện ĐÚNG mã sẽ gửi đi, không phải mã gợi ý
// chung của loại (`QT-KHKK-V2-001`) — hiện một đằng gửi một nẻo là kiểu bẫy tệ nhất.
const defaultCode = lockedCode ?? DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(source?.code ?? defaultCode) const [code, setCode] = useState(source?.code ?? defaultCode)
const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`) const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(source?.description ?? '') const [description, setDescription] = useState(source?.description ?? '')
@ -795,11 +887,19 @@ function Designer({
// ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============ // ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============
await api.post('/approval-workflows-v2', { await api.post('/approval-workflows-v2', {
applicableType, applicableType,
code, // [W2c S182] Panel nhóm: mã lấy từ prop khoá, KHÔNG lấy từ state ô nhập.
// Ô nhập đang disabled nên state không đổi được qua UI — nhưng gửi thẳng
// `lockedCode` là bất-biến theo CẤU TRÚC, không phụ thuộc kỷ luật của ô input.
code: lockedCode ?? code,
name, name,
description: description || null, description: description || null,
// S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số. // S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số.
ceoApprovalThreshold: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')), // [W2f S182] KHKK KHÔNG dùng ngưỡng máy (người được uỷ quyền quyết lên CEO hay
// không, qua ô "Duyệt là KẾT THÚC") ⇒ gửi thẳng null, không để giá trị lỡ tay
// nằm lại trong state rồi âm thầm bật một nhánh rẽ mà quy trình KHKK không chạy.
ceoApprovalThreshold: isKhkk
? null
: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
steps: steps.map((s, i) => ({ steps: steps.map((s, i) => ({
order: i + 1, order: i + 1,
name: s.name, name: s.name,
@ -924,12 +1024,16 @@ function Designer({
required required
className="font-mono" className="font-mono"
// Mã là khoá của chuỗi phiên bản ⇒ sửa tại chỗ KHÔNG đổi được (BE :521 → 409). // Mã là khoá của chuỗi phiên bản ⇒ sửa tại chỗ KHÔNG đổi được (BE :521 → 409).
disabled={isEdit} // [W2c S182] Panel nhóm KHKK cũng khoá: đổi mã ở đây = đẻ quy trình mồ côi
// (phiếu của nhóm vẫn đi tìm đúng `KHKK-N{n}`).
disabled={isEdit || lockedCode !== null}
/> />
<div className="text-[11px] text-slate-400"> <div className="text-[11px] text-slate-400">
{isEdit {isEdit
? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.' ? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'} : lockedCode !== null
? 'Mã khoá theo nhóm — version tự tăng.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -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 // 🔴 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 // 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. // 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}
/> />
<div className="text-[11px] leading-relaxed text-slate-400"> <div className="text-[11px] leading-relaxed text-slate-400">
{isEdit {isKhkk
? '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.' ? '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.'
: '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.'} : 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.'}
</div> </div>
</div> </div>
@ -984,6 +1093,17 @@ function Designer({
<div className="rounded-lg border border-violet-200 bg-violet-50/30 px-3 py-2 text-[11px] leading-relaxed text-violet-800"> <div className="rounded-lg border border-violet-200 bg-violet-50/30 px-3 py-2 text-[11px] leading-relaxed text-violet-800">
Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới 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). (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 && (
<div className="mt-1 text-violet-900">
Riêng Kế hoạch kết : một số ô bị khoá 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 tác dụng
thật <span className="font-semibold">&quot;Trả về Drafter&quot;</span> (KHKK luôn trả về
người soạn) <span className="font-semibold">&quot;Duyệt KẾT THÚC&quot;</span> tick cho NV
đưc uỷ quyền quyết dừng hay trình tiếp CEO.
</div>
)}
</div> </div>
<div className="space-y-2 rounded-lg border border-slate-200 p-3"> <div className="space-y-2 rounded-lg border border-slate-200 p-3">
@ -1228,57 +1348,107 @@ function Designer({
<div className="mb-1 text-[10px] font-medium uppercase text-amber-700"> <div className="mb-1 text-[10px] font-medium uppercase text-amber-700">
Quyền duyệt {usersList.data?.find(u => u.id === entry.approverUserId)?.fullName ?? 'Chưa chọn NV'} Quyền duyệt {usersList.data?.find(u => u.id === entry.approverUserId)?.fullName ?? 'Chưa chọn NV'}
</div> </div>
{/* [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). */}
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
<label className="flex items-center gap-1 text-[11px] text-slate-700"> <label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={entry.allowReturnOneLevel} checked={entry.allowReturnOneLevel}
disabled={isKhkk}
onChange={e => updateField('allowReturnOneLevel', e.target.checked)} onChange={e => updateField('allowReturnOneLevel', e.target.checked)}
/> />
<span>Trả về 1 Cấp trước</span> <span>Trả về 1 Cấp trước</span>
</label> </label>
<label className="flex items-center gap-1 text-[11px] text-slate-700"> <label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={entry.allowReturnOneStep} checked={entry.allowReturnOneStep}
disabled={isKhkk}
onChange={e => updateField('allowReturnOneStep', e.target.checked)} onChange={e => updateField('allowReturnOneStep', e.target.checked)}
/> />
<span>Trả về 1 Bước trước</span> <span>Trả về 1 Bước trước</span>
</label> </label>
<label className="flex items-center gap-1 text-[11px] text-slate-700"> <label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={entry.allowReturnToAssignee} checked={entry.allowReturnToAssignee}
disabled={isKhkk}
onChange={e => updateField('allowReturnToAssignee', e.target.checked)} onChange={e => updateField('allowReturnToAssignee', e.target.checked)}
/> />
<span>Trả về Người chỉ đnh</span> <span>Trả về Người chỉ đnh</span>
</label> </label>
<label className="flex items-center gap-1 text-[11px] text-slate-700"> {/* [W2d S182] Ô này KHÁC 6 ô kia: KHKK có chạy nhánh trả-về-người-soạn,
và chạy KHÔNG ĐIỀU KIỆN — cờ chưa được phân nhánh ở service nên bỏ
tick cũng không tắt được. Vì vậy khoá nhưng KHÔNG làm mờ kiểu
"chưa có": mờ ở đây sẽ đọc thành "tính năng chưa chạy", ngược hẳn
sự thật. Câu nhắc viết riêng, không dùng câu chung. */}
<label
className="flex items-center gap-1 text-[11px] text-slate-700"
title={isKhkk ? khkkDrafterHint : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={entry.allowReturnToDrafter} checked={entry.allowReturnToDrafter}
disabled={isKhkk}
onChange={e => updateField('allowReturnToDrafter', e.target.checked)} onChange={e => updateField('allowReturnToDrafter', e.target.checked)}
/> />
<span>Trả về Drafter (mặc đnh)</span> <span>Trả về Drafter (mặc đnh)</span>
</label> </label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700"> {/* [W2d S182 — vá MAJOR-1] Câu này là thứ DUY NHẤT phân biệt ô trên
với 6 ô "chưa áp dụng" cùng khối, nên nó phải NHÌN THẤY ĐƯỢC chứ
không nằm sau một cú rê chuột. `col-start-2` để dòng chữ rơi đúng
dưới ô của nó (cột phải), không bị đọc nhầm sang ô bên trái. */}
{isKhkk && (
<div className="col-start-2 -mt-0.5 pl-4 text-[10px] leading-snug text-slate-500">
{khkkDrafterHint}
</div>
)}
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={entry.allowApproverEditDetails} checked={entry.allowApproverEditDetails}
disabled={isKhkk}
onChange={e => updateField('allowApproverEditDetails', e.target.checked)} onChange={e => updateField('allowApproverEditDetails', e.target.checked)}
/> />
<span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span> <span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span>
</label> </label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700"> <label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={entry.allowApproverEditBudget} checked={entry.allowApproverEditBudget}
disabled={isKhkk}
onChange={e => updateField('allowApproverEditBudget', e.target.checked)} onChange={e => updateField('allowApproverEditBudget', e.target.checked)}
/> />
<span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span> <span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span>
@ -1289,13 +1459,20 @@ function Designer({
service KHKK CHƯA port nhánh SkipToFinal + BE CREATE vẫn ép-false cờ service KHKK CHƯA port nhánh SkipToFinal + BE CREATE vẫn ép-false cờ
này cho type-10 ⇒ ô này DISABLE cho KHKK kẻo tick bị nuốt im lặng này cho type-10 ⇒ ô này DISABLE cho KHKK kẻo tick bị nuốt im lặng
(class "UI nói một đằng DB một nẻo" — đúng thứ W1 S161 cảnh báo). */} (class "UI nói một đằng DB một nẻo" — đúng thứ W1 S161 cảnh báo). */}
<label className={`col-span-2 flex items-center gap-1 text-[11px] ${applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'text-slate-400' : 'text-slate-700'}`}> {/* [W2d S182] GIỮ NGUYÊN hành vi ô này (kể cả `checked` ép false —
khác 6 ô trên, đây là quyết định cũ của K3 S165 và BE Create
ĐANG THẬT SỰ ép-false cờ này cho type 10, nên màn hình đang
khớp DB). Chỉ đổi 3 biểu thức trùng lặp sang cờ dùng chung
`isKhkk` — giá trị y hệt, để 8 chỗ khoá không lệch nhau. */}
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? 'Kế hoạch ký kết chưa hỗ trợ duyệt vượt cấp — service chưa port nhánh này (K3)' : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? false : entry.allowApproverSkipToFinal} checked={isKhkk ? false : entry.allowApproverSkipToFinal}
disabled={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN} disabled={isKhkk}
title={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'Kế hoạch ký kết chưa hỗ trợ duyệt vượt cấp — service chưa port nhánh này (K3)' : undefined}
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)} onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
/> />
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span> <span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
@ -1317,11 +1494,15 @@ function Designer({
per-Cấp: 1 dòng NV = 1 ô tick riêng. Tick cho NV nào thì per-Cấp: 1 dòng NV = 1 ô tick riêng. Tick cho NV nào thì
CHỈ NV đó xóa được, và chỉ khi phiếu đang Chờ duyệt ĐÚNG CHỈ NV đó xóa được, và chỉ khi phiếu đang Chờ duyệt ĐÚNG
lượt của họ. Cấp có 3 NV mà tick 1 ⇒ vẫn chỉ 1 người xóa. */} lượt của họ. Cấp có 3 NV mà tick 1 ⇒ vẫn chỉ 1 người xóa. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-rose-700"> <label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-rose-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input <input
type="checkbox" type="checkbox"
className="h-3 w-3" className="h-3 w-3"
checked={entry.allowApproverDelete} checked={entry.allowApproverDelete}
disabled={isKhkk}
onChange={e => updateField('allowApproverDelete', e.target.checked)} onChange={e => updateField('allowApproverDelete', e.target.checked)}
/> />
<span>Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình</span> <span>Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình</span>

View File

@ -30,6 +30,20 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc', AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', 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}).
// ⚠️ Chép cho ĐỦ bảng đối chiếu FE↔BE, KHÔNG phải để dùng ở app này: toàn bộ họ
// `AwV2_*` là màn CẤU HÌNH bên fe-admin — fe-user cố ý không có trang Designer,
// cũng không có nhánh `AwV2_*` trong `Layout.tsx`. Đừng đọc mấy dòng này thành
// "fe-user thiếu route" rồi đi thêm route.
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 // [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. // "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) // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)

View File

@ -59,6 +59,11 @@ interface WorkflowDto {
code: string code: string
name: string name: string
isUserSelectable: boolean 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 // 🧊 [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). // `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 // 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. // 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 wantWorkflowCode = `KHKK-N${approvalGroup}`
const autoWorkflow = workflows.data?.find( const autoWorkflow = workflows.data?.find(
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode, (w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
) ?? null ) ?? null
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. // 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({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') 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 = { const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId: autoWorkflow.id, approvalWorkflowId: autoWorkflow.id,
@ -349,9 +364,10 @@ export function KhkkCreatePage() {
</div> </div>
{!workflows.isLoading && !autoWorkflow ? ( {!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa có quy trình <strong>{wantWorkflowCode}</strong> (hoặc chưa đưc ghim Chưa dùng đưc quy trình <strong>{wantWorkflowCode}</strong>: phải
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống phiên bản <strong>đang áp dụng</strong> <strong>đã ghim</strong> cho user chọn.
KHÔNG tự ng quy trình của nhóm khác. 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.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">

View File

@ -4,7 +4,9 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans; // [S182 W1d] ContractSigningPlanWorkflowGuards.ParseGroup
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans; // [S182 W1c] changelog + phase KHKK
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Domain.PurchaseEvaluations;
@ -330,21 +332,53 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
{ {
var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType; 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) // Auto-increment version theo Code (cùng Code = cùng "logical" workflow)
var nextVersion = await db.ApprovalWorkflows var nextVersion = await db.ApprovalWorkflows
.Where(w => w.Code == request.Code) .Where(w => w.Code == code)
.MaxAsync(w => (int?)w.Version, ct) ?? 0; .MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++; nextVersion++;
// Deactivate active version cho ApplicableType này (only ONE active per type) // Tắt bản đang active — PHẠM VI phụ thuộc loại (bản trước: LUÔN per-type).
var actives = await db.ApprovalWorkflows //
.Where(w => w.ApplicableType == typeEnum && w.IsActive) // • type-10 `ContractSigningPlan` (KHKK): **per-CODE**. Loại này có 8 quy trình
.ToListAsync(ct); // 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; foreach (var old in actives) old.IsActive = false;
var def = new ApprovalWorkflow 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, Version = nextVersion,
ApplicableType = typeEnum, ApplicableType = typeEnum,
Name = request.Name, 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". // đượ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à // ⚠️ 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 // đợ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: // phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/
// `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/
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng. // 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 && if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
or ApprovalWorkflowApplicableType.DuyetNccPhuongAn) or ApprovalWorkflowApplicableType.DuyetNccPhuongAn)
@ -871,10 +912,6 @@ public class UpdateAwDefinitionCommandHandler(
.Select(p => new { p.Id, p.Phase }) .Select(p => new { p.Id, p.Phase })
.ToListAsync(ct); .ToListAsync(ct);
var note = string.Join(" | ", changes);
if (!string.IsNullOrWhiteSpace(request.Reason))
note = $"Lý do: {request.Reason.Trim()} | {note}";
foreach (var pe in affected) foreach (var pe in affected)
{ {
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
@ -885,8 +922,48 @@ public class UpdateAwDefinitionCommandHandler(
EntityId = def.Id, EntityId = def.Id,
PhaseAtChange = pe.Phase, PhaseAtChange = pe.Phase,
UserId = currentUser.UserId, UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống", UserName = actorName,
Summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)", 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, ContextNote = note,
}); });
} }
@ -1184,9 +1261,21 @@ public class SetAwUserSelectableCommandHandler(IApplicationDbContext db)
} }
} }
// ========== DELETE version (chỉ khi chưa có phiếu pin) ========== // ========== DELETE version — CHẶN khi còn phiếu pin / còn chữ ký ==========
// 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. // 🔴 [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; public record DeleteAwDefinitionCommand(Guid Id) : IRequest;
@ -1195,9 +1284,90 @@ public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db)
{ {
public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct) 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 var def = await db.ApprovalWorkflows
.FirstOrDefaultAsync(d => d.Id == request.Id, ct) .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); db.ApprovalWorkflows.Remove(def);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@ -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Đ."); 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 /// Precompute ID phiếu mà `userId` là approver của Cấp ĐANG CHỜ — mirror
/// `PurchaseEvaluationFeatures.cs:817-852 ResolveV2InboxIdsAsync`. /// `PurchaseEvaluationFeatures.cs:817-852 ResolveV2InboxIdsAsync`.
/// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list /// 🔴 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`. // ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct); 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 // ---- 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 // `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 // = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho
// 8 workflow nhóm seed @K3. // 8 workflow nhóm seed @K3.
var wfCode = await db.ApprovalWorkflows.AsNoTracking() // [S182 W1b] Thân rào DỜI sang `ContractSigningPlanWorkflowGuards` — cùng luật này
.Where(w => w.Id == request.ApprovalWorkflowId) // 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.
.Select(w => w.Code).FirstOrDefaultAsync(ct); await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
var groupMatch = System.Text.RegularExpressions.Regex.Match(wfCode ?? string.Empty, "^KHKK-N([1-8])$"); db, request.ApprovalWorkflowId, approvalGroup, ct);
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.");
// 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT // 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
// (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE + // (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
@ -600,9 +630,18 @@ public class UpdateContractSigningPlanDraftCommandHandler(
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser); 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). // 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) if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId)
{ {
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, awId, ct); await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, awId, ct);
await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, awId, ct);
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
db, awId, plan.ApprovalGroup, ct);
plan.ApprovalWorkflowId = awId; plan.ApprovalWorkflowId = awId;
} }

View File

@ -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.");
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; 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.ContractSigningPlans.Services;
using SolutionErp.Application.Notifications; using SolutionErp.Application.Notifications;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
@ -186,6 +187,15 @@ public class ContractSigningPlanWorkflowService(
throw new ConflictException( 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}."); $"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 steps = await LoadStepsAsync(awId, ct);
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList(); var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
if (firstLevelGroup.Count == 0) if (firstLevelGroup.Count == 0)

View File

@ -131,7 +131,13 @@ public class ContractSigningPlanApprovalTests
string code, string code,
Guid[][][] steps, Guid[][][] steps,
int stepOrderStart = 1, 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()); await using var db = f.NewDb(TestCurrentUser.System());
@ -139,13 +145,13 @@ public class ContractSigningPlanApprovalTests
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Code = code, Code = code,
Version = 1, Version = version,
Name = "Quy trình duyệt Kế hoạch ký kết", 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 // 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`). // của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan, ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
IsActive = true, IsActive = isActive,
IsUserSelectable = true, IsUserSelectable = isUserSelectable,
}; };
db.ApprovalWorkflows.Add(wf); db.ApprovalWorkflows.Add(wf);
@ -193,21 +199,29 @@ public class ContractSigningPlanApprovalTests
ContractSigningPlanPhase phase, ContractSigningPlanPhase phase,
int? stepIndex, int? stepIndex,
int? levelOrder, 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)). // 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); 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. // [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) // 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 đủ"
// — mirror phiếu "đã gán đủ" trước trình; Code random né unique-filtered giữa các plan. // 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 var catalog = new ContractCatalogEntry
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Code = "T-" + Guid.NewGuid().ToString("N")[..8], Code = "T-" + Guid.NewGuid().ToString("N")[..8],
TenVi = "Hạng mục test W3", TenVi = "Hạng mục test W3",
GroupCode = "B1", GroupCode = "B1",
ApprovalGroup = 1, ApprovalGroup = approvalGroup,
SignerRole = "TP.PRO", SignerRole = "TP.PRO",
IsActive = true, IsActive = true,
SortOrder = 1, SortOrder = 1,
@ -225,6 +239,7 @@ public class ContractSigningPlanApprovalTests
DepartmentId = Guid.NewGuid(), DepartmentId = Guid.NewGuid(),
DrafterUserId = drafter.UserId!.Value, DrafterUserId = drafter.UserId!.Value,
Phase = phase, Phase = phase,
ApprovalGroup = approvalGroup,
ApprovalWorkflowId = workflowId, ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = stepIndex, CurrentWorkflowStepIndex = stepIndex,
CurrentApprovalLevelOrder = levelOrder, CurrentApprovalLevelOrder = levelOrder,
@ -1138,4 +1153,299 @@ public class ContractSigningPlanApprovalTests
.Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize)) .Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
.ToListAsync(CancellationToken.None); .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<Guid?> 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<int> 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<ConflictException>(
"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<ConflictException>(
"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<ConflictException>(
"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<ConflictException>(
"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<ConflictException>(
"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");
}
} }

View File

@ -142,18 +142,25 @@ public class ContractSigningPlanGroupAndCatalogTests
KhkkGroupFixture f, KhkkGroupFixture f,
string code, string code,
Guid[][][] steps, 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()); await using var db = f.NewDb(TestCurrentUser.System());
var wf = new ApprovalWorkflow var wf = new ApprovalWorkflow
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Code = code, Code = code,
Version = 1, Version = version,
Name = "Quy trình duyệt Kế hoạch ký kết", Name = "Quy trình duyệt Kế hoạch ký kết",
ApplicableType = type, ApplicableType = type,
IsActive = true, IsActive = isActive,
IsUserSelectable = true, IsUserSelectable = isUserSelectable,
}; };
db.ApprovalWorkflows.Add(wf); db.ApprovalWorkflows.Add(wf);
@ -855,9 +862,21 @@ public class ContractSigningPlanGroupAndCatalogTests
var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3); var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3);
var ex = await wrongGroup.Should().ThrowAsync<ConflictException>( var ex = await wrongGroup.Should().ThrowAsync<ConflictException>(
"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"); "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", // [S182 W1b — spec change, KHÔNG phải nới bar] Thân rào dời sang
"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 " // `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync` dùng chung cho 3 cửa
+ "chỉ thấy '409' và không biết đổi cái gì"); // (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. // 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"); (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); .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<ConflictException>(
"'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<ConflictException>(
"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 // [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 // auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn

View File

@ -4,9 +4,10 @@ using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.ApprovalWorkflowsV2; using SolutionErp.Application.ApprovalWorkflowsV2;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2; 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.Contracts; // ApprovalDecision + ChangelogAction (shared HĐ/PE)
using SolutionErp.Domain.Identity; 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.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services; using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common; using SolutionErp.Infrastructure.Tests.Common;
@ -67,12 +68,15 @@ public class UpdateAwDefinitionTests
ApprovalWorkflowApplicableType type, ApprovalWorkflowApplicableType type,
(int LevelOrder, Guid User)[][] stepSpecs, (int LevelOrder, Guid User)[][] stepSpecs,
decimal? ceoThreshold = null, 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 var wf = new ApprovalWorkflow
{ {
Code = code, Code = code,
Version = 1, Version = version,
ApplicableType = type, ApplicableType = type,
Name = "QT test sửa tại chỗ", Name = "QT test sửa tại chỗ",
IsActive = true, IsActive = true,
@ -969,4 +973,561 @@ public class UpdateAwDefinitionTests
"vết phải đọc được bằng TÊN, không phải GUID"); "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<Dictionary<string, Guid>> SeedEightKhkkGroupsAsync(
TestApplicationDbContext db, Guid approverId)
{
var map = new Dictionary<string, Guid>();
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<List<WfRow>> 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<int> 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<ContractSigningPlan> 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<ApprovalWorkflow>`; `LeaveTypeId` cũng chỉ là Guid trần).
private static async Task<LeaveRequest> 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<int> 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<ConflictException>(
"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<ConflictException>()).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<DbUpdateException>(
"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<ConflictException>(
"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<ConflictException>(
"đơ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<NotFoundException>(
"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<ConflictException>(
"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");
}
}
} }