[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
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:
@ -43,6 +43,8 @@ Dữ-liệu cũ (WAL · HANDOFF · auto-memory · migration-todos) **KHÔNG** đ
|
||||
| `YC-010` | "OK làm hết rồi khép trọn đi" | 2026-08-07 · S181 | đóng | **2** | `WAL` chain W5b/W3b/W6 | Khép trọn gói: W5b + W3b + phần còn lại W6 | `YC-008` |
|
||||
| `YC-011` | "1,2 -> làm đi, OK sau đó /session-end" | 2026-08-07 · S181 | mở | 1 | `WAL` `next:` | (1) push 42 commit · (2) cổng đồng-bộ + báo cáo cuối · rồi đóng phiên | |
|
||||
| `YC-012` | "nâng cap" *(🔸 trích từ `spec-…-07-08-2026.md:64`, cửa-sổ **S180**, KHÔNG bắt trực tiếp ở cửa-sổ này — khai đúng nấc)* | 2026-08-07 · S180 | đóng | 1 | `memory-budget.json` `_cap_raised_S181` | Lệnh dứt khoát nâng `hmw_width.cap` 20→30. 🔴 @S179 lead **tự thu-hẹp** chữ "OK" thành "chưa phải lệnh đổi số" — lần này không tự thu-hẹp | |
|
||||
| `YC-013` | "Hiện tạo muốn cac quy trình này giống như duyệt NCC. Có thể thêm sửa xóa edit stick tương tự như duyệt NCC. Cho 2 /fable-real invest cách làm BE và Database, 1 /fable-real invest cách cấu hình giao diện front-end đúng nhất về UI lẫn giao diện, ko lệch. -> Ra các wave xử lý chi tiết nhất. cho 2 /fable-real review và tổng hợp các wave xử lý chi tiết. Cuối cùng fan-out cho Opus 5 MAX xử lý nhé." *(kèm 4 ảnh trang `approval-workflows-v2/DuyetNcc` + dialog Sửa-tại-chỗ)* | 2026-08-08 · S182 | **mở** | 1 | `runs/2026-08-08-S182-khkk-awf-mirror-ncc/` | 8 workflow "Duyệt KHKK — Nhóm 1..8" phải có ĐỦ chức năng như trang "Duyệt NCC": xem steps/cấp/NV + per-NV quyền duyệt + Sửa-tại-chỗ + Tạo-phiên-bản-mới + Ghim/Bỏ-ghim + Xoá-version + lịch-sử-versions. UI **không lệch** khuôn DuyetNcc. Pipeline anh chỉ định: 3 `/fable-real investigator-codebase` (BE · DB · FE-UI) → wave-plan chi tiết → 2 `/fable-real reviewer` → hmw fan-out **Opus 5 MAX** execute | |
|
||||
| `YC-014` | "C — Mờ ô chưa nghe (Recommended)" *(AskUser D3 @S182 — sau khi invest phát hiện 6/9 checkbox KHKK-runtime bỏ qua)* | 2026-08-08 · S182 | đóng | 1 | `spec-khkk-awf-mirror-ncc-DRAFT.md` MỤC 3 D3 | Dialog KHKK giữ layout y khuôn; ô máy-chưa-nghe **mờ + tooltip "chưa áp dụng cho KHKK"** (tiền lệ ô Duyệt-thẳng-Cấp-cuối @K3). KHÔNG port semantics đợt này (W3 hủy); port sau thì gỡ mờ dần | |
|
||||
|
||||
> 🔴 **MỘT yêu-cầu, nhắc 4 lần.** Đây là dữ-liệu **TC-01** sinh ra để bắt, và nó tố-giác chính lead: hiểu sai ở `YC-002` nên anh phải nói lại 3 lần. **Ghi lại chứ không làm đẹp.**
|
||||
>
|
||||
|
||||
@ -30,6 +30,21 @@ export const MenuKeys = {
|
||||
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
|
||||
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
|
||||
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
|
||||
// [W2e S182] 8 leaf Designer theo NHÓM DUYỆT của Kế hoạch ký kết HĐ.
|
||||
// Mirror BE `MenuKeys.cs:127-134` (8 key này VÀO `All` ⇒ 32 policy AwV2_KhkkN{n}.{R,C,U,D}).
|
||||
// ⚠️ File này là bản chép TAY của BE và hiện chỉ dùng cho `can(menuKey, action)`;
|
||||
// 8 key dưới đây CHƯA có call-site nào — chép vào để bảng đối chiếu FE↔BE không
|
||||
// khuyết, tránh lượt sau đọc chỗ này rồi kết luận "BE chưa có key nhóm".
|
||||
// Đường vào sidebar KHÔNG đi qua đây: sidebar dựng từ `/api/menus/me` rồi
|
||||
// `Layout.tsx:188` (`/^KhkkN[1-8]$/`) mới đổi key thành đường dẫn.
|
||||
AwV2_KhkkN1: 'AwV2_KhkkN1',
|
||||
AwV2_KhkkN2: 'AwV2_KhkkN2',
|
||||
AwV2_KhkkN3: 'AwV2_KhkkN3',
|
||||
AwV2_KhkkN4: 'AwV2_KhkkN4',
|
||||
AwV2_KhkkN5: 'AwV2_KhkkN5',
|
||||
AwV2_KhkkN6: 'AwV2_KhkkN6',
|
||||
AwV2_KhkkN7: 'AwV2_KhkkN7',
|
||||
AwV2_KhkkN8: 'AwV2_KhkkN8',
|
||||
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
|
||||
// "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
|
||||
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
|
||||
|
||||
@ -59,6 +59,11 @@ interface WorkflowDto {
|
||||
code: string
|
||||
name: string
|
||||
isUserSelectable: boolean
|
||||
// [W2g S182] Cùng một mã `KHKK-N{n}` có NHIỀU phiên bản (admin bấm "Tạo phiên bản
|
||||
// mới" thì bản cũ chuyển sang lưu trữ). `history` mà BE trả về là DANH SÁCH ĐẦY ĐỦ
|
||||
// — cả bản đang áp dụng lẫn bản đã lưu trữ — nên thiếu cờ này thì màn tạo phiếu
|
||||
// không phân biệt được, xem thêm chỗ dùng bên dưới.
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
|
||||
@ -110,9 +115,15 @@ export function KhkkCreatePage() {
|
||||
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
|
||||
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
|
||||
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
|
||||
// [W2g S182] 🔴 Phải lọc thêm `isActive`. Danh sách trả về gồm CẢ phiên bản đã lưu
|
||||
// trữ, mà `find` lấy phần tử ĐẦU TIÊN khớp mã — hễ admin tạo phiên bản mới cho nhóm
|
||||
// này là phiếu mới có thể bị gắn vào bản CŨ (thứ tự do BE trả, không do màn này
|
||||
// quyết), đi theo đội hình duyệt đã bị thay. Gắn sai quy trình thì lúc phát hiện
|
||||
// phiếu đã chạy được vài trạm rồi. Ràng buộc `isUserSelectable` ở query trên vẫn giữ
|
||||
// (admin ghim bản nào thì user mới được dùng bản đó).
|
||||
const wantWorkflowCode = `KHKK-N${approvalGroup}`
|
||||
const autoWorkflow = workflows.data?.find(
|
||||
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
|
||||
(w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
|
||||
) ?? null
|
||||
|
||||
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
|
||||
@ -125,7 +136,11 @@ export function KhkkCreatePage() {
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
|
||||
if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
|
||||
// [W2g S182] Câu này nói ĐIỀU KIỆN, không nói "không tồn tại": quy trình có thể
|
||||
// đang nằm đó nhưng ở phiên bản đã lưu trữ hoặc chưa được ghim.
|
||||
if (!autoWorkflow) {
|
||||
throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode} đang áp dụng và được ghim cho user chọn`)
|
||||
}
|
||||
const body: CreateKhkkInput = {
|
||||
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
||||
approvalWorkflowId: autoWorkflow.id,
|
||||
@ -349,9 +364,10 @@ export function KhkkCreatePage() {
|
||||
</div>
|
||||
{!workflows.isLoading && !autoWorkflow ? (
|
||||
<p className="mt-1 text-[11px] text-amber-700">
|
||||
⚠ Chưa có quy trình mã <strong>{wantWorkflowCode}</strong> (hoặc chưa được ghim
|
||||
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống
|
||||
KHÔNG tự dùng quy trình của nhóm khác.
|
||||
⚠ Chưa dùng được quy trình mã <strong>{wantWorkflowCode}</strong>: phải có
|
||||
phiên bản <strong>đang áp dụng</strong> và <strong>đã ghim</strong> cho user chọn.
|
||||
Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống KHÔNG tự dùng
|
||||
phiên bản đã lưu trữ, cũng KHÔNG tự dùng quy trình của nhóm khác.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
|
||||
@ -31,6 +31,9 @@ import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import type { Department, Paged } from '@/types/master'
|
||||
// [W2 S182] 2 hàm dùng chung với màn nghiệp vụ (fe-user) — KHÔNG chép chuỗi
|
||||
// `KHKK-N{n}` / nhãn nhóm vào file này, để 1 nguồn duy nhất khi BE đổi.
|
||||
import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk'
|
||||
|
||||
// ===== Types (mirror BE AwAdminOverviewDto) =====
|
||||
|
||||
@ -286,7 +289,21 @@ function isLevelEnabled(step: EditStep, order: LevelOrder): boolean {
|
||||
export function ApprovalWorkflowsV2Page() {
|
||||
const qc = useQueryClient()
|
||||
const { typeCode } = useParams<{ typeCode?: string }>()
|
||||
const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null
|
||||
|
||||
// [W2a S182] 8 leaf `AwV2_KhkkN1..8` deep-link về CHÍNH trang này với typeCode
|
||||
// `KhkkN{1..8}` (`Layout.tsx:188` — chỗ-thứ-4 Pattern 16-bis đã nối từ K3 S164).
|
||||
// `KhkkN{n}` KHÔNG phải một ApplicableType riêng: cả 8 nhóm đều là type 10
|
||||
// (Kế hoạch ký kết HĐ), phân biệt nhau bằng MÃ quy trình `KHKK-N{n}`. Nên chỗ này
|
||||
// tách 2 khái niệm:
|
||||
// · `khkkGroup` = nhóm duyệt 1..8 (null nếu không vào từ leaf nhóm)
|
||||
// · `selectedTypeInt` = ApplicableType gửi BE — nhóm nào cũng là 10.
|
||||
// Hệ quả CÓ CHỦ ĐÍCH: queryKey của 8 panel nhóm + panel `/ContractSigningPlan`
|
||||
// TRÙNG NHAU ⇒ 1 lần tải dùng chung, và invalidate sau khi lưu làm tươi cả 9 màn.
|
||||
const khkkGroupMatch = typeCode?.match(/^KhkkN([1-8])$/) ?? null
|
||||
const khkkGroup = khkkGroupMatch ? Number(khkkGroupMatch[1]) : null
|
||||
const selectedTypeInt = khkkGroup !== null
|
||||
? TYPE_INT_CONTRACT_SIGNING_PLAN
|
||||
: typeCode ? TYPE_CODE_TO_INT[typeCode] : null
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
|
||||
@ -296,23 +313,58 @@ export function ApprovalWorkflowsV2Page() {
|
||||
},
|
||||
})
|
||||
|
||||
const currentType = selectedTypeInt
|
||||
? overview.data?.types.find(t => t.applicableType === selectedTypeInt)
|
||||
const typeBucket = selectedTypeInt
|
||||
? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null
|
||||
: null
|
||||
|
||||
// [W2b S182] Lát cắt TỔNG HỢP cho panel nhóm. BE trả 1 bucket cho CẢ type 10
|
||||
// (`active` = 1-trong-8 quy trình nhóm, tuỳ bản nào kích hoạt sau cùng), nên
|
||||
// panel nhóm KHÔNG được đọc `bucket.active` — sẽ hiện nhầm quy trình của nhóm khác.
|
||||
// Cắt lại từ `history`:
|
||||
// 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE
|
||||
// `ApprovalWorkflowV2AdminFeatures.cs:206-211` — không lọc `!IsActive`);
|
||||
// chính `TypePanel` mới lọc `!isActive` lúc render mục "Lịch sử versions".
|
||||
// · `active` suy TỪ chính lát cắt (`find(v => v.isActive)`), không mượn ô ngoài.
|
||||
// Giữ nguyên shape `TypeSummaryDto` ⇒ `TypePanel`/`Designer` không cần biết
|
||||
// chúng đang xem một nhóm hay cả loại.
|
||||
const currentType: TypeSummaryDto | null = useMemo(() => {
|
||||
if (khkkGroup === null) return typeBucket
|
||||
if (!typeBucket) return null
|
||||
const wantCode = khkkGroupWorkflowCode(khkkGroup)
|
||||
const versions = typeBucket.history.filter(d => d.code === wantCode)
|
||||
return {
|
||||
applicableType: typeBucket.applicableType,
|
||||
applicableTypeLabel: typeBucket.applicableTypeLabel,
|
||||
active: versions.find(v => v.isActive) ?? null,
|
||||
history: versions,
|
||||
}
|
||||
}, [khkkGroup, typeBucket])
|
||||
|
||||
// [W2c S182] Mã quy trình của panel nhóm là KHOÁ, không phải gợi ý: mỗi nhóm đúng
|
||||
// MỘT chuỗi phiên bản `KHKK-N{n}`. Gõ mã khác trên panel nhóm = đẻ quy trình mồ côi
|
||||
// (phiếu nhóm đó vẫn tìm `KHKK-N{n}` — `KhkkCreatePage.tsx:113`) ⇒ khoá thẳng ô Mã.
|
||||
const lockedCode = khkkGroup !== null ? khkkGroupWorkflowCode(khkkGroup) : null
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<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={
|
||||
<span className="flex items-center gap-2">
|
||||
<Workflow className="h-5 w-5" />
|
||||
{currentType
|
||||
{khkkGroup !== null
|
||||
? `Quy trình duyệt (Mới): ${khkkGroupMenuLabel(khkkGroup)}`
|
||||
: currentType
|
||||
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
|
||||
: 'Quy trình duyệt (Mới)'}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
currentType
|
||||
khkkGroup !== null
|
||||
? `${currentType?.applicableTypeLabel ?? 'Kế hoạch ký kết HĐ'} — nhóm duyệt N${khkkGroup}. Mã quy trình khoá theo nhóm: ${lockedCode}; mỗi lần lưu là một phiên bản mới của CHÍNH nhóm này, 7 nhóm còn lại không đổi.`
|
||||
: currentType
|
||||
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
|
||||
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
|
||||
}
|
||||
@ -344,7 +396,11 @@ export function ApprovalWorkflowsV2Page() {
|
||||
|
||||
{currentType && (
|
||||
<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}
|
||||
lockedCode={lockedCode}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })}
|
||||
/>
|
||||
)}
|
||||
@ -354,7 +410,17 @@ export function ApprovalWorkflowsV2Page() {
|
||||
|
||||
// ===== Per-type panel =====
|
||||
|
||||
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
|
||||
function TypePanel({
|
||||
type,
|
||||
// [W2c S182] `null` = panel theo LOẠI (admin tự đặt mã, hành vi cũ).
|
||||
// Khác null = panel theo NHÓM KHKK: mã bị khoá, mọi version đều thuộc chuỗi đó.
|
||||
lockedCode,
|
||||
onSaved,
|
||||
}: {
|
||||
type: TypeSummaryDto
|
||||
lockedCode: string | null
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [designerOpen, setDesignerOpen] = useState(false)
|
||||
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
|
||||
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id +
|
||||
@ -448,6 +514,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
|
||||
<Designer
|
||||
applicableType={type.applicableType}
|
||||
applicableTypeLabel={type.applicableTypeLabel}
|
||||
lockedCode={lockedCode}
|
||||
cloneFrom={cloneFrom}
|
||||
editTarget={editTarget}
|
||||
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
|
||||
@ -620,6 +687,7 @@ function DefinitionCard({
|
||||
function Designer({
|
||||
applicableType,
|
||||
applicableTypeLabel,
|
||||
lockedCode,
|
||||
cloneFrom,
|
||||
editTarget,
|
||||
onClose,
|
||||
@ -627,6 +695,8 @@ function Designer({
|
||||
}: {
|
||||
applicableType: number
|
||||
applicableTypeLabel: string
|
||||
// [W2c S182] Mã bị KHOÁ theo nhóm KHKK (`KHKK-N{n}`), null = tự do như cũ.
|
||||
lockedCode: string | null
|
||||
cloneFrom: DefinitionDto | null
|
||||
// [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom.
|
||||
editTarget: DefinitionDto | null
|
||||
@ -638,16 +708,38 @@ function Designer({
|
||||
const isEdit = editTarget !== null
|
||||
const source = editTarget ?? cloneFrom
|
||||
|
||||
// [K3 S164 — GỠ KHOÁ] `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN: 2 ô "duyệt sớm"
|
||||
// nay sống cho MỌI loại quy trình, kể cả Kế hoạch ký kết HĐ (type 10) — BE đã port
|
||||
// đường finalize ở K3 cùng wave, và owner chốt TP.CCM là người quyết lên CEO hay
|
||||
// kết thúc. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`).
|
||||
// [W2d/W2f S182 — D3=C] Kế hoạch ký kết HĐ (type 10, gồm cả 8 panel nhóm) chưa
|
||||
// dùng tới một phần cờ quyền của khuôn Duyệt NCC. Gom về ĐÚNG MỘT cờ để 8 ô
|
||||
// (7 ô quyền + ô Ngưỡng CEO) không mỗi chỗ tự viết lại điều kiện rồi lệch nhau.
|
||||
const isKhkk = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN
|
||||
// Câu dùng chung cho các ô CHƯA có đường chạy ở KHKK. Ô nào có lý do RIÊNG thì
|
||||
// viết riêng tại chỗ (xem ô "Trả về Drafter") — trạng thái tick vẫn là THẬT.
|
||||
// 🔴 Câu nhắc phải gắn `title` trên <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(
|
||||
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
|
||||
[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 [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
|
||||
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) ============
|
||||
await api.post('/approval-workflows-v2', {
|
||||
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,
|
||||
description: description || null,
|
||||
// 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) => ({
|
||||
order: i + 1,
|
||||
name: s.name,
|
||||
@ -924,11 +1024,15 @@ function Designer({
|
||||
required
|
||||
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).
|
||||
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">
|
||||
{isEdit
|
||||
? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.'
|
||||
: 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>
|
||||
@ -951,10 +1055,15 @@ function Designer({
|
||||
// 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh
|
||||
// cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng
|
||||
// không gõ vào rồi mới nhận lỗi. Giá trị gửi lên lấy thẳng từ bản đang lưu.
|
||||
disabled={isEdit}
|
||||
// [W2f S182] KHKK khoá VÔ ĐIỀU KIỆN (kể cả tạo mới): quy trình KHKK không
|
||||
// rẽ nhánh theo số tiền — người được uỷ quyền quyết. Tiền lệ cùng cách làm
|
||||
// là ô "Duyệt thẳng Cấp cuối" bên dưới.
|
||||
disabled={isEdit || isKhkk}
|
||||
/>
|
||||
<div className="text-[11px] leading-relaxed text-slate-400">
|
||||
{isEdit
|
||||
{isKhkk
|
||||
? 'KHKK không dùng ngưỡng máy — NĐUQ quyết. Ai được kết thúc quy trình thì tick ô "Duyệt là KẾT THÚC" ở đúng NV đó bên dưới.'
|
||||
: isEdit
|
||||
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
|
||||
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
|
||||
</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">
|
||||
ⓘ Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới
|
||||
(Trả lại / Edit Section 2 / Edit Budget / Duyệt thẳng Cấp cuối).
|
||||
{/* [W2d S182 — D3=C] Nói thẳng lý do các ô bị mờ, thay vì để admin tự đoán
|
||||
là "mất quyền" hay "lỗi". Ô mờ vẫn hiện ĐÚNG giá trị đang lưu trong DB. */}
|
||||
{isKhkk && (
|
||||
<div className="mt-1 text-violet-900">
|
||||
Riêng Kế hoạch ký kết HĐ: một số ô bị khoá vì luồng duyệt KHKK chưa dùng tới
|
||||
(ô vẫn hiển thị đúng giá trị đang lưu, không bị đổi). Hai ô đang có tác dụng
|
||||
thật là <span className="font-semibold">"Trả về Drafter"</span> (KHKK luôn trả về
|
||||
người soạn) và <span className="font-semibold">"Duyệt là KẾT THÚC"</span> — tick cho NV
|
||||
được uỷ quyền quyết dừng hay trình tiếp CEO.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Quyền duyệt {usersList.data?.find(u => u.id === entry.approverUserId)?.fullName ?? 'Chưa chọn NV'}
|
||||
</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">
|
||||
<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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowReturnOneLevel}
|
||||
disabled={isKhkk}
|
||||
onChange={e => updateField('allowReturnOneLevel', e.target.checked)}
|
||||
/>
|
||||
<span>Trả về 1 Cấp trước</span>
|
||||
</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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowReturnOneStep}
|
||||
disabled={isKhkk}
|
||||
onChange={e => updateField('allowReturnOneStep', e.target.checked)}
|
||||
/>
|
||||
<span>Trả về 1 Bước trước</span>
|
||||
</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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowReturnToAssignee}
|
||||
disabled={isKhkk}
|
||||
onChange={e => updateField('allowReturnToAssignee', e.target.checked)}
|
||||
/>
|
||||
<span>Trả về Người chỉ định</span>
|
||||
</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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowReturnToDrafter}
|
||||
disabled={isKhkk}
|
||||
onChange={e => updateField('allowReturnToDrafter', e.target.checked)}
|
||||
/>
|
||||
<span>Trả về Drafter (mặc định)</span>
|
||||
</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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowApproverEditDetails}
|
||||
disabled={isKhkk}
|
||||
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>
|
||||
</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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowApproverEditBudget}
|
||||
disabled={isKhkk}
|
||||
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>
|
||||
@ -1289,13 +1459,20 @@ function Designer({
|
||||
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
|
||||
(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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? false : entry.allowApproverSkipToFinal}
|
||||
disabled={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}
|
||||
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}
|
||||
checked={isKhkk ? false : entry.allowApproverSkipToFinal}
|
||||
disabled={isKhkk}
|
||||
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
|
||||
/>
|
||||
<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ì
|
||||
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. */}
|
||||
<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
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowApproverDelete}
|
||||
disabled={isKhkk}
|
||||
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>
|
||||
|
||||
@ -30,6 +30,20 @@ export const MenuKeys = {
|
||||
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
|
||||
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
|
||||
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
|
||||
// [W2e S182] 8 leaf Designer theo NHÓM DUYỆT của Kế hoạch ký kết HĐ.
|
||||
// Mirror BE `MenuKeys.cs:127-134` (8 key này VÀO `All` ⇒ 32 policy AwV2_KhkkN{n}.{R,C,U,D}).
|
||||
// ⚠️ 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
|
||||
// "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)
|
||||
|
||||
@ -59,6 +59,11 @@ interface WorkflowDto {
|
||||
code: string
|
||||
name: string
|
||||
isUserSelectable: boolean
|
||||
// [W2g S182] Cùng một mã `KHKK-N{n}` có NHIỀU phiên bản (admin bấm "Tạo phiên bản
|
||||
// mới" thì bản cũ chuyển sang lưu trữ). `history` mà BE trả về là DANH SÁCH ĐẦY ĐỦ
|
||||
// — cả bản đang áp dụng lẫn bản đã lưu trữ — nên thiếu cờ này thì màn tạo phiếu
|
||||
// không phân biệt được, xem thêm chỗ dùng bên dưới.
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
|
||||
@ -110,9 +115,15 @@ export function KhkkCreatePage() {
|
||||
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
|
||||
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
|
||||
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
|
||||
// [W2g S182] 🔴 Phải lọc thêm `isActive`. Danh sách trả về gồm CẢ phiên bản đã lưu
|
||||
// trữ, mà `find` lấy phần tử ĐẦU TIÊN khớp mã — hễ admin tạo phiên bản mới cho nhóm
|
||||
// này là phiếu mới có thể bị gắn vào bản CŨ (thứ tự do BE trả, không do màn này
|
||||
// quyết), đi theo đội hình duyệt đã bị thay. Gắn sai quy trình thì lúc phát hiện
|
||||
// phiếu đã chạy được vài trạm rồi. Ràng buộc `isUserSelectable` ở query trên vẫn giữ
|
||||
// (admin ghim bản nào thì user mới được dùng bản đó).
|
||||
const wantWorkflowCode = `KHKK-N${approvalGroup}`
|
||||
const autoWorkflow = workflows.data?.find(
|
||||
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
|
||||
(w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
|
||||
) ?? null
|
||||
|
||||
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
|
||||
@ -125,7 +136,11 @@ export function KhkkCreatePage() {
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
|
||||
if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
|
||||
// [W2g S182] Câu này nói ĐIỀU KIỆN, không nói "không tồn tại": quy trình có thể
|
||||
// đang nằm đó nhưng ở phiên bản đã lưu trữ hoặc chưa được ghim.
|
||||
if (!autoWorkflow) {
|
||||
throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode} đang áp dụng và được ghim cho user chọn`)
|
||||
}
|
||||
const body: CreateKhkkInput = {
|
||||
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
|
||||
approvalWorkflowId: autoWorkflow.id,
|
||||
@ -349,9 +364,10 @@ export function KhkkCreatePage() {
|
||||
</div>
|
||||
{!workflows.isLoading && !autoWorkflow ? (
|
||||
<p className="mt-1 text-[11px] text-amber-700">
|
||||
⚠ Chưa có quy trình mã <strong>{wantWorkflowCode}</strong> (hoặc chưa được ghim
|
||||
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống
|
||||
KHÔNG tự dùng quy trình của nhóm khác.
|
||||
⚠ Chưa dùng được quy trình mã <strong>{wantWorkflowCode}</strong>: phải có
|
||||
phiên bản <strong>đang áp dụng</strong> và <strong>đã ghim</strong> cho user chọn.
|
||||
Nhờ admin mở trong màn Quy trình duyệt rồi quay lại — hệ thống KHÔNG tự dùng
|
||||
phiên bản đã lưu trữ, cũng KHÔNG tự dùng quy trình của nhóm khác.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
|
||||
@ -4,7 +4,9 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.ContractSigningPlans; // [S182 W1d] ContractSigningPlanWorkflowGuards.ParseGroup
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans; // [S182 W1c] changelog + phase KHKK
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
@ -330,21 +332,53 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
|
||||
{
|
||||
var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType;
|
||||
|
||||
// 🔴 [S182 MAJOR-2] CHUẨN HOÁ `Code` TẠI ĐƯỜNG GHI — nguồn duy nhất cho cả handler.
|
||||
//
|
||||
// Trước bản này BA thành phần hỏi cùng câu "code này thuộc nhóm nào" bằng BA luật khác nhau:
|
||||
// • `ContractSigningPlanWorkflowGuards.GroupCodePattern` — regex .NET, case-SENSITIVE;
|
||||
// • `w.Code == request.Code` — EF → SQL Server, theo collation `*_CI_AS` = case-INSENSITIVE;
|
||||
// • FE `KhkkCreatePage` — `.trim().toUpperCase()` = case-INSENSITIVE.
|
||||
// ⇒ admin gõ `khkk-n1` ở panel type-10 gộp (ô Mã KHÔNG khoá vì `lockedCode = null`):
|
||||
// DB coi là trùng ⇒ hạ `IsActive` của `KHKK-N1` THẬT · FE vẫn auto-pin sang bản mới ·
|
||||
// nhưng regex trả `null` ⇒ rào nhóm 3-site và D5 IM LẶNG TẮT cho toàn bộ phiếu nhóm 1.
|
||||
// Không lỗi nào báo.
|
||||
// 🔴 Test KHÔNG thể bắt: 14 ca chạy SQLite, nơi `=` trên TEXT là BINARY (case-SENSITIVE)
|
||||
// ⇒ ngữ nghĩa NGƯỢC prod. Mù theo CẤU TRÚC, không assertion nào bịt được ⇒ phải chặn
|
||||
// bằng chuẩn-hoá, đừng chặn bằng test.
|
||||
var code = request.Code.Trim().ToUpperInvariant();
|
||||
|
||||
// Auto-increment version theo Code (cùng Code = cùng "logical" workflow)
|
||||
var nextVersion = await db.ApprovalWorkflows
|
||||
.Where(w => w.Code == request.Code)
|
||||
.Where(w => w.Code == code)
|
||||
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
|
||||
nextVersion++;
|
||||
|
||||
// Deactivate active version cho ApplicableType này (only ONE active per type)
|
||||
var actives = await db.ApprovalWorkflows
|
||||
// Tắt bản đang active — PHẠM VI phụ thuộc loại (bản trước: LUÔN per-type).
|
||||
//
|
||||
// • type-10 `ContractSigningPlan` (KHKK): **per-CODE**. Loại này có 8 quy trình
|
||||
// SỐNG SONG SONG cùng type — mỗi nhóm duyệt N1..N8 một `Code` riêng
|
||||
// (`DbInitializer.SeedKhkkGroupWorkflowsAsync:609`, seed cả 8 với IsActive=1).
|
||||
// Tắt theo type ⇒ tạo version mới cho MỘT nhóm sẽ hạ IsActive của CẢ 7 nhóm còn
|
||||
// lại (mỗi nhóm mất quy trình active ⇒ FE hết auto-pin ⇒ không tạo được phiếu).
|
||||
// • các loại khác: **per-TYPE** như cũ — 1 active/type là bất biến của chúng.
|
||||
//
|
||||
// 🔴 [S182 R-6] Vị ngữ type-10 KHOÁ đúng 3 vế `Code == request.Code &&
|
||||
// ApplicableType == typeEnum && IsActive`. CẤM thay bằng biến-thể regex
|
||||
// `^KHKK-N[1-8]$`: `Code` type-10 nằm ngoài họ đó (vd `QT-KHKK-V2-001`) sẽ rơi
|
||||
// lại nhánh per-type và nuke đúng 8 nhóm mà rào này sinh ra để cứu.
|
||||
// (`nextVersion` `:334-337` vốn đã tính per-Code ⇒ nhánh này làm 2 vế khớp nhau.)
|
||||
var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan
|
||||
? await db.ApprovalWorkflows
|
||||
.Where(w => w.Code == code && w.ApplicableType == typeEnum && w.IsActive)
|
||||
.ToListAsync(ct)
|
||||
: await db.ApprovalWorkflows
|
||||
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
|
||||
.ToListAsync(ct);
|
||||
foreach (var old in actives) old.IsActive = false;
|
||||
|
||||
var def = new ApprovalWorkflow
|
||||
{
|
||||
Code = request.Code,
|
||||
Code = code, // [S182 MAJOR-2] bản đã chuẩn hoá, KHÔNG dùng request.Code thô
|
||||
Version = nextVersion,
|
||||
ApplicableType = typeEnum,
|
||||
Name = request.Name,
|
||||
@ -856,10 +890,17 @@ public class UpdateAwDefinitionCommandHandler(
|
||||
// được "quy trình vừa bị sửa lúc nào, ai sửa, vì sao".
|
||||
// ⚠️ KHAI GIỚI HẠN: repo CHƯA có bảng changelog cho quy trình duyệt, mà
|
||||
// đợt 1 bị chốt "no new table" ⇒ vết cho workflow thuộc 5 module Văn
|
||||
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) + KHKK (W1 S161 — reviewer F-6b:
|
||||
// `ContractSigningPlanChangelogs` là changelog PHIẾU, không phải changelog QUY-TRÌNH;
|
||||
// wire nếu cần ở W2/W3) hiện CHỈ có UpdatedAt/
|
||||
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/
|
||||
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
|
||||
// 🔄 [S182 W1c] KHKK (type-10) ĐÃ WIRE — xem nhánh `else if` bên dưới. Câu cũ ở
|
||||
// đây xếp KHKK chung rọ "chỉ có UpdatedAt/UpdatedBy" và hẹn "wire nếu cần ở W2/W3";
|
||||
// đợt này wire thật nên câu đó gỡ, không để lại neo thối.
|
||||
var note = string.Join(" | ", changes);
|
||||
if (!string.IsNullOrWhiteSpace(request.Reason))
|
||||
note = $"Lý do: {request.Reason.Trim()} | {note}";
|
||||
var summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)";
|
||||
var actorName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống";
|
||||
|
||||
if (changes.Count > 0 &&
|
||||
def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
|
||||
or ApprovalWorkflowApplicableType.DuyetNccPhuongAn)
|
||||
@ -871,10 +912,6 @@ public class UpdateAwDefinitionCommandHandler(
|
||||
.Select(p => new { p.Id, p.Phase })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var note = string.Join(" | ", changes);
|
||||
if (!string.IsNullOrWhiteSpace(request.Reason))
|
||||
note = $"Lý do: {request.Reason.Trim()} | {note}";
|
||||
|
||||
foreach (var pe in affected)
|
||||
{
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
@ -885,8 +922,48 @@ public class UpdateAwDefinitionCommandHandler(
|
||||
EntityId = def.Id,
|
||||
PhaseAtChange = pe.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống",
|
||||
Summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)",
|
||||
UserName = actorName,
|
||||
Summary = summary,
|
||||
ContextNote = note,
|
||||
});
|
||||
}
|
||||
}
|
||||
// [S182 W1c] KHKK (type-10) — mirror khối PE ngay trên, cùng shape 8 field.
|
||||
//
|
||||
// VÌ SAO CẦN: dialog PUT hứa nguyên văn với admin "ghi vào lịch sử phiếu đang chạy"
|
||||
// (`ApprovalWorkflowsV2Page.tsx:963-977`). Với type-10 lời hứa đó trước nay là RỖNG —
|
||||
// sửa quy trình xong lịch sử phiếu KHKK không có dòng nào. Đây là fix cho lời hứa
|
||||
// thành thật, không phải tính năng mới.
|
||||
//
|
||||
// 🔴 Vị ngữ phiếu "đang chịu ảnh hưởng" = CHƯA kết thúc: loại 2 phase TERMINAL
|
||||
// (`DaDuyet` chốt giá + `TuChoi`). `TraLai` KHÔNG loại — phiếu trả lại sẽ được
|
||||
// trình LẠI và chạy đúng quy trình vừa sửa ⇒ người soạn cần thấy vết.
|
||||
// 🔴 CỐ Ý KHÔNG `IgnoreQueryFilters()`: `ContractSigningPlans` có global filter
|
||||
// (`ContractSigningPlanConfiguration.cs:55`) ⇒ phiếu XOÁ MỀM tự rụng khỏi tập.
|
||||
// Đúng ý muốn: ghi changelog cho phiếu đã xoá là đẻ rác không ai đọc. (Ngược với
|
||||
// rào DELETE bên dưới — ở đó xoá mềm VẪN phải chặn vì FK còn thật.)
|
||||
else if (changes.Count > 0 &&
|
||||
def.ApplicableType is ApprovalWorkflowApplicableType.ContractSigningPlan)
|
||||
{
|
||||
var affectedPlans = await db.ContractSigningPlans.AsNoTracking()
|
||||
.Where(p => p.ApprovalWorkflowId == def.Id
|
||||
&& p.Phase != ContractSigningPlanPhase.DaDuyet
|
||||
&& p.Phase != ContractSigningPlanPhase.TuChoi)
|
||||
.Select(p => new { p.Id, p.Phase })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var plan in affectedPlans)
|
||||
{
|
||||
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
EntityType = ContractSigningPlanEntityType.Workflow,
|
||||
Action = SolutionErp.Domain.Contracts.ChangelogAction.Update,
|
||||
EntityId = def.Id,
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = actorName,
|
||||
Summary = summary,
|
||||
ContextNote = note,
|
||||
});
|
||||
}
|
||||
@ -1184,9 +1261,21 @@ public class SetAwUserSelectableCommandHandler(IApplicationDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== DELETE version (chỉ khi chưa có phiếu pin) ==========
|
||||
// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT.
|
||||
// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete.
|
||||
// ========== DELETE version — CHẶN khi còn phiếu pin / còn chữ ký ==========
|
||||
//
|
||||
// 🔴 [S182 W1d] Câu cũ ở đây ("chưa có phiếu nào pin schema mới → unconditional delete OK cho
|
||||
// UAT") đã HẾT ĐÚNG từ lâu: 8 module pin `ApprovalWorkflowId` thật trên prod. Xoá trần để
|
||||
// lại 2 hậu quả khác hẳn nhau tuỳ module — và chính vì khác nhau nên rào này có HAI lý do,
|
||||
// không phải một:
|
||||
// • PE / Hợp đồng / KHKK có **FK vật lý** ⇒ xoá trần nổ SQL 547 = 500 thô. Rào = đổi
|
||||
// 500 khó hiểu thành 409 đọc được. (UX)
|
||||
// • Proposal / Leave / Ot / Travel / Vehicle pin **LOOSE, 0-FK** ⇒ DB không đỡ gì cả,
|
||||
// xoá trần đi lọt ÊM và để lại con trỏ treo. Rào này là **hàng phòng thủ DUY NHẤT**.
|
||||
// (TOÀN VẸN DỮ LIỆU — nặng hơn vế trên)
|
||||
//
|
||||
// Thứ tự kiểm: usage TRƯỚC (i+ii), D5 SAU (iv). Cả hai đều 409; usage đứng trước vì khi cả hai
|
||||
// cùng đúng thì "5 phiếu đang gắn" là câu admin xử lý được, còn "nhóm phải còn ≥1 version" là
|
||||
// luật chính sách — nói cái hành-động-được trước.
|
||||
|
||||
public record DeleteAwDefinitionCommand(Guid Id) : IRequest;
|
||||
|
||||
@ -1195,9 +1284,90 @@ public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db)
|
||||
{
|
||||
public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct)
|
||||
{
|
||||
// (iii) `NotFoundException` thay `KeyNotFoundException`: BCL exception KHÔNG được
|
||||
// `GlobalExceptionMiddleware` map ⇒ id lạ trước nay ra 500 thô. Nay 404.
|
||||
var def = await db.ApprovalWorkflows
|
||||
.FirstOrDefaultAsync(d => d.Id == request.Id, ct)
|
||||
?? throw new KeyNotFoundException($"ApprovalWorkflow {request.Id} không tồn tại.");
|
||||
?? throw new NotFoundException("ApprovalWorkflow", request.Id);
|
||||
|
||||
// ---- (i) Đếm phiếu ĐANG PIN, quét đủ 8 module có cột `ApprovalWorkflowId`.
|
||||
//
|
||||
// 🔴 `IgnoreQueryFilters()` cho PE / Contract / KHKK — 3 entity DUY NHẤT có global
|
||||
// filter `!IsDeleted` (đã grep `HasQueryFilter` trong Configurations để chốt danh
|
||||
// sách, không suy từ trí nhớ). Phiếu XOÁ MỀM vẫn CÒN ROW ⇒ FK vẫn thật ⇒ bỏ filter
|
||||
// thì ta đếm ra 0 rồi xoá và nổ 547 đúng lúc chạy. 5 module Văn phòng số không có
|
||||
// filter nên đếm thẳng.
|
||||
var pinned = 0;
|
||||
pinned += await db.PurchaseEvaluations.IgnoreQueryFilters()
|
||||
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
pinned += await db.Contracts.IgnoreQueryFilters()
|
||||
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
pinned += await db.ContractSigningPlans.IgnoreQueryFilters()
|
||||
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
pinned += await db.Proposals.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
pinned += await db.LeaveRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
pinned += await db.OtRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
pinned += await db.TravelRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
pinned += await db.VehicleBookings.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
|
||||
|
||||
// ---- (ii) Đếm CHỮ KÝ còn trỏ vào các dòng Cấp của quy trình này — 8 bảng
|
||||
// `*LevelOpinion`. Phiếu có thể đã bỏ pin mà chữ ký vẫn còn (FK Restrict về
|
||||
// `ApprovalWorkflowLevels`) ⇒ đếm pin thôi là chưa đủ để dám xoá.
|
||||
// `IgnoreQueryFilters()` cả 8 cho ĐỒNG HÌNH — bảng nào không có filter thì gọi
|
||||
// vô hại, bảng có filter mà quên gọi thì đếm hụt.
|
||||
var levelIds = await db.ApprovalWorkflowLevels.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(l => l.Step!.ApprovalWorkflowId == def.Id)
|
||||
.Select(l => l.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var signatures = 0;
|
||||
if (levelIds.Count > 0)
|
||||
{
|
||||
signatures += await db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
signatures += await db.ContractLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
signatures += await db.ContractSigningPlanLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
signatures += await db.ProposalLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
signatures += await db.LeaveRequestLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
signatures += await db.OtRequestLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
signatures += await db.TravelRequestLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
signatures += await db.VehicleBookingLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
|
||||
}
|
||||
|
||||
if (pinned > 0 || signatures > 0)
|
||||
throw new ConflictException(
|
||||
$"Quy trình '{def.Code}' v{def.Version} đang được dùng: {pinned} hồ sơ đang gắn " +
|
||||
$"+ {signatures} chữ ký đã lưu — không xoá được. " +
|
||||
"Muốn ngừng dùng thì bỏ kích hoạt (hoặc bỏ ghim) thay vì xoá.");
|
||||
|
||||
// ---- (iv) [D5] Nhóm duyệt KHKK phải LUÔN còn ít nhất 1 version.
|
||||
//
|
||||
// Seeder `SeedKhkkGroupWorkflowsAsync` idempotent PER-CODE (`DbInitializer.cs:610`:
|
||||
// `if (Any(w.Code == code)) continue;`) ⇒ xoá SẠCH một mã `KHKK-N{n}` thì lần khởi
|
||||
// động sau seeder đẻ LẠI bản v01 mặc định — roster + cờ mà admin đã chỉnh biến mất
|
||||
// âm thầm, không có lỗi nào báo. Rào ở đây đóng đường UI dẫn tới ca đó.
|
||||
// (Version 0-usage mà KHÔNG phải bản cuối của mã ⇒ vẫn xoá bình thường.)
|
||||
if (ContractSigningPlanWorkflowGuards.ParseGroup(def.Code) is int group)
|
||||
{
|
||||
// [S182 MINOR-3] Lọc CẢ `ApplicableType`: UNIQUE là `(Code, Version)` chứ không kèm
|
||||
// type ⇒ một row `KHKK-N{n}` ở type KHÁC (Version khác) sẽ bị tính là "anh em" và
|
||||
// cho phép xoá bản type-10 CUỐI CÙNG — đúng ca mà D5 sinh ra để chặn.
|
||||
var siblings = await db.ApprovalWorkflows
|
||||
.CountAsync(w => w.Code == def.Code
|
||||
&& w.ApplicableType == def.ApplicableType
|
||||
&& w.Id != def.Id, ct);
|
||||
if (siblings == 0)
|
||||
throw new ConflictException(
|
||||
$"Nhóm N{group} phải còn ≥1 version — đây là version cuối cùng của quy trình " +
|
||||
$"'{def.Code}'. Tạo version mới trước, rồi mới xoá bản này.");
|
||||
}
|
||||
|
||||
db.ApprovalWorkflows.Remove(def);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@ -317,6 +317,34 @@ internal static class ContractSigningPlanScope
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Kế hoạch ký kết HĐ.");
|
||||
}
|
||||
|
||||
/// [S182 W1b2 — D6] Quy trình có còn được phép PIN MỚI không: `IsActive` ∧ `IsUserSelectable`.
|
||||
/// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147`.
|
||||
///
|
||||
/// 🔴 CHỈ gọi ở đường ĐẶT PIN MỚI (tạo phiếu / sửa nháp có ĐỔI quy trình). CỐ Ý KHÔNG gọi
|
||||
/// khi giữ nguyên pin cũ: phiếu lỡ pin bản đã archived vẫn phải sửa được GhiChu/HoSoLink,
|
||||
/// nếu không thì admin ngừng một quy trình là khoá cứng mọi phiếu nháp đang trỏ vào nó.
|
||||
/// Cũng KHÔNG gọi ở đường TRÌNH duyệt vì lý do đó (D6 chốt 2 site).
|
||||
///
|
||||
/// 2 cờ ném 2 message TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): "đã ngừng
|
||||
/// hoạt động" và "chưa ghim" sửa ở 2 nút khác nhau trong Designer — gộp thì người đọc lỗi
|
||||
/// không biết bấm nút nào.
|
||||
internal static async Task EnsureWorkflowPinnableAsync(
|
||||
IApplicationDbContext db, Guid approvalWorkflowId, CancellationToken ct)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == approvalWorkflowId)
|
||||
.Select(w => new { w.Code, w.IsActive, w.IsUserSelectable })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wf is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", approvalWorkflowId);
|
||||
if (!wf.IsActive)
|
||||
throw new ConflictException(
|
||||
$"Quy trình '{wf.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động.");
|
||||
if (!wf.IsUserSelectable)
|
||||
throw new ConflictException(
|
||||
$"Quy trình '{wf.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác.");
|
||||
}
|
||||
|
||||
/// Precompute ID phiếu mà `userId` là approver của Cấp ĐANG CHỜ — mirror
|
||||
/// `PurchaseEvaluationFeatures.cs:817-852 ResolveV2InboxIdsAsync`.
|
||||
/// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list
|
||||
@ -472,18 +500,20 @@ public class CreateContractSigningPlanCommandHandler(
|
||||
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
|
||||
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
|
||||
|
||||
// ---- Rào (iv-bis) [S182 W1b2 — D6] Quy trình phải CÒN DÙNG ĐƯỢC lúc pin.
|
||||
// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147` (cùng lỗ, đã bịt cho
|
||||
// type-3): FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì pin được bản đã
|
||||
// archived ⇒ phiếu trình xong treo ở quy trình không ai bảo trì.
|
||||
await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, request.ApprovalWorkflowId, ct);
|
||||
|
||||
// ---- Rào (v) [K3 S165 — QĐ-3 "K3 chỉ ĐỌC ApprovalGroup"] Workflow họ nhóm
|
||||
// `KHKK-N{n}` ⇒ n PHẢI khớp nhóm duyệt của phiếu (phiếu nhóm 3 pin quy trình nhóm 5
|
||||
// = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho
|
||||
// 8 workflow nhóm seed @K3.
|
||||
var wfCode = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == request.ApprovalWorkflowId)
|
||||
.Select(w => w.Code).FirstOrDefaultAsync(ct);
|
||||
var groupMatch = System.Text.RegularExpressions.Regex.Match(wfCode ?? string.Empty, "^KHKK-N([1-8])$");
|
||||
if (groupMatch.Success && int.Parse(groupMatch.Groups[1].Value) != approvalGroup)
|
||||
throw new ConflictException(
|
||||
$"Quy trình '{wfCode}' thuộc nhóm duyệt N{groupMatch.Groups[1].Value}, " +
|
||||
$"không khớp nhóm duyệt N{approvalGroup} của phiếu — chọn đúng quy trình của nhóm.");
|
||||
// [S182 W1b] Thân rào DỜI sang `ContractSigningPlanWorkflowGuards` — cùng luật này
|
||||
// nay chặn ở CẢ 3 cửa (tạo / sửa nháp re-pin / trình duyệt), trước chỉ có cửa này.
|
||||
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
|
||||
db, request.ApprovalWorkflowId, approvalGroup, ct);
|
||||
|
||||
// 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
|
||||
// (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
|
||||
@ -600,9 +630,18 @@ public class UpdateContractSigningPlanDraftCommandHandler(
|
||||
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
|
||||
|
||||
// Re-pin quy trình: guard type LẠI (workflow có thể bị admin đổi loại giữa chừng).
|
||||
//
|
||||
// [S182 W1b/W1b2] Cửa này trước KHÔNG có rào nhóm ⇒ tạo phiếu đúng nhóm rồi sửa nháp
|
||||
// pin sang quy trình nhóm khác là lọt. Nay đủ 3 rào như đường TẠO.
|
||||
// 🔴 Cả 3 nằm TRONG nhánh `awId != plan.ApprovalWorkflowId` (đường ĐỔI pin) — CỐ Ý:
|
||||
// client gửi lại đúng workflow cũ (form "Sửa" echo nguyên field) không được biến
|
||||
// thành 409, nếu không thì sửa GhiChu của phiếu pin bản archived sẽ hỏng (D6).
|
||||
if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId)
|
||||
{
|
||||
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, awId, ct);
|
||||
await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, awId, ct);
|
||||
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
|
||||
db, awId, plan.ApprovalGroup, ct);
|
||||
plan.ApprovalWorkflowId = awId;
|
||||
}
|
||||
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.ContractSigningPlans; // ContractSigningPlanWorkflowGuards (rào nhóm dùng chung 3 site)
|
||||
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
@ -186,6 +187,15 @@ public class ContractSigningPlanWorkflowService(
|
||||
throw new ConflictException(
|
||||
$"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}.");
|
||||
|
||||
// ── [S182 W1b] Rào NHÓM ⟷ QUY TRÌNH, site 3/3 (2 site kia ở đường tạo/sửa nháp).
|
||||
// Cửa này là chốt CUỐI và là cửa DUY NHẤT bắt được phiếu pin lệch có TỪ TRƯỚC: hai
|
||||
// rào kia chỉ chặn lúc GHI, nên phiếu tạo trước khi có rào (hoặc trước khi admin đổi
|
||||
// mã quy trình) vẫn nằm sẵn trong DB với nhóm lệch. Đứng ngay trên `LoadStepsAsync`
|
||||
// để phiếu lệch không kịp bước vào ChoDuyet rồi mới lộ.
|
||||
// Thân luật + lý do "cho qua code ngoài họ": `ContractSigningPlanWorkflowGuards`.
|
||||
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
|
||||
db, awId, plan.ApprovalGroup, ct);
|
||||
|
||||
var steps = await LoadStepsAsync(awId, ct);
|
||||
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
|
||||
if (firstLevelGroup.Count == 0)
|
||||
|
||||
@ -131,7 +131,13 @@ public class ContractSigningPlanApprovalTests
|
||||
string code,
|
||||
Guid[][][] steps,
|
||||
int stepOrderStart = 1,
|
||||
int stepOrderStride = 1)
|
||||
int stepOrderStride = 1,
|
||||
// [S182 W1 T7] 3 tham số đuôi cho rào D6 (pin-check IsActive ∧ IsUserSelectable) và
|
||||
// cho việc dựng 2 version CÙNG Code (UNIQUE là (Code, Version)). Optional trailing ⇒
|
||||
// mọi call-site cũ giữ nguyên chữ.
|
||||
int version = 1,
|
||||
bool isActive = true,
|
||||
bool isUserSelectable = true)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
|
||||
@ -139,13 +145,13 @@ public class ContractSigningPlanApprovalTests
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Version = version,
|
||||
Name = "Quy trình duyệt Kế hoạch ký kết",
|
||||
// Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
|
||||
// của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
|
||||
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
IsActive = isActive,
|
||||
IsUserSelectable = isUserSelectable,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
@ -193,21 +199,29 @@ public class ContractSigningPlanApprovalTests
|
||||
ContractSigningPlanPhase phase,
|
||||
int? stepIndex,
|
||||
int? levelOrder,
|
||||
LineSeed[] lines)
|
||||
LineSeed[] lines,
|
||||
// [S182 W1 T4-T7] Nhóm duyệt của phiếu. Hạng mục fixture ĐI THEO nhóm này (cùng giá trị)
|
||||
// ⇒ rào hạng mục `…WorkflowService.cs:182-187` luôn cho qua, và mọi 409 quan sát được
|
||||
// trong T5 chỉ có thể đến từ rào NHÓM-QUY-TRÌNH. Trailing optional ⇒ 12 call-site cũ giữ nguyên.
|
||||
int approvalGroup = 1)
|
||||
{
|
||||
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
|
||||
await using var db = f.NewDb(drafter);
|
||||
|
||||
// [K2 S164 — vá-5] SubmitAsync nay CHẶN dòng chưa gán hạng mục + hạng mục khác nhóm.
|
||||
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu (entity default ApprovalGroup=1)
|
||||
// — mirror phiếu "đã gán đủ" trước trình; Code random né unique-filtered giữa các plan.
|
||||
// Fixture gán MỌI line vào 1 hạng mục CÙNG NHÓM với phiếu — mirror phiếu "đã gán đủ"
|
||||
// trước trình; Code random né unique-filtered giữa các plan.
|
||||
// 🔴 [S182 W1] Hạng mục ĐI THEO `approvalGroup` của phiếu (không còn hằng 1): T5 seed
|
||||
// phiếu nhóm 3, nếu hạng mục vẫn nhóm 1 thì `SubmitAsync` ném 409 của rào HẠNG MỤC
|
||||
// (`ContractSigningPlanWorkflowService.cs:182-187`) và test sẽ "xanh vì nhầm cửa" —
|
||||
// đo đúng chữ 409 nhưng sai hoàn toàn cơ chế.
|
||||
var catalog = new ContractCatalogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "T-" + Guid.NewGuid().ToString("N")[..8],
|
||||
TenVi = "Hạng mục test W3",
|
||||
GroupCode = "B1",
|
||||
ApprovalGroup = 1,
|
||||
ApprovalGroup = approvalGroup,
|
||||
SignerRole = "TP.PRO",
|
||||
IsActive = true,
|
||||
SortOrder = 1,
|
||||
@ -225,6 +239,7 @@ public class ContractSigningPlanApprovalTests
|
||||
DepartmentId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = phase,
|
||||
ApprovalGroup = approvalGroup,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = stepIndex,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
@ -1138,4 +1153,299 @@ public class ContractSigningPlanApprovalTests
|
||||
.Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
|
||||
.ToListAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ###################################################################
|
||||
// [S182 W1 — YC-013, 2026-08-08] T4-T7: rào NHÓM-QUY-TRÌNH 3-site + pin-check D6.
|
||||
// SPEC: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
|
||||
// spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1 fix (b) + (b2).
|
||||
//
|
||||
// 🔴 VẤN ĐỀ: rào "phiếu nhóm N{g} chỉ pin được quy trình `KHKK-N{g}`" hôm nay chỉ đứng ở
|
||||
// ĐÚNG MỘT CỬA — `CreateContractSigningPlanCommandHandler` (`Features.cs:479-486`).
|
||||
// Hai cửa còn lại đi thẳng: UpdateDraft `:603-607` chỉ gọi `EnsureWorkflowTypeAsync`,
|
||||
// và `SubmitAsync` (`ContractSigningPlanWorkflowService.cs:176-189`) không hỏi câu này
|
||||
// bao giờ. Hệ quả không phải "409 muộn" mà là KÝ SAI DÂY CHUYỀN: phiếu nhóm 3 chạy trạm
|
||||
// của nhóm 5 ⇒ hồ sơ tới tay người không có thẩm quyền ký, im lặng, đúng luồng UI.
|
||||
// W1b gom logic vào `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync`
|
||||
// và cắm đủ 3 cửa.
|
||||
//
|
||||
// ⛔ KHÔNG nhân bản cửa Create ở đây. Cửa đó ĐÃ có lưới đủ răng từ K3/S164 —
|
||||
// `ContractSigningPlanGroupAndCatalogTests.Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa`
|
||||
// (chặn sai-nhóm + cho-qua-ngoài-họ + không-đốt-mã), đang XANH ở baseline. Nó chính là
|
||||
// chứng-nhân "phép dời logic sang class dùng chung KHÔNG đổi ngữ nghĩa cửa cũ"; chép lại
|
||||
// 60 dòng seed PE vào file này chỉ đẻ bản sao đo cùng một thứ. T4-T7 chỉ viết phần nó
|
||||
// KHÔNG đo: 2 cửa mới + rào D6.
|
||||
//
|
||||
// 🔎 Chữ trong thông điệp 409: spec đưa RA HAI bản (giữ nguyên câu Create `:483-486`, hoặc
|
||||
// câu mới W1b "Phiếu nhóm N{g} đang gắn quy trình N{k}…"). Cả hai đều hợp lệ ⇒ test pin
|
||||
// cái BẤT BIẾN giữa hai bản: thông điệp phải nêu CẢ nhóm-của-phiếu LẪN nhóm-của-quy-trình,
|
||||
// vì thiếu một trong hai thì người dùng đọc xong vẫn không biết phải đổi cái gì.
|
||||
// (Nếu BE chọn câu mới nguyên văn ⇒ nó BỎ mã quy trình và làm ĐỎ assert `Contain("KHKK-N5")`
|
||||
// của test K3 nói trên — đã báo lead ở §8 artifact, KHÔNG tự nới test cũ.)
|
||||
// ###################################################################
|
||||
|
||||
private static async Task UpdateDraftAsync(
|
||||
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId,
|
||||
Guid? awId = null, string? ghiChu = null)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var handler = new UpdateContractSigningPlanDraftCommandHandler(db, actor);
|
||||
await handler.Handle(
|
||||
new UpdateContractSigningPlanDraftCommand(planId, GhiChu: ghiChu, HoSoLink: null,
|
||||
ApprovalWorkflowId: awId),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
/// Đọc pin TỪ ĐĨA (context mới). Mọi vế "409 rồi thì không đổi gì" phải đo ở đây chứ không
|
||||
/// đo trên entity trong bộ nhớ — handler gán `plan.ApprovalWorkflowId` TRƯỚC khi
|
||||
/// `SaveChangesAsync` chạy, nên bản in-memory nói dối được.
|
||||
private static async Task<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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,18 +142,25 @@ public class ContractSigningPlanGroupAndCatalogTests
|
||||
KhkkGroupFixture f,
|
||||
string code,
|
||||
Guid[][][] steps,
|
||||
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan)
|
||||
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.ContractSigningPlan,
|
||||
// [S182 vá MAJOR-3] 3 tham số ĐUÔI cho rào D6 (`EnsureWorkflowPinnableAsync`): cần
|
||||
// dựng nhiều version CÙNG một Code (UNIQUE là (Code, Version) —
|
||||
// `ApprovalWorkflowConfiguration.cs:23`) rồi lật TỪNG cờ một. Optional TRAILING ⇒ mọi
|
||||
// call-site cũ giữ nguyên chữ. Cùng khuôn `ContractSigningPlanApprovalTests.cs:135-140`.
|
||||
int version = 1,
|
||||
bool isActive = true,
|
||||
bool isUserSelectable = true)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Version = version,
|
||||
Name = "Quy trình duyệt Kế hoạch ký kết",
|
||||
ApplicableType = type,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
IsActive = isActive,
|
||||
IsUserSelectable = isUserSelectable,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
@ -855,9 +862,21 @@ public class ContractSigningPlanGroupAndCatalogTests
|
||||
var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3);
|
||||
var ex = await wrongGroup.Should().ThrowAsync<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");
|
||||
ex.Which.Message.Should().Contain("KHKK-N5").And.Contain("N3",
|
||||
"thông báo phải chỉ ra CẢ quy trình đang pin lẫn nhóm của phiếu, nếu không người dùng "
|
||||
+ "chỉ thấy '409' và không biết đổi cái gì");
|
||||
// [S182 W1b — spec change, KHÔNG phải nới bar] Thân rào dời sang
|
||||
// `ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync` dùng chung cho 3 cửa
|
||||
// (tạo / sửa nháp / trình), và spec chốt MỘT câu 409 duy nhất cho cả ba:
|
||||
// "Phiếu nhóm N{g} đang gắn quy trình N{k} — vào phiếu (Nháp/Trả lại) đổi lại quy trình
|
||||
// đúng nhóm trước khi trình."
|
||||
// Câu mới bỏ MÃ quy trình, giữ SỐ NHÓM. Không mất thông tin: rào chỉ nổ cho mã TRONG họ
|
||||
// `KHKK-N[1-8]` nên "N5" xác định duy nhất mã "KHKK-N5" (mã ngoài họ đi thẳng, vế (3)).
|
||||
// ⇒ pin cái BẤT BIẾN qua cả hai bản chữ: thông điệp phải nêu ĐỦ HAI nhóm.
|
||||
// [S182 MINOR-1] Pin NGUYÊN CỤM có thứ tự ở ĐÚNG 1 ca này (các ca khác giữ Contain 2 vế):
|
||||
// `Contain(N5) ∧ Contain(N3)` vẫn xanh nếu ai đó ĐẢO 2 đối số ở call-site
|
||||
// (`EnsureWorkflowGroupMatchAsync(db, awId, approvalGroup, ct)` — 2 tham số kề nhau,
|
||||
// 3 call-site). Cụm đầy đủ dưới đây chết ngay khi đảo ⇒ bất-biến thứ-tự có chứng nhân.
|
||||
ex.Which.Message.Should().Contain("Phiếu nhóm N3 đang gắn quy trình N5",
|
||||
"thông báo phải nêu ĐỦ và ĐÚNG CHIỀU: nhóm của phiếu (N3) trước, nhóm của quy trình "
|
||||
+ "đang pin (N5) sau — đảo đối số ở bất kỳ call-site nào là câu này đổi chiều");
|
||||
|
||||
// Bị chặn thì KHÔNG được để lại dấu vết nào.
|
||||
(await ReadPlanScopesAsync(f)).Should().BeEmpty("rào ném trước khi `Add` ⇒ không có phiếu nửa vời");
|
||||
@ -901,6 +920,90 @@ public class ContractSigningPlanGroupAndCatalogTests
|
||||
.ToListAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// [S182 — vá MAJOR-3 reviewer pre-commit] Rào D6 Ở ĐƯỜNG TẠO —
|
||||
// `ContractSigningPlanFeatures.cs:507` gọi `ContractSigningPlanScope
|
||||
// .EnsureWorkflowPinnableAsync` (thân `:331-346`).
|
||||
//
|
||||
// Vì sao ca này phải tồn tại RIÊNG: D6 cắm ở HAI site (Create `:507` + UpdateDraft `:642`),
|
||||
// nhưng ca duy nhất phủ D6 — T7 `ContractSigningPlanApprovalTests.cs:1402` — gọi
|
||||
// `UpdateDraftAsync` ở CẢ BỐN chân. Tức là write-path mà MỌI phiếu KHKK đi qua đang có 0
|
||||
// răng đo: ai dời/đảo/gỡ guard khỏi Create thì cả suite vẫn xanh.
|
||||
//
|
||||
// Ba chân trên CÙNG một bộ seed. Ba quy trình khác nhau ĐÚNG ở Version + MỘT cờ; Code đều
|
||||
// là `KHKK-N1` và phiếu cũng nhóm 1 ⇒ rào (v) `:515` khớp nên IM LẶNG, mọi 409 quan sát
|
||||
// được chỉ có thể đến từ D6:
|
||||
// (1) IsActive=false ⇒ 409 "… đã ngừng hoạt động";
|
||||
// (2) IsUserSelectable=false ⇒ 409 "… chưa được ghim", và THÔNG ĐIỆP PHẢI KHÁC (1) — đó
|
||||
// đúng là lý do prod tách 2 câu (`:328-330`: hai nguyên nhân sửa ở hai nút khác nhau
|
||||
// trong Designer). 🔴 Bản này BUỘC giữ IsActive=true, nếu không vế `!IsActive` nổ
|
||||
// trước và chân (2) đang đo lại chân (1) — hai cờ cùng false = phép đo rỗng.
|
||||
// (3) CONTROL DƯƠNG: bản active + đã ghim ⇒ tạo được, VÀ mã ra `001`. Hai lần 409 ở trên
|
||||
// đứng TRƯỚC `codeGen.GenerateAsync` (`:524`), mà codegen TỰ COMMIT trong transaction
|
||||
// riêng (`ContractSigningPlanCodeGenerator.cs:23-43`) ⇒ đảo thứ tự hai bước này là số
|
||||
// thứ tự bốc hơi vĩnh viễn (mã sẽ ra `003`) và dải mã KHKK thủng lỗ.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Create_PinQuyTrinhDaNgungHoacChuaGhim_ThiBiChan_VaKhongDotMa()
|
||||
{
|
||||
using var f = new KhkkGroupFixture();
|
||||
var bch = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "NV duyệt");
|
||||
var pe = await SeedPeForCreateAsync(f);
|
||||
|
||||
var dangDung = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
|
||||
var daNghi = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
|
||||
version: 2, isActive: false);
|
||||
// IsActive VẪN true — xem ghi chú chân (2) ở khối trên.
|
||||
var chuaGhim = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]],
|
||||
version: 3, isUserSelectable: false);
|
||||
|
||||
// ---- (1) Pin bản ĐÃ NGƯNG HOẠT ĐỘNG.
|
||||
var actNghi = async () => await CreateAsync(f, bch, pe.PeId, daNghi, approvalGroup: 1);
|
||||
var exNghi = await actNghi.Should().ThrowAsync<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
|
||||
// auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn
|
||||
|
||||
@ -4,9 +4,10 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans; // [S182 W1] KHKK plan + changelog (T1-T3, T8-T14)
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (shared HĐ/PE)
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius)
|
||||
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius) + LeaveRequest (T12)
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
@ -67,12 +68,15 @@ public class UpdateAwDefinitionTests
|
||||
ApprovalWorkflowApplicableType type,
|
||||
(int LevelOrder, Guid User)[][] stepSpecs,
|
||||
decimal? ceoThreshold = null,
|
||||
string code = "QT-UPD-V2")
|
||||
string code = "QT-UPD-V2",
|
||||
// [S182 W1] T14 cần 2 version CÙNG Code (UNIQUE là (Code, Version) —
|
||||
// `ApprovalWorkflowConfiguration.cs:26`). Optional trailing ⇒ 14 call-site cũ không đổi.
|
||||
int version = 1)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Version = version,
|
||||
ApplicableType = type,
|
||||
Name = "QT test sửa tại chỗ",
|
||||
IsActive = true,
|
||||
@ -969,4 +973,561 @@ public class UpdateAwDefinitionTests
|
||||
"vết phải đọc được bằng TÊN, không phải GUID");
|
||||
}
|
||||
}
|
||||
|
||||
// ###################################################################
|
||||
// [S182 W1 — YC-013, 2026-08-08] 8 panel "Duyệt KHKK — Nhóm 1..8" mirror trang Duyệt NCC.
|
||||
// SPEC FINAL: `.claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
|
||||
// spec-khkk-awf-mirror-ncc-08-08-2026.md` MỤC 2 W1.
|
||||
//
|
||||
// 10 lưới dưới đây (T1-T3 + T8-T14) đo 3 fix của W1 trên `ApprovalWorkflowV2AdminFeatures.cs`.
|
||||
// Viết TRƯỚC khi BE land (test-before cho a/b, test-after cho c/d) ⇒ ca nào ĐỎ hôm nay là
|
||||
// ĐỎ HỢP LỆ; hình dạng "đỏ vì chưa làm" đã ghi trong `sub-test-specialist-2.md §7`.
|
||||
//
|
||||
// 🔴 MỆNH ĐỀ TRUNG TÂM (R-6, spec `:14`): với `ApplicableType = ContractSigningPlan` (10),
|
||||
// tập bị tắt khi tạo version mới là **THEO Code**, không theo Type:
|
||||
// Where(w.Code == request.Code && w.ApplicableType == typeEnum && w.IsActive)
|
||||
// Hôm nay `:340-343` lọc THEO TYPE ⇒ POST một nhóm = tắt IsActive của CẢ 8 nhóm prod.
|
||||
// T1/T3 là hai nửa của cùng một mệnh đề; T2 giữ cho bản vá KHÔNG nới sang type khác.
|
||||
// ⚠️ CẤM biến-thể regex `^KHKK-N[1-8]$` (R-6): mã type-10 ngoài họ vẫn phải per-Code —
|
||||
// vế đó chính là chân thứ hai của T3.
|
||||
// ###################################################################
|
||||
|
||||
private const ApprovalWorkflowApplicableType Khkk = ApprovalWorkflowApplicableType.ContractSigningPlan;
|
||||
|
||||
/// 8 quy trình nhóm `KHKK-N1..N8` (type-10, v01, IsActive) — hình dạng prod SAU seeder K3.
|
||||
/// Một người duyệt dùng chung cho cả 8: rào trùng chỉ áp trong CÙNG một Cấp của CÙNG một Bước.
|
||||
private static async Task<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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user