[CLAUDE] Workflow: sua quy trinh duyet TAI CHO + co F6 AllowApproverDelete (dot 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s

Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.

Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.

- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
  20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
  - 2 phep thu: (T1) khung khong doi - Order tung row bat bien
                (T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
                     Code, ApplicableType => 409 bao tao ban moi
  - diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
  - guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
    (cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
  - purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
  - KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay

Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-27 12:36:59 +07:00
parent d745fec445
commit b1bae77c9c
13 changed files with 8604 additions and 41 deletions

View File

@ -19,7 +19,7 @@
import { useMemo, useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown, Pin, PinOff } from 'lucide-react' import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown, Pin, PinOff, Pencil, AlertTriangle } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -52,6 +52,10 @@ type LevelDto = {
allowApproverEditBudget: boolean allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean allowApproverSkipToFinal: boolean
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO) allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO)
// [F6 S155] PER-NGƯỜI (per-row, KHÔNG per-Cấp): NV slot này được XÓA phiếu khi
// đang ChoDuyet và đến lượt duyệt của chính mình. 1 row = 1 người ⇒ admin chỉ
// đích danh, không mở cho cả Cấp.
allowApproverDelete: boolean
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role). // [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
canEditProBudget: boolean canEditProBudget: boolean
canEditCcmBudget: boolean canEditCcmBudget: boolean
@ -94,6 +98,12 @@ type TypeSummaryDto = {
type LevelOrder = 1 | 2 | 3 type LevelOrder = 1 | 2 | 3
type EditLevelEntry = { type EditLevelEntry = {
// [F-7 S155] Id của dòng Cấp ĐANG TỒN TẠI trong DB. `undefined` = dòng MỚI
// (admin vừa bấm "+ Thêm NV"). CHỈ dùng cho đường PUT sửa-tại-chỗ:
// `UpdateAwLevelInput.Id` là `Guid?` — có Id = sửa dòng cũ, null = thêm người
// vào Cấp có sẵn (ApprovalWorkflowV2AdminFeatures.cs:424-426).
// Đường POST tạo version mới KHÔNG gửi field này (BE sinh Id mới).
id?: string
order: LevelOrder order: LevelOrder
approverUserId: string approverUserId: string
// Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ // Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ
@ -108,8 +118,20 @@ type EditLevelEntry = {
allowApproverEditBudget: boolean allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean allowApproverSkipToFinal: boolean
allowApproverFinalize: boolean allowApproverFinalize: boolean
// [F6 S155] Cho phép NV này xóa phiếu khi đến lượt duyệt của mình (default false).
allowApproverDelete: boolean
}
type EditStep = {
// [F-7 S155] Id + Order GỐC của Bước trong DB — BẮT BUỘC cho PUT sửa-tại-chỗ
// (`UpdateAwStepInput.Id` là Guid **không** nullable: lệnh này không có đường
// tạo Bước mới) và `reqStep.Order` phải KHỚP `dbStep.Order` (:554) — nên KHÔNG
// được suy ra bằng index `i+1` như đường POST (Order trong DB có thể không liền mạch).
id?: string
order?: number
name: string
departmentId: string | null
levelEntries: EditLevelEntry[]
} }
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null } type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
@ -142,11 +164,17 @@ function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
// Mig 29 (S21 t5) — clone 5 Allow* per slot từ existing Level. // Mig 29 (S21 t5) — clone 5 Allow* per slot từ existing Level.
function copyFromDefinition(d: DefinitionDto): EditStep[] { function copyFromDefinition(d: DefinitionDto): EditStep[] {
return d.steps.map(s => ({ return d.steps.map(s => ({
// [F-7 S155] Giữ Id + Order gốc. Đường POST (tạo version mới) KHÔNG dùng 2
// field này; đường PUT (sửa tại chỗ) BẮT BUỘC có. Cùng 1 hàm nạp cho cả 2
// chế độ để không sinh nhánh nạp thứ hai — nơi 2 nhánh dễ lệch nhau.
id: s.id,
order: s.order,
name: s.name, name: s.name,
departmentId: s.departmentId, departmentId: s.departmentId,
levelEntries: s.levels levelEntries: s.levels
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP) .filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
.map(l => ({ .map(l => ({
id: l.id,
order: l.order as LevelOrder, order: l.order as LevelOrder,
approverUserId: l.approverUserId, approverUserId: l.approverUserId,
allowReturnOneLevel: l.allowReturnOneLevel ?? false, allowReturnOneLevel: l.allowReturnOneLevel ?? false,
@ -157,6 +185,9 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
allowApproverEditBudget: l.allowApproverEditBudget ?? false, allowApproverEditBudget: l.allowApproverEditBudget ?? false,
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false, allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false,
allowApproverFinalize: l.allowApproverFinalize ?? false, allowApproverFinalize: l.allowApproverFinalize ?? false,
// [F6 S155] `?? false` giữ nguyên khuôn F5: bản cũ (BE chưa có cột) clone
// ra vẫn hợp lệ thay vì undefined lọt vào checked → uncontrolled input.
allowApproverDelete: l.allowApproverDelete ?? false,
})), })),
})) }))
} }
@ -175,6 +206,31 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
allowApproverEditBudget: false, allowApproverEditBudget: false,
allowApproverSkipToFinal: false, allowApproverSkipToFinal: false,
allowApproverFinalize: false, allowApproverFinalize: false,
// [F6 S155] default false — quyền xóa phiếu đang duyệt phải admin tick tay.
allowApproverDelete: false,
}
}
// [F-7 S155] 🔴 CHỐNG BẪY "silent drop": payload gửi BE là object literal LIỆT KÊ
// TAY từng field (không spread `...e`) — thiếu 1 dòng thì TS vẫn build sạch, UI vẫn
// tick, BE nhận default `false` ⇒ tick vô hình (đã xảy ra thật với F6, xem
// sub-implementer-frontend-2.md §2.4). Từ đợt này có HAI call-site (POST + PUT) ⇒
// nguy cơ nhân đôi. Gom 9 cờ vào ĐÚNG MỘT hàm, cả 2 call-site cùng spread nó:
// thêm cờ thứ 10 chỉ phải sửa 1 chỗ, không thể lệch giữa 2 đường.
// - 7 cờ QUYỀN-TẠI-CHỖ (4×Return + EditDetails + EditBudget + Delete): PUT ghi được.
// - 2 cờ ĐỔI-ĐƯỜNG-ĐI (SkipToFinal, Finalize): PUT chỉ ECHO — gửi lệch ⇒ 409
// (ApprovalWorkflowV2AdminFeatures.cs:580-585). Vẫn PHẢI gửi đúng giá trị đang lưu.
function levelFlagsPayload(e: EditLevelEntry) {
return {
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
allowApproverDelete: e.allowApproverDelete,
} }
} }
@ -269,6 +325,9 @@ export function ApprovalWorkflowsV2Page() {
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) { function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
const [designerOpen, setDesignerOpen] = useState(false) const [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null) const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id +
// Version). Null ⇒ chế độ tạo version MỚI (POST) như cũ. Hai đường loại trừ nhau.
const [editTarget, setEditTarget] = useState<DefinitionDto | null>(null)
const qc = useQueryClient() const qc = useQueryClient()
const del = useMutation({ const del = useMutation({
@ -299,7 +358,8 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<DefinitionCard <DefinitionCard
def={type.active} def={type.active}
isActive isActive
onClone={d => { setCloneFrom(d); setDesignerOpen(true) }} onClone={d => { setEditTarget(null); setCloneFrom(d); setDesignerOpen(true) }}
onEditInPlace={d => { setCloneFrom(null); setEditTarget(d); setDesignerOpen(true) }}
onToggleSelectable={() => toggleSelectable.mutate({ onToggleSelectable={() => toggleSelectable.mutate({
id: type.active!.id, id: type.active!.id,
isUserSelectable: !type.active!.isUserSelectable, isUserSelectable: !type.active!.isUserSelectable,
@ -318,7 +378,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3> <h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3>
<Button onClick={() => { setCloneFrom(type.active); setDesignerOpen(true) }}> <Button onClick={() => { setEditTarget(null); setCloneFrom(type.active); setDesignerOpen(true) }}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tạo quy trình mới Tạo quy trình mới
</Button> </Button>
@ -336,7 +396,11 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
key={d.id} key={d.id}
def={d} def={d}
isActive={false} isActive={false}
onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }} onClone={dd => { setEditTarget(null); setCloneFrom(dd); setDesignerOpen(true) }}
// 🔴 Version ARCHIVED cũng PHẢI sửa-tại-chỗ được: đúng kịch bản F3 —
// admin lỡ bấm "Tạo quy trình mới", bản cũ archive, nhưng phiếu đang
// treo vẫn pin Id bản cũ đó. Cứu phiếu = sửa CHÍNH bản archived này.
onEditInPlace={dd => { setCloneFrom(null); setEditTarget(dd); setDesignerOpen(true) }}
onToggleSelectable={() => toggleSelectable.mutate({ onToggleSelectable={() => toggleSelectable.mutate({
id: d.id, id: d.id,
isUserSelectable: !d.isUserSelectable, isUserSelectable: !d.isUserSelectable,
@ -353,8 +417,9 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
applicableType={type.applicableType} applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel} applicableTypeLabel={type.applicableTypeLabel}
cloneFrom={cloneFrom} cloneFrom={cloneFrom}
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }} editTarget={editTarget}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }} onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null); onSaved() }}
/> />
)} )}
</div> </div>
@ -367,12 +432,14 @@ function DefinitionCard({
def, def,
isActive, isActive,
onClone, onClone,
onEditInPlace,
onToggleSelectable, onToggleSelectable,
onDelete, onDelete,
}: { }: {
def: DefinitionDto def: DefinitionDto
isActive: boolean isActive: boolean
onClone: (d: DefinitionDto) => void onClone: (d: DefinitionDto) => void
onEditInPlace: (d: DefinitionDto) => void
onToggleSelectable: () => void onToggleSelectable: () => void
onDelete: () => void onDelete: () => void
}) { }) {
@ -475,10 +542,26 @@ function DefinitionCard({
))} ))}
</ol> </ol>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex w-44 shrink-0 flex-col gap-1.5">
<Button variant="outline" size="sm" onClick={() => onClone(def)}> {/* [F-7 S155] HAI hành động TÁCH BẠCH — hệ quả khác hẳn nhau, nhãn phải nói rõ:
(1) SỬA TẠI CHỖ → PUT, giữ nguyên Id + phiên bản ⇒ phiếu ĐANG CHẠY nhận NGAY.
(2) TẠO PHIÊN BẢN MỚI → POST, sinh Id mới ⇒ CHỈ áp cho phiếu tạo SAU đó. */}
<Button
size="sm"
onClick={() => onEditInPlace(def)}
title="Sửa ngay trên phiên bản này: thêm/bớt người trong Cấp có sẵn, đổi quyền duyệt. Giữ nguyên số phiên bản — phiếu ĐANG CHẠY áp dụng NGAY."
>
<Pencil className="h-3.5 w-3.5" />
Sửa tại chỗ
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onClone(def)}
title="Chép bản này thành phiên bản MỚI (số phiên bản tăng). Phiếu đang chạy VẪN theo bản cũ — chỉ phiếu tạo sau mới theo bản mới."
>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Tạo từ bản này Tạo phiên bản mới
</Button> </Button>
{/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */} {/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */}
<Button <Button
@ -506,28 +589,45 @@ function Designer({
applicableType, applicableType,
applicableTypeLabel, applicableTypeLabel,
cloneFrom, cloneFrom,
editTarget,
onClose, onClose,
onSaved, onSaved,
}: { }: {
applicableType: number applicableType: number
applicableTypeLabel: string applicableTypeLabel: string
cloneFrom: DefinitionDto | null cloneFrom: DefinitionDto | null
// [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom.
editTarget: DefinitionDto | null
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
}) { }) {
// [F-7 S155] `isEdit` quyết định TOÀN BỘ hành vi khác biệt của Designer:
// nguồn nạp, field nào khoá, thao tác nào chặn, và gọi PUT hay POST.
const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom
const initialSteps: EditStep[] = useMemo( const initialSteps: EditStep[] = useMemo(
() => (cloneFrom ? copyFromDefinition(cloneFrom) : [makeEmptyStep(1)]), () => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[cloneFrom], [source],
) )
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001' const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode) const [code, setCode] = useState(source?.code ?? defaultCode)
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`) const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '') const [description, setDescription] = useState(source?.description ?? '')
// S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ. // S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ.
// [F-7 S155] Ở chế độ sửa-tại-chỗ ô này bị KHOÁ và giá trị gửi lên KHÔNG lấy từ
// state string này (xem payload PUT) — tránh mọi sai lệch do parse/format.
const [ceoThreshold, setCeoThreshold] = useState( const [ceoThreshold, setCeoThreshold] = useState(
cloneFrom?.ceoApprovalThreshold != null ? String(cloneFrom.ceoApprovalThreshold) : '', source?.ceoApprovalThreshold != null ? String(source.ceoApprovalThreshold) : '',
) )
// [F-7 S155] Lý do sửa → BE ghi vào changelog của CHÍNH những phiếu đang chạy
// trên quy trình này (ApprovalWorkflowV2AdminFeatures.cs:802-818). Optional.
const [reason, setReason] = useState('')
// [F-7 S155] Thông điệp lỗi từ BE (nhất là 409 giải thích vì sao KHÔNG sửa tại
// chỗ được). Toast tự tắt sau vài giây và cắt dòng — 409 của lệnh này dài và là
// hướng dẫn hành động ("hãy tạo phiên bản MỚI") ⇒ phải giữ trên màn để đọc kỹ.
const [serverError, setServerError] = useState<string | null>(null)
const [steps, setSteps] = useState<EditStep[]>(initialSteps) const [steps, setSteps] = useState<EditStep[]>(initialSteps)
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options // Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
@ -569,12 +669,94 @@ function Designer({
if (!e.approverUserId) { if (!e.approverUserId) {
throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`) throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`)
} }
const u = usersList.data?.find(x => x.id === e.approverUserId) // [F-7 S155] Ràng "NV phải thuộc Phòng đã chọn" CHỈ áp cho đường tạo mới.
if (u && u.departmentId !== s.departmentId) { // Sửa tại chỗ được phép đổi Phòng của Bước (BE xếp hạng AN TOÀN và KHÔNG
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`) // kiểm quan hệ NV↔Phòng — :705-706 chỉ gán DepartmentId), trong khi người
// đang duyệt thì KHÔNG được đụng (đổi người mà đã có chữ ký ⇒ 409). Giữ
// ràng này ở chế độ sửa sẽ chặn oan đúng thao tác BE cho phép.
if (!isEdit) {
const u = usersList.data?.find(x => x.id === e.approverUserId)
if (u && u.departmentId !== s.departmentId) {
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`)
}
} }
} }
} }
// ============ [F-7 S155] ĐƯỜNG 1 — SỬA TẠI CHỖ (PUT, giữ Id + Version) ============
if (editTarget) {
// Chặn TRƯỚC ở FE những thay đổi BE sẽ trả 409, để người dùng đọc được câu
// tiếng Việt nói rõ phải làm gì thay vì nhận lỗi sau khi bấm Lưu. UI đã khoá
// các nút ±Bước/đổi thứ tự, đây là lớp chặn thứ hai (state vẫn có thể lệch
// nếu dữ liệu nạp vào bất thường).
if (steps.length !== editTarget.steps.length) {
throw new Error(
`Sửa tại chỗ không thêm/bớt được Bước (đang có ${editTarget.steps.length}, sau khi sửa còn ${steps.length}). Hãy dùng "Tạo phiên bản mới".`,
)
}
for (const s of steps) {
const src = editTarget.steps.find(x => x.id === s.id)
if (!s.id || !src) {
throw new Error('Sửa tại chỗ không tạo được Bước mới. Hãy dùng "Tạo phiên bản mới".')
}
if (src.levels.some(l => l.order < 1 || l.order > MAX_LEVELS_PER_STEP)) {
throw new Error(
`Bước "${src.name}" có cấp ngoài phạm vi 1${MAX_LEVELS_PER_STEP} nên màn này không hiển thị đủ. Không sửa tại chỗ được — hãy dùng "Tạo phiên bản mới".`,
)
}
// `src.levels[].order` từ DTO là `number`, còn `levelEntries[].order` là
// `LevelOrder` (1|2|3) ⇒ ép cả hai Set về `number` để so sánh được.
const srcOrders = new Set<number>(src.levels.map(l => l.order))
const curOrders = new Set<number>(s.levelEntries.map(e => e.order))
const lost = [...srcOrders].filter(o => !curOrders.has(o)).sort()
if (lost.length > 0) {
throw new Error(
`Bước "${s.name}": Cấp ${lost.join(', ')} sẽ không còn ai duyệt — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ ít nhất 1 người ở mỗi Cấp, hoặc dùng "Tạo phiên bản mới".`,
)
}
const added = [...curOrders].filter(o => !srcOrders.has(o)).sort()
if (added.length > 0) {
throw new Error(
`Bước "${s.name}": sửa tại chỗ không thêm được Cấp mới (Cấp ${added.join(', ')}). Hãy dùng "Tạo phiên bản mới".`,
)
}
}
await api.put(`/approval-workflows-v2/${editTarget.id}`, {
id: editTarget.id,
name,
description: description || null,
// Lý do sửa → changelog phiếu đang chạy. Rỗng = không gửi.
reason: reason.trim() === '' ? null : reason.trim(),
// ECHO (null = BE bỏ qua, nhưng gửi đúng vẫn tốt hơn: lệch ⇒ 409 sớm).
code: editTarget.code,
applicableType,
// 🔴 ECHO NGHIÊM (ApprovalWorkflowV2AdminFeatures.cs:529 so sánh BẰNG,
// KHÔNG có "null = bỏ qua"): thiếu/sai ⇒ 409. Lấy THẲNG số từ DTO vừa
// GET về — KHÔNG đi qua state string `ceoThreshold` để loại mọi rủi ro
// parse/format ('' → null, dấu phân cách nghìn, làm tròn).
ceoApprovalThreshold: editTarget.ceoApprovalThreshold,
steps: steps.map(s => ({
id: s.id,
// Order gốc, KHÔNG phải index: BE bắt `reqStep.Order == dbStep.Order` (:554).
order: s.order,
name: s.name,
departmentId: s.departmentId,
levels: s.levelEntries.map(e => ({
// null = dòng MỚI (thêm người vào Cấp có sẵn). Có Id = sửa dòng cũ.
id: e.id ?? null,
order: e.order,
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
// 9 cờ qua ĐÚNG 1 hàm dùng chung với POST — xem levelFlagsPayload().
...levelFlagsPayload(e),
})),
})),
})
return
}
// ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============
await api.post('/approval-workflows-v2', { await api.post('/approval-workflows-v2', {
applicableType, applicableType,
code, code,
@ -595,27 +777,37 @@ function Designer({
order: e.order, order: e.order,
name: `Cấp ${e.order}`, name: `Cấp ${e.order}`,
approverUserId: e.approverUserId, approverUserId: e.approverUserId,
allowReturnOneLevel: e.allowReturnOneLevel, // [F6 S155 → F-7 S155] 🔴 DÂY DỄ QUÊN NHẤT: payload liệt kê tay từng
allowReturnOneStep: e.allowReturnOneStep, // field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick
allowReturnToAssignee: e.allowReturnToAssignee, // vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT),
allowReturnToDrafter: e.allowReturnToDrafter, // 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi.
allowApproverEditDetails: e.allowApproverEditDetails, ...levelFlagsPayload(e),
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
})), })),
})), })),
}) })
}, },
onSuccess: () => { onSuccess: () => {
toast.success('Đã lưu quy trình mới. Version cũ đã archive.') setServerError(null)
toast.success(
isEdit
? `Đã lưu thay đổi trên ${editTarget!.code} v${editTarget!.version} — GIỮ NGUYÊN phiên bản. Các phiếu đang chạy trên quy trình này áp dụng NGAY.`
: 'Đã lưu quy trình mới. Version cũ đã archive. Lưu ý: phiếu ĐANG chạy vẫn theo bản cũ.',
)
onSaved() onSaved()
}, },
onError: err => toast.error(getErrorMessage(err)), onError: err => {
// 🔴 KHÔNG nuốt thông điệp BE. 409 của lệnh sửa-tại-chỗ giải thích chính xác
// thay đổi nào phạm luật + bảo phải làm gì ("tạo phiên bản MỚI") ⇒ giữ lại
// trên màn (banner) chứ không chỉ toast thoáng qua.
const msg = getErrorMessage(err)
setServerError(msg)
toast.error(msg)
},
}) })
function submit(e: FormEvent) { function submit(e: FormEvent) {
e.preventDefault() e.preventDefault()
setServerError(null)
if (steps.length === 0) { if (steps.length === 0) {
toast.error('Phải có ít nhất 1 bước') toast.error('Phải có ít nhất 1 bước')
return return
@ -635,23 +827,74 @@ function Designer({
<Dialog <Dialog
open open
onClose={onClose} onClose={onClose}
title={`Tạo quy trình mới — ${applicableTypeLabel}`} title={
isEdit
? `Sửa tại chỗ — ${editTarget!.code} v${String(editTarget!.version).padStart(2, '0')} (giữ nguyên phiên bản)`
: `Tạo quy trình mới — ${applicableTypeLabel}`
}
size="lg" size="lg"
footer={ footer={
<> <>
<Button variant="outline" onClick={onClose}>Hủy</Button> <Button variant="outline" onClick={onClose}>Hủy</Button>
<Button onClick={submit} disabled={save.isPending} form="aw-v2-form"> <Button onClick={submit} disabled={save.isPending} form="aw-v2-form">
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'} {save.isPending
? 'Đang lưu…'
: isEdit ? 'Lưu thay đổi (giữ nguyên phiên bản)' : 'Lưu + kích hoạt'}
</Button> </Button>
</> </>
} }
> >
<form id="aw-v2-form" onSubmit={submit} className="space-y-4"> <form id="aw-v2-form" onSubmit={submit} className="space-y-4">
{/* [F-7 S155] Banner phân biệt HAI đường — người dùng phải hiểu hệ quả
khác nhau TRƯỚC khi sửa, không phải sau khi bấm Lưu. */}
{isEdit ? (
<div className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-[11.5px] leading-relaxed text-emerald-900">
<div className="font-semibold">Đang SỬA TẠI CHỖ phiên bản {editTarget!.code} v{String(editTarget!.version).padStart(2, '0')} không tạo phiên bản mới.</div>
<div className="mt-1">
Lưu xong, thay đi áp dụng <span className="font-semibold">NGAY</span> cho cả những phiếu <span className="font-semibold">đang chạy</span> trên quy trình này
(kể cả phiếu đang chờ duyệt) đây cách duy nhất đ cờ quyền vừa tick tới đưc phiếu đã gửi đi.
</div>
<div className="mt-1">
Sửa được: thêm/bớt người trong Cấp <span className="font-semibold"> sẵn</span> · đi người duyệt · các ô quyền duyệt (gồm quyền XÓA phiếu) · tên hiển thị · Phòng của Bước.
<br />
Muốn thêm/bớt Bước hoặc Cấp, đi thứ tự, đi / Ngưỡng CEO / cờ &quot;Duyệt KẾT THÚC&quot; / &quot;Duyt thng Cp cui&quot; phi bm <span className="font-semibold">&quot;To phiên bn mi&quot;</span>.
</div>
</div>
) : (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-[11.5px] leading-relaxed text-amber-900">
<div className="font-semibold">Đang TẠO PHIÊN BẢN MỚI số phiên bản sẽ tăng, bản hiện tại chuyển sang lưu trữ.</div>
<div className="mt-1">
Phiếu <span className="font-semibold">đang chạy vẫn theo bản </span> (phiếu gắn chặt vào phiên bản lúc tạo). Chỉ phiếu tạo SAU khi lưu mới theo bản này.
Nếu chỉ muốn thêm người / đi quyền cho phiếu đang treo, hãy đóng lại bấm <span className="font-semibold">&quot;Sửa tại chỗ&quot;</span>.
</div>
</div>
)}
{/* [F-7 S155] Thông điệp từ máy chủ — nhất là 409 "không sửa tại chỗ được vì…".
Giữ trên màn (không chỉ toast) để đọc kỹ và làm theo hướng dẫn. */}
{serverError && (
<div className="flex items-start gap-2 rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-[11.5px] leading-relaxed text-rose-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="whitespace-pre-wrap">{serverError}</div>
</div>
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label> quy trình *</Label> <Label> quy trình *</Label>
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" /> <Input
<div className="text-[11px] text-slate-400">Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.</div> value={code}
onChange={e => setCode(e.target.value)}
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}
/>
<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.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
</div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Tên hiển thị *</Label> <Label>Tên hiển thị *</Label>
@ -669,12 +912,33 @@ function Designer({
value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')} value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')}
onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))} onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))}
placeholder="Để trống = luôn theo đủ quy trình" placeholder="Để trống = luôn theo đủ quy trình"
// 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh
// cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng
// 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}
/> />
<div className="text-[11px] leading-relaxed text-slate-400"> <div className="text-[11px] leading-relaxed text-slate-400">
Gói &lt; ngưỡng: CCM duyệt xong, không cần CEO. ngưỡng: phải lên CEO. {isEdit
Đ trống = luôn theo đ quy trình. ? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
</div> </div>
</div> </div>
{/* [F-7 S155] Lý do sửa — BE ghi vào changelog của chính những phiếu đang
chạy trên quy trình này, để người đọc lịch sử phiếu hiểu vì sao quy
trình đổi giữa chừng (ApprovalWorkflowV2AdminFeatures.cs:802-818). */}
{isEdit && (
<div className="col-span-2 space-y-1.5">
<Label> do sửa (ghi vào lịch sử phiếu đang chạy)</Label>
<Textarea
rows={2}
maxLength={500}
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Vd: Bổ sung quyền xóa phiếu cho Trưởng phòng Mua hàng theo yêu cầu ngày 27/07."
/>
</div>
)}
</div> </div>
{/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver. {/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver.
@ -1005,6 +1269,19 @@ function Designer({
/> />
<span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span> <span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span>
</label> </label>
{/* [F6 S155 — quyền xóa phiếu đang duyệt] PER-NGƯỜI, KHÔNG
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">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverDelete}
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>
</label>
</div> </div>
</div> </div>
) )

View File

@ -280,6 +280,8 @@ function WorkflowCard({ wf }: { wf: AwDefinitionDto }) {
<FlagRow active={r.level.allowApproverSkipToFinal} label="Cho phép duyệt thẳng Cấp cuối khi đang duyệt" colSpan2 /> <FlagRow active={r.level.allowApproverSkipToFinal} label="Cho phép duyệt thẳng Cấp cuối khi đang duyệt" colSpan2 />
{/* [Mig 58 — anh Kiệt FDC, S89] Flag thứ 8: cấp này Duyệt = KẾT THÚC (không trình CEO) — trước đây matrix thiếu nên "chưa hiển thị chính xác". */} {/* [Mig 58 — anh Kiệt FDC, S89] Flag thứ 8: cấp này Duyệt = KẾT THÚC (không trình CEO) — trước đây matrix thiếu nên "chưa hiển thị chính xác". */}
<FlagRow active={r.level.allowApproverFinalize} label="Duyệt là KẾT THÚC quy trình (không trình tiếp CEO)" colSpan2 /> <FlagRow active={r.level.allowApproverFinalize} label="Duyệt là KẾT THÚC quy trình (không trình tiếp CEO)" colSpan2 />
{/* [F6 S155] Flag thứ 9 — quyền xóa phiếu đang duyệt, PER-NGƯỜI của đúng dòng NV này (không phải cả Cấp). */}
<FlagRow active={r.level.allowApproverDelete} label="Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình" colSpan2 />
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -2,7 +2,8 @@
// ApprovalWorkflowV2AdminFeatures.cs). Chỉ subset cần cho fe-user read-only // ApprovalWorkflowV2AdminFeatures.cs). Chỉ subset cần cho fe-user read-only
// matrix view (Plan AA Chunk B S24). // matrix view (Plan AA Chunk B S24).
// //
// 8 Allow* flag per slot Level — Mig 29/30/31 + Mig 58 (allowApproverFinalize). // 9 Allow* flag per slot Level — Mig 29/30/31 + Mig 58 (allowApproverFinalize)
// + F6 S155 (allowApproverDelete).
export type AwLevelDto = { export type AwLevelDto = {
id: string id: string
@ -20,6 +21,10 @@ export type AwLevelDto = {
allowApproverSkipToFinal: boolean allowApproverSkipToFinal: boolean
// [Mig 58 — anh Kiệt FDC, S89] Flag thứ 8: cấp này Duyệt = KẾT THÚC quy trình (không trình tiếp CEO). // [Mig 58 — anh Kiệt FDC, S89] Flag thứ 8: cấp này Duyệt = KẾT THÚC quy trình (không trình tiếp CEO).
allowApproverFinalize: boolean allowApproverFinalize: boolean
// [F6 S155] Flag thứ 9: PER-NGƯỜI (per-row) — NV slot này được XÓA phiếu khi
// phiếu đang Chờ duyệt VÀ đúng lượt duyệt của chính họ. "Cấp" là tập row cùng
// Order ⇒ tick 1 row KHÔNG mở quyền cho cả Cấp.
allowApproverDelete: boolean
} }
export type AwStepDto = { export type AwStepDto = {

View File

@ -33,6 +33,23 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
return Ok(new { id }); return Ok(new { id });
} }
// [S155 F-1/F-1a] SỬA TẠI CHỖ quy trình — KHÔNG tăng Version, KHÔNG đụng
// IsActive, KHÔNG đổi Id. Giữ Id là TOÀN BỘ mục đích: phiếu đang chạy pin
// ApprovalWorkflowId và đọc workflow SỐNG (PurchaseEvaluationWorkflowService
// :674 `w.Id == awId`) ⇒ chỉ có sửa-tại-chỗ mới tới được phiếu đang treo.
// Chỉ nhận thay đổi hạng AN TOÀN; handler thi hành 2 phép thử (T1) khung
// không đổi + (T2) luật chơi không đổi, vi phạm ⇒ 409.
// 🔴 Policy BẮT BUỘC — khớp 3 verb ghi còn lại của controller này. Thiếu nó =
// leo thang đặc quyền: thêm mình vào một Cấp ⇒ duyệt phiếu người khác;
// bật cờ F6 ⇒ tự cấp quyền xoá phiếu đang duyệt.
[HttpPut("{id:guid}")]
[Authorize(Policy = "Workflows.Create")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateAwDefinitionCommand cmd, CancellationToken ct)
{
await mediator.Send(cmd with { Id = id }, ct);
return NoContent();
}
public record SetUserSelectableBody(bool IsUserSelectable); public record SetUserSelectableBody(bool IsUserSelectable);
// Mig 25 — admin toggle stick/unstick "cho user pick lúc create phiếu". // Mig 25 — admin toggle stick/unstick "cho user pick lúc create phiếu".

View File

@ -2,9 +2,11 @@ using FluentValidation;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.ApprovalWorkflowsV2; namespace SolutionErp.Application.ApprovalWorkflowsV2;
@ -39,6 +41,7 @@ public record AwLevelDto(
bool AllowApproverEditBudget, bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal, bool AllowApproverSkipToFinal,
bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO) bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
bool AllowApproverDelete, // [Mig 68 S155] slot này được XÓA MỀM phiếu ChoDuyet khi tới lượt mình
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy // [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
// từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO); // từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer. // CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
@ -185,7 +188,7 @@ public class GetAwAdminOverviewQueryHandler(
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email, return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee, l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget, l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
l.AllowApproverSkipToFinal, l.AllowApproverFinalize, l.AllowApproverSkipToFinal, l.AllowApproverFinalize, l.AllowApproverDelete,
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId)); proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
}).ToList() }).ToList()
)).ToList()); )).ToList());
@ -227,7 +230,10 @@ public record CreateAwLevelInput(
bool AllowApproverEditDetails = false, bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = false, bool AllowApproverEditBudget = false,
bool AllowApproverSkipToFinal = false, bool AllowApproverSkipToFinal = false,
bool AllowApproverFinalize = false); bool AllowApproverFinalize = false,
// [Mig 68 S155] F6 — slot này được XÓA MỀM phiếu đang treo ChoDuyet khi tới
// lượt mình duyệt. Default false (admin opt-in per slot).
bool AllowApproverDelete = false);
public record CreateAwStepInput( public record CreateAwStepInput(
int Order, int Order,
@ -366,6 +372,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditBudget = l.AllowApproverEditBudget, AllowApproverEditBudget = l.AllowApproverEditBudget,
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal, AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
AllowApproverFinalize = l.AllowApproverFinalize, AllowApproverFinalize = l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).ToList(), }).ToList(),
}) })
.ToList(), .ToList(),
@ -376,6 +383,747 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
} }
} }
// ============================================================================
// ========== PUT sửa TẠI CHỖ (S155 2026-07-27 — hạng mục F, owner chốt (7)) ==========
// ============================================================================
// Owner: "Chỗ quy trình duyệt -> Cho thêm tính năng thêm người/điều chỉnh quyền
// -> Chứ ko cần phải tạo mới. Khi nào cần thay đổi quy trình duyệt thì mới bắt
// buộc tạo mới."
//
// 🔴 VÌ SAO PHẢI CÓ (§F.0 spec S155 — phụ thuộc CỨNG, không phải tiện nghi):
// POST tạo version mới sinh Id MỚI (:336) → phiếu đang treo pin Id CŨ
// (`PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId`, tham chiếu SỐNG
// không snapshot) → bản cũ chỉ bị IsActive=false (:334) nên vẫn resolve được →
// phiếu chạy tiếp trên cấu hình CŨ, IM LẶNG. Không có đường re-pin
// (`PE.ApprovalWorkflowId` chỉ ghi lúc create/update-nháp). ⇒ Tick cờ hôm nay
// bằng POST thì phiếu cần cứu KHÔNG BAO GIỜ thấy cờ.
// ⇒ Lệnh này GIỮ NGUYÊN Id + Version + IsActive. Giữ Id là TOÀN BỘ mục đích.
//
// 🔴 PHÂN LOẠI (§F.2) — AN TOÀN ⟺ thoả CẢ HAI phép thử:
// (T1) Khung không đổi — tập {Step.Order} và {Level.Order} giữ nguyên (con trỏ
// phiếu LAI: StepIndex = INDEX vào list sort `:686`, LevelOrder = so GIÁ
// TRỊ `:694` ⇒ giữ 2 tập Order thì không con trỏ nào dịch).
// Ở đây SIẾT MẠNH HƠN set-equality: Order của TỪNG row là BẤT BIẾN. Lý do:
// hoán vị Order giữa 2 Bước giữ nguyên TẬP nhưng đổi thứ tự thật (#11 PHÁ VỠ),
// set-equality một mình KHÔNG bắt được.
// (T2) Luật chơi không đổi — cấm trường quyết định ĐƯỜNG ĐI: CeoApprovalThreshold,
// F5 AllowApproverFinalize, Code, ApplicableType.
//
// ✅ Cho sửa: thêm/bớt người trong Cấp CÓ SẴN · đổi ApproverUserId · 7 cờ
// QUYỀN-TẠI-CHỖ (F1×4, F3, F4, F6) · tên hiển thị (workflow/step/level) ·
// Step.DepartmentId (owner chốt AN TOÀN — BE chỉ dùng làm hint, không khớp người duyệt).
// ❌ Từ chối 409 + bảo tạo version mới: thêm/bớt/đổi thứ tự Bước hoặc Cấp ·
// Code · ApplicableType · CeoApprovalThreshold · F5 · F2.
//
// 🔸 KHAI QUYẾT ĐỊNH (spec KHÔNG xếp hạng F2 `AllowApproverSkipToFinal`): xếp F2
// vào nhóm CẤM-ĐỔI cùng F5 vì nó cho phép nhảy thẳng Cấp cuối = đổi đường đi
// phiếu, không phải "quyền tại chỗ". Chọn phía CHẶT khi spec im lặng; nới sau
// rẻ hơn thu hồi. Cả F2 lẫn F5 nhận theo kiểu ECHO: gửi lên phải KHỚP giá trị
// đang lưu (null = không khai báo, giữ nguyên), lệch ⇒ 409 — KHÔNG bao giờ ghi.
public record UpdateAwLevelInput(
// null = dòng MỚI (thêm người vào Cấp CÓ SẴN). Có Id = dòng đang tồn tại.
Guid? Id,
int Order,
string? Name,
Guid ApproverUserId,
// 7 cờ QUYỀN-TẠI-CHỖ — sửa được tại chỗ.
bool AllowReturnOneLevel = false,
bool AllowReturnOneStep = false,
bool AllowReturnToAssignee = false,
bool AllowReturnToDrafter = true,
bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = false,
bool AllowApproverDelete = false,
// 2 cờ ĐỔI-ĐƯỜNG-ĐI — ECHO-only (null = không khai báo). Lệch ⇒ 409.
bool? AllowApproverSkipToFinal = null,
bool? AllowApproverFinalize = null);
public record UpdateAwStepInput(
Guid Id, // BẮT BUỘC — không có đường thêm/bớt Bước ở lệnh này
int Order,
string Name,
Guid? DepartmentId,
List<UpdateAwLevelInput> Levels);
public record UpdateAwDefinitionCommand(
Guid Id,
string Name,
string? Description,
List<UpdateAwStepInput> Steps,
// F-1c — lý do sửa, đi vào VẾT (changelog phiếu đang chạy). Optional.
string? Reason = null,
// (T2) ECHO-only. Code/ApplicableType: null = không khai báo → bỏ qua.
string? Code = null,
int? ApplicableType = null,
// 🔴 CeoApprovalThreshold ECHO NGHIÊM: phải KHỚP TUYỆT ĐỐI giá trị đang lưu
// (cả 2 cùng null, hoặc cùng giá trị). KHÔNG dùng "null = bỏ qua" như 2 trường
// trên vì null LÀ một giá trị có nghĩa (= bỏ ngưỡng) ⇒ nếu bỏ qua thì admin xoá
// trắng ô ngưỡng sẽ nhận 204 mà số KHÔNG đổi = sai IM LẶNG, đúng cái class lỗi
// spec này đang đi chữa. Client PHẢI echo lại ngưỡng hiện tại.
decimal? CeoApprovalThreshold = null) : IRequest;
public class UpdateAwDefinitionCommandValidator : AbstractValidator<UpdateAwDefinitionCommand>
{
public UpdateAwDefinitionCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.Reason).MaximumLength(500);
RuleFor(x => x.Steps).NotEmpty()
.WithMessage("Quy trình phải có ít nhất 1 bước.");
RuleForEach(x => x.Steps).ChildRules(step =>
{
step.RuleFor(s => s.Id).NotEmpty()
.WithMessage("Mỗi Bước phải kèm Id của Bước đang tồn tại (sửa tại chỗ không tạo Bước mới).");
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
step.RuleFor(s => s.Levels).NotEmpty()
.WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
step.RuleForEach(s => s.Levels).ChildRules(level =>
{
level.RuleFor(l => l.Order)
.InclusiveBetween(1, CreateAwDefinitionCommandValidator.MaxLevelsPerStep);
level.RuleFor(l => l.Name).MaximumLength(200);
level.RuleFor(l => l.ApproverUserId).NotEmpty()
.WithMessage("Mỗi dòng cấp phải chỉ định 1 NV duyệt.");
});
step.RuleFor(s => s.Levels).Must(HaveNoDuplicateApproverInSameLevel)
.WithMessage("Một NV không được duyệt hai lần trong cùng một Cấp.");
});
}
private static bool HaveNoDuplicateApproverInSameLevel(List<UpdateAwLevelInput> levels) =>
levels.GroupBy(l => new { l.Order, l.ApproverUserId }).All(g => g.Count() == 1);
}
public class UpdateAwDefinitionCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IDateTime dateTime,
UserManager<User> userManager) : IRequestHandler<UpdateAwDefinitionCommand>
{
private const string NewVersionHint =
"Thay đổi này đổi CẤU TRÚC/ĐƯỜNG ĐI của quy trình nên không sửa tại chỗ được — hãy tạo phiên bản quy trình MỚI.";
private sealed record LevelOpinionCount(Guid LevelId, int Count);
// F2 — một chữ ký CÒN LƯU trên dòng Cấp, kèm đủ thứ để NÓI TÊN hồ sơ đang giữ nó.
// `Code` nullable vì mọi module đều cho hồ sơ nháp chưa sinh mã.
private sealed record RetainedOpinionRef(Guid LevelId, string Module, string? Code, bool ParentDeleted);
public async Task Handle(UpdateAwDefinitionCommand request, CancellationToken ct)
{
var def = await db.ApprovalWorkflows
.Include(w => w.Steps)
.ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == request.Id, ct)
?? throw new NotFoundException("Quy trình duyệt", request.Id);
// ================= (T2) LUẬT CHƠI KHÔNG ĐỔI =================
if (request.Code is string code && !string.Equals(code, def.Code, StringComparison.Ordinal))
throw new ConflictException(
$"Không được đổi Mã quy trình khi sửa tại chỗ ({def.Code} → {code}): mã là khoá của chuỗi phiên bản. {NewVersionHint}");
if (request.ApplicableType is int applicableType && applicableType != (int)def.ApplicableType)
throw new ConflictException(
$"Không được đổi Loại áp dụng khi sửa tại chỗ: phiếu đang pin quy trình này sẽ chạy sang module khác. {NewVersionHint}");
if (request.CeoApprovalThreshold != def.CeoApprovalThreshold)
throw new ConflictException(
$"Không được đổi Ngưỡng giá trị gói CEO khi sửa tại chỗ ({FormatMoney(def.CeoApprovalThreshold)} → {FormatMoney(request.CeoApprovalThreshold)}): " +
$"phiếu đang chờ sẽ đổi nhánh cần/không-cần CEO giữa chừng, trong khi người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó. {NewVersionHint}");
// ================= (T1) KHUNG KHÔNG ĐỔI — Bước =================
var dbSteps = def.Steps.OrderBy(s => s.Order).ToList();
var reqStepIds = request.Steps.Select(s => s.Id).ToList();
if (reqStepIds.Distinct().Count() != reqStepIds.Count)
throw new ConflictException("Danh sách Bước gửi lên có Id trùng nhau.");
if (!dbSteps.Select(s => s.Id).ToHashSet().SetEquals(reqStepIds))
throw new ConflictException(
$"Không được thêm/bớt Bước khi sửa tại chỗ (quy trình đang có {dbSteps.Count} bước, gửi lên {reqStepIds.Count}). {NewVersionHint}");
// ================= Phân tích diff theo Level.Id (F-3: CẤM delete-then-insert) =================
var toRemove = new List<ApprovalWorkflowLevel>(); // bỏ người khỏi Cấp
var toUpdate = new List<(ApprovalWorkflowLevel Db, UpdateAwLevelInput Req, ApprovalWorkflowStep Step)>();
var toAdd = new List<(ApprovalWorkflowStep Step, UpdateAwLevelInput Req)>();
var approverChanged = new List<ApprovalWorkflowLevel>(); // đổi ApproverUserId (hạng NỬA #5)
foreach (var reqStep in request.Steps)
{
var dbStep = dbSteps.First(s => s.Id == reqStep.Id);
var where = $"Bước {dbStep.Order} ({dbStep.Name})";
if (reqStep.Order != dbStep.Order)
throw new ConflictException(
$"Không được đổi thứ tự Bước ({where}: {dbStep.Order} → {reqStep.Order}): con trỏ phiếu trỏ theo INDEX của danh sách đã sắp xếp nên sẽ trỏ sang bước khác, ÂM THẦM. {NewVersionHint}");
var dbLevels = dbStep.Levels.OrderBy(l => l.Order).ToList();
var reqLevelIds = reqStep.Levels.Where(l => l.Id is Guid).Select(l => l.Id!.Value).ToList();
if (reqLevelIds.Distinct().Count() != reqLevelIds.Count)
throw new ConflictException($"{where}: danh sách cấp duyệt gửi lên có Id trùng nhau.");
var dbLevelById = dbLevels.ToDictionary(l => l.Id);
var unknown = reqLevelIds.Where(id => !dbLevelById.ContainsKey(id)).ToList();
if (unknown.Count > 0)
throw new ConflictException($"{where}: có dòng cấp duyệt không thuộc bước này (Id {string.Join(", ", unknown)}).");
foreach (var reqLevel in reqStep.Levels)
{
if (reqLevel.Id is Guid lid)
{
var dbLevel = dbLevelById[lid];
// Order của TỪNG dòng là BẤT BIẾN (siết mạnh hơn set-equality).
if (reqLevel.Order != dbLevel.Order)
throw new ConflictException(
$"{where}: không được chuyển người duyệt từ Cấp {dbLevel.Order} sang Cấp {reqLevel.Order} khi sửa tại chỗ. {NewVersionHint}");
// (T2) 2 cờ ĐỔI-ĐƯỜNG-ĐI — echo phải khớp.
if (reqLevel.AllowApproverFinalize is bool f && f != dbLevel.AllowApproverFinalize)
throw new ConflictException(
$"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \"Duyệt kết thúc (không trình CEO)\" khi sửa tại chỗ — cờ này đổi ĐIỂM KẾT THÚC của phiếu và đóng băng số tiền ngay lúc duyệt. {NewVersionHint}");
if (reqLevel.AllowApproverSkipToFinal is bool s && s != dbLevel.AllowApproverSkipToFinal)
throw new ConflictException(
$"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \"Duyệt vượt cấp tới Cấp cuối\" khi sửa tại chỗ — cờ này cho bỏ qua các Bước/Cấp trung gian. {NewVersionHint}");
toUpdate.Add((dbLevel, reqLevel, dbStep));
if (reqLevel.ApproverUserId != dbLevel.ApproverUserId)
approverChanged.Add(dbLevel);
}
else
{
// Dòng MỚI — người mới KHÔNG được mang cờ đổi-đường-đi.
if (reqLevel.AllowApproverFinalize == true)
throw new ConflictException(
$"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \"Duyệt kết thúc (không trình CEO)\". {NewVersionHint}");
if (reqLevel.AllowApproverSkipToFinal == true)
throw new ConflictException(
$"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \"Duyệt vượt cấp tới Cấp cuối\". {NewVersionHint}");
toAdd.Add((dbStep, reqLevel));
}
}
var removedHere = dbLevels.Where(l => !reqLevelIds.Contains(l.Id)).ToList();
foreach (var gone in removedHere)
{
// Bỏ người đang giữ cờ đổi-đường-đi = đổi điểm kết thúc / bỏ đường tắt.
if (gone.AllowApproverFinalize)
throw new ConflictException(
$"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \"Duyệt kết thúc (không trình CEO)\" — bỏ đi là đổi ĐIỂM KẾT THÚC của phiếu. {NewVersionHint}");
if (gone.AllowApproverSkipToFinal)
throw new ConflictException(
$"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \"Duyệt vượt cấp tới Cấp cuối\". {NewVersionHint}");
}
toRemove.AddRange(removedHere);
// (T1) tập {Level.Order} SAU khi sửa phải trùng khít tập TRƯỚC.
var finalOrders = reqStep.Levels.Select(l => l.Order).ToHashSet();
var dbOrders = dbLevels.Select(l => l.Order).ToHashSet();
if (!dbOrders.SetEquals(finalOrders))
{
var lost = dbOrders.Except(finalOrders).OrderBy(o => o).ToList();
if (lost.Count > 0)
throw new ConflictException(
$"{where}: Cấp {string.Join(", ", lost)} sẽ KHÔNG CÒN người duyệt nào — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ lại ít nhất 1 người ở mỗi Cấp. {NewVersionHint}");
var added = finalOrders.Except(dbOrders).OrderBy(o => o).ToList();
throw new ConflictException(
$"{where}: không được thêm Cấp mới (Cấp {string.Join(", ", added)}) khi sửa tại chỗ. {NewVersionHint}");
}
}
// ================= NV được trỏ tới phải tồn tại (chặn FK 547 → 500) =================
var referencedUserIds = toUpdate.Select(t => t.Req.ApproverUserId)
.Concat(toAdd.Select(t => t.Req.ApproverUserId))
.Distinct().ToList();
if (referencedUserIds.Count > 0)
{
var existing = await db.Users.AsNoTracking()
.Where(u => referencedUserIds.Contains(u.Id))
.Select(u => u.Id).ToListAsync(ct);
var missing = referencedUserIds.Except(existing).ToList();
if (missing.Count > 0)
throw new ConflictException($"Có NV duyệt không tồn tại trong hệ thống (Id {string.Join(", ", missing)}).");
}
// ================= F-5 / F-5a — hạng NỬA: chặn LỊCH SỰ trước khi FK nổ =================
// Row đã có CHỮ KÝ (LevelOpinion) mà bị bỏ đi → FK Restrict nổ 547 → 500.
// Row đã có chữ ký mà bị đổi ApproverUserId → chữ ký của người A hoá thành
// của người B, IM LẶNG (opinion khoá theo LevelId, không theo UserId).
// Guard chạy HAI TẦNG (tầng 2 thêm ở wave 5 theo F2 của reviewer):
// tầng 1 `CountLiveOpinionsAsync` — chữ ký trên hồ sơ CÒN SỐNG (thông điệp cũ).
// tầng 2 `ListRetainedOpinionsAsync` — chữ ký còn LƯU nhưng không "sống"
// (hồ sơ cha xoá mềm, hoặc ý kiến tự nó xoá mềm).
// 🔴 ĐẢO QUYẾT ĐỊNH F-5a (bản trước): trước đây coi "phiếu đã xoá thì chặn admin là
// OAN" nên để purge xoá CỨNG chữ ký của phiếu xoá mềm. Sai — xoá mềm là KHÔI PHỤC
// ĐƯỢC và §A spec S155 có màn "Đã xoá" đọc lịch sử; xoá cứng chữ ký của nó là mất
// trắng, im lặng, không dấu vết. Nay chặn cả hai tầng; chỉ hồ sơ đã KHÔNG CÒN ROW
// mới bị coi là mồ côi (xem `PurgeOrphanOpinionsAsync`).
var guardedLevelIds = toRemove.Select(l => l.Id).Concat(approverChanged.Select(l => l.Id)).Distinct().ToList();
if (guardedLevelIds.Count > 0)
{
var liveOpinions = await CountLiveOpinionsAsync(guardedLevelIds, ct);
var blocked = toRemove.Where(l => liveOpinions.GetValueOrDefault(l.Id) > 0).ToList();
if (blocked.Count > 0)
{
var first = blocked[0];
var step = dbSteps.First(s => s.Id == first.ApprovalWorkflowStepId);
throw new ConflictException(
$"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không bỏ được người này vì đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực. Bỏ đi là xoá mất lịch sử duyệt. {NewVersionHint}");
}
var blockedSwap = approverChanged.Where(l => liveOpinions.GetValueOrDefault(l.Id) > 0).ToList();
if (blockedSwap.Count > 0)
{
var first = blockedSwap[0];
var step = dbSteps.First(s => s.Id == first.ApprovalWorkflowStepId);
throw new ConflictException(
$"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không đổi được người duyệt vì dòng này đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực — đổi người sẽ khiến ý kiến của người cũ hoá thành của người mới. {NewVersionHint}");
}
// ===== 🔴 F2 — TẦNG 2: chữ ký CÒN LƯU nhưng không "sống" =====
// Hai ca mà tầng 1 CỐ TÌNH bỏ qua, và trước bản vá bị `PurgeOrphanOpinionsAsync`
// xoá CỨNG (mất vĩnh viễn, chỉ để lại một con số đếm trong changelog):
// (a) hồ sơ cha XOÁ MỀM — row còn nguyên, khôi phục được, màn "Đã xoá" phải
// đọc được lịch sử ký;
// (b) chính opinion đã xoá mềm trên hồ sơ CÒN SỐNG — bản ghi KIỂM TOÁN của
// tài liệu đang hiệu lực.
// Nay chặn admin y như phiếu sống, và NÓI RÕ hồ sơ nào đang giữ chữ ký để họ
// xử lý được (khôi phục, hoặc xoá hẳn) thay vì đoán.
var retained = await ListRetainedOpinionsAsync(guardedLevelIds, ct);
if (retained.Count > 0)
{
var goneFirst = toRemove.FirstOrDefault(l => retained.ContainsKey(l.Id));
var target = goneFirst ?? approverChanged.FirstOrDefault(l => retained.ContainsKey(l.Id));
if (target is not null)
{
var step = dbSteps.First(s => s.Id == target.ApprovalWorkflowStepId);
var rows = retained[target.Id];
var verb = goneFirst is not null ? "không bỏ được người này" : "không đổi được người duyệt";
throw new ConflictException(
$"Bước {step.Order} ({step.Name}) — Cấp {target.Order}: {verb} vì còn {rows.Count} chữ ký/ý kiến đang LƯU, thuộc: {DescribeRetained(rows)}. " +
$"Chữ ký của hồ sơ đã xoá vẫn là LỊCH SỬ DUYỆT (màn \"Đã xoá\" còn đọc được) nên hệ thống KHÔNG tự xoá cứng. " +
$"Hãy khôi phục hồ sơ rồi xử lý, hoặc xoá HẲN hồ sơ đó khỏi hệ thống trước. {NewVersionHint}");
}
}
}
// ================= 🔴 F1 (reviewer đợt 1) — RÀO GIAO DỊCH =================
// TRƯỚC bản vá: `PurgeOrphanOpinionsAsync` dùng `ExecuteDeleteAsync`, mà
// ExecuteDelete phát DELETE thẳng xuống SQL và **auto-commit NGAY** khi không có
// transaction ambient. `SaveChangesAsync` thì mãi cuối method. Giữa hai điểm đó có
// ÍT NHẤT 4 nguồn ném ĐỘC LẬP: `ResolveUserNamesAsync` (round-trip DB) ·
// `db.ApprovalWorkflowLevels.Remove` (severed-association nếu detach bắt hụt) ·
// truy vấn phiếu bị ảnh hưởng (round-trip DB) · chính `SaveChanges` (FK/unique/
// concurrency/timeout/huỷ-token). Ném ở bất kỳ chỗ nào ⇒ mọi thay đổi sau đó
// rollback, NHƯNG chữ ký đã bị xoá CỨNG thì không quay lại — admin nhận 409/500 và
// tưởng "không có gì xảy ra". Đúng class lỗi SAI IM LẶNG mà spec S155 đang đi chữa.
//
// Mở transaction TẠI ĐÂY — ngay trước thao tác KHÔNG-HOÀN-TÁC đầu tiên. Mọi guard
// phía trên chỉ ĐỌC (AsNoTracking) và ném trước khi chạm dữ liệu, nên không cần
// nằm trong rào; mở sớm hơn chỉ kéo dài thời gian giữ khoá trên SQL Server.
// KHÔNG dùng IsolationLevel.Serializable như các code-generator (`ContractCodeGenerator.cs:37`):
// ở đó Serializable để chống ĐUA cấp-số; ở đây yêu cầu là NGUYÊN TỬ (all-or-nothing),
// không phải chống đua — siết isolation chỉ làm màn quản trị dễ deadlock.
// `await using` + dispose = rollback nếu chưa Commit (khuôn nhà: `LeaveOtApprovalFeatures.cs:34-44`).
var context = (DbContext)db;
await using var tx = await context.Database.BeginTransactionAsync(ct);
// ================= Dọn rác chữ ký MỒ CÔI trước khi xoá dòng =================
// 🔴 F2 (reviewer đợt 1) đã THU HẸP nhãn "mồ côi": chỉ còn opinion mà hồ sơ cha
// KHÔNG CÒN ROW trong DB. Hồ sơ xoá MỀM giữ nguyên chữ ký và bị guard chặn ở trên.
// 🔴 LevelOpinion là AuditableEntity ⇒ Remove() chỉ xoá MỀM (interceptor đổi
// Deleted→Modified) ⇒ row ở lại ⇒ FK vẫn chặn. Phải ExecuteDelete (SQL thẳng).
var purgedOrphans = 0;
if (toRemove.Count > 0)
purgedOrphans = await PurgeOrphanOpinionsAsync(toRemove.Select(l => l.Id).ToList(), ct);
// ================= ÁP THAY ĐỔI (chỉ trường hạng AN TOÀN) =================
var userNames = await ResolveUserNamesAsync(
toUpdate.Select(t => t.Db.ApproverUserId)
.Concat(toUpdate.Select(t => t.Req.ApproverUserId))
.Concat(toAdd.Select(t => t.Req.ApproverUserId))
.Concat(toRemove.Select(l => l.ApproverUserId))
.Distinct().ToList(), ct);
string NameOf(Guid id) => userNames.GetValueOrDefault(id) ?? id.ToString();
var changes = new List<string>();
if (def.Name != request.Name) changes.Add($"Đổi tên quy trình: \"{def.Name}\" → \"{request.Name}\"");
def.Name = request.Name;
def.Description = request.Description;
foreach (var reqStep in request.Steps)
{
var dbStep = dbSteps.First(s => s.Id == reqStep.Id);
if (dbStep.Name != reqStep.Name) changes.Add($"Bước {dbStep.Order}: đổi tên \"{dbStep.Name}\" → \"{reqStep.Name}\"");
if (dbStep.DepartmentId != reqStep.DepartmentId) changes.Add($"Bước {dbStep.Order} ({reqStep.Name}): đổi phòng ban phụ trách");
dbStep.Name = reqStep.Name;
dbStep.DepartmentId = reqStep.DepartmentId;
}
foreach (var (dbLevel, reqLevel, step) in toUpdate)
{
if (dbLevel.ApproverUserId != reqLevel.ApproverUserId)
changes.Add($"Bước {step.Order} Cấp {dbLevel.Order}: đổi NV duyệt {NameOf(dbLevel.ApproverUserId)} → {NameOf(reqLevel.ApproverUserId)}");
var flagDiff = DescribeFlagChanges(dbLevel, reqLevel);
if (flagDiff.Count > 0)
changes.Add($"Bước {step.Order} Cấp {dbLevel.Order} — {NameOf(reqLevel.ApproverUserId)}: {string.Join("; ", flagDiff)}");
dbLevel.Name = reqLevel.Name;
dbLevel.ApproverUserId = reqLevel.ApproverUserId;
dbLevel.AllowReturnOneLevel = reqLevel.AllowReturnOneLevel;
dbLevel.AllowReturnOneStep = reqLevel.AllowReturnOneStep;
dbLevel.AllowReturnToAssignee = reqLevel.AllowReturnToAssignee;
dbLevel.AllowReturnToDrafter = reqLevel.AllowReturnToDrafter;
dbLevel.AllowApproverEditDetails = reqLevel.AllowApproverEditDetails;
dbLevel.AllowApproverEditBudget = reqLevel.AllowApproverEditBudget;
dbLevel.AllowApproverDelete = reqLevel.AllowApproverDelete;
// ❌ KHÔNG gán AllowApproverFinalize / AllowApproverSkipToFinal — echo-only.
}
foreach (var (step, reqLevel) in toAdd)
{
var newLevel = new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = reqLevel.Order,
Name = reqLevel.Name,
ApproverUserId = reqLevel.ApproverUserId,
AllowReturnOneLevel = reqLevel.AllowReturnOneLevel,
AllowReturnOneStep = reqLevel.AllowReturnOneStep,
AllowReturnToAssignee = reqLevel.AllowReturnToAssignee,
AllowReturnToDrafter = reqLevel.AllowReturnToDrafter,
AllowApproverEditDetails = reqLevel.AllowApproverEditDetails,
AllowApproverEditBudget = reqLevel.AllowApproverEditBudget,
AllowApproverDelete = reqLevel.AllowApproverDelete,
AllowApproverSkipToFinal = false, // đổi-đường-đi: dòng mới luôn tắt
AllowApproverFinalize = false,
};
// 🔴 PHẢI đăng ký TƯỜNG MINH qua DbSet.Add — KHÔNG được chỉ `step.Levels.Add(...)`.
// `BaseEntity.Id = Guid.NewGuid()` (BaseEntity.cs:5) ⇒ khoá LUÔN khác default.
// Khi DetectChanges gặp một entity lạ trong collection của cha ĐANG ĐƯỢC THEO DÕI,
// EF quyết Added/Modified bằng heuristic "khoá đã set ⇒ coi như đã tồn tại" ⇒ nó
// gán state **Modified** ⇒ phát UPDATE cho dòng CHƯA CÓ ⇒ 0 row ⇒
// DbUpdateConcurrencyException. DbSet.Add ép state Added, không qua heuristic.
// (POST tạo mới không dính vì Add từ GỐC — cả đồ thị bị ép Added.)
db.ApprovalWorkflowLevels.Add(newLevel);
step.Levels.Add(newLevel); // giữ nav in-memory nhất quán cho phần đọc phía sau
changes.Add($"Bước {step.Order} Cấp {reqLevel.Order}: THÊM NV duyệt {NameOf(reqLevel.ApproverUserId)}");
}
// 🔴 Gỡ THEO DÕI mọi bản sao chữ ký còn nằm trong change-tracker và trỏ vào các dòng
// Cấp sắp xoá — BẮT BUỘC làm TRƯỚC `Remove` bên dưới. Xem `DetachTrackedOpinionsOn`.
if (toRemove.Count > 0)
DetachTrackedOpinionsOn(db, toRemove.Select(l => l.Id).ToHashSet());
foreach (var gone in toRemove)
{
var step = dbSteps.First(s => s.Id == gone.ApprovalWorkflowStepId);
changes.Add($"Bước {step.Order} Cấp {gone.Order}: BỎ NV duyệt {NameOf(gone.ApproverUserId)}");
db.ApprovalWorkflowLevels.Remove(gone); // BaseEntity → xoá CỨNG (không dính soft-delete)
}
if (purgedOrphans > 0)
changes.Add($"Dọn {purgedOrphans} chữ ký mồ côi (hồ sơ mang chữ ký đã KHÔNG CÒN trong hệ thống) để gỡ được người khỏi Cấp");
// ================= F-1c — GHI VẾT =================
// (a) Chạm entity cha để AuditingInterceptor đóng dấu UpdatedAt/UpdatedBy kể
// cả khi chỉ có dòng Cấp đổi (ai + lúc nào).
def.UpdatedAt = dateTime.UtcNow;
// (b) LÝ DO + NỘI DUNG đổi → changelog của CHÍNH những phiếu đang chịu ảnh
// hưởng (phiếu chưa kết thúc, đang pin quy trình này). Đối xứng với yêu
// cầu ghi changelog khi xoá phiếu (§B4): người đọc lịch sử phiếu thấy
// đượ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) hiện CHỈ có UpdatedAt/
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
or ApprovalWorkflowApplicableType.DuyetNccPhuongAn)
{
var affected = await db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.ApprovalWorkflowId == def.Id
&& p.Phase != PurchaseEvaluationPhase.DaDuyet
&& p.Phase != PurchaseEvaluationPhase.TuChoi)
.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
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = SolutionErp.Domain.Contracts.ChangelogAction.Update,
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)",
ContextNote = note,
});
}
}
await db.SaveChangesAsync(ct);
// 🔴 F1 — CHỐT rào: tới đây purge + gỡ dòng Cấp + changelog mới cùng nhau có hiệu lực.
// Ném ở BẤT KỲ đâu phía trên ⇒ dispose của `tx` rollback TRỌN, kể cả các câu DELETE
// của `PurgeOrphanOpinionsAsync` (ExecuteDelete tự enlist vào transaction đang mở).
await tx.CommitAsync(ct);
}
// Đếm chữ ký CÒN HIỆU LỰC trên các dòng Cấp — quét CẢ 7 bảng LevelOpinion đang
// FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số). Chỉ soi PE là
// vẫn nổ FK 547 khi quy trình thuộc module khác (§F.3 blast radius ≥6 module).
// 🔴 `!p.IsDeleted` viết TƯỜNG MINH: chỉ PE + Contract có global query filter,
// 5 bảng Office KHÔNG có ⇒ dựa filter là đúng PE mà sai 5 module kia.
private async Task<Dictionary<Guid, int>> CountLiveOpinionsAsync(List<Guid> levelIds, CancellationToken ct)
{
var acc = levelIds.ToDictionary(id => id, _ => 0);
void Merge(List<LevelOpinionCount> rows)
{
foreach (var r in rows) acc[r.LevelId] = acc.GetValueOrDefault(r.LevelId) + r.Count;
}
Merge(await db.PurchaseEvaluationLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.PurchaseEvaluations.Any(p => p.Id == o.PurchaseEvaluationId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.ContractLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.Contracts.Any(p => p.Id == o.ContractId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.ProposalLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.Proposals.Any(p => p.Id == o.ProposalId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.LeaveRequestLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.LeaveRequests.Any(p => p.Id == o.LeaveRequestId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.OtRequestLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.OtRequests.Any(p => p.Id == o.OtRequestId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.TravelRequestLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.TravelRequests.Any(p => p.Id == o.TravelRequestId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
Merge(await db.VehicleBookingLevelOpinions.AsNoTracking()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId) && !o.IsDeleted
&& db.VehicleBookings.Any(p => p.Id == o.VehicleBookingId && !p.IsDeleted))
.GroupBy(o => o.ApprovalWorkflowLevelId)
.Select(g => new LevelOpinionCount(g.Key, g.Count())).ToListAsync(ct));
return acc;
}
// Chữ ký CÒN LƯU trên các dòng Cấp nhưng KHÔNG thuộc diện "sống" của
// `CountLiveOpinionsAsync` — tức hồ sơ cha VẪN CÒN ROW, mà (cha xoá mềm HOẶC ý kiến
// tự nó xoá mềm). Đây chính là tập mà bản trước đem đi xoá CỨNG (F2 của reviewer).
//
// 🔴 `IgnoreQueryFilters()` LÀ BẮT BUỘC, không phải trang trí: PE và Contract có global
// filter `!IsDeleted` (`PurchaseEvaluationConfiguration.cs:84`). Không tắt filter thì hồ
// sơ xoá mềm biến mất khỏi phép JOIN ⇒ ta lại kết luận "mồ côi" ⇒ đúng cái sai đang chữa.
// (5 bảng Văn phòng số KHÔNG có filter — gọi vẫn vô hại, giữ 7 khối cùng một hình dạng.)
private async Task<Dictionary<Guid, List<RetainedOpinionRef>>> ListRetainedOpinionsAsync(
List<Guid> levelIds, CancellationToken ct)
{
var rows = new List<RetainedOpinionRef>();
rows.AddRange(await db.PurchaseEvaluationLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.PurchaseEvaluations, o => o.PurchaseEvaluationId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Phiếu duyệt NCC", x.p.MaPhieu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.ContractLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.Contracts, o => o.ContractId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Hợp đồng", x.p.MaHopDong, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.ProposalLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.Proposals, o => o.ProposalId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đề xuất", x.p.MaDeXuat, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.LeaveRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.LeaveRequests, o => o.LeaveRequestId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn nghỉ phép", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.OtRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.OtRequests, o => o.OtRequestId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn tăng ca", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.TravelRequestLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.TravelRequests, o => o.TravelRequestId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn công tác", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
rows.AddRange(await db.VehicleBookingLevelOpinions.AsNoTracking().IgnoreQueryFilters()
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId))
.Join(db.VehicleBookings, o => o.VehicleBookingId, p => p.Id, (o, p) => new { o, p })
.Where(x => x.o.IsDeleted || x.p.IsDeleted)
.Select(x => new RetainedOpinionRef(x.o.ApprovalWorkflowLevelId, "Đơn xe", x.p.MaDonTu, x.p.IsDeleted))
.ToListAsync(ct));
return rows.GroupBy(r => r.LevelId).ToDictionary(g => g.Key, g => g.ToList());
}
private static string DescribeRetained(List<RetainedOpinionRef> rows)
{
var head = string.Join("; ", rows.Take(3).Select(r =>
$"{r.Module} {(string.IsNullOrWhiteSpace(r.Code) ? "(chưa )" : r.Code)}" +
(r.ParentDeleted ? " — đã xoá" : " — ý kiến đã thu hồi")));
return rows.Count > 3 ? $"{head}; … và {rows.Count - 3} hồ sơ khác" : head;
}
// Xoá CỨNG những chữ ký THẬT SỰ MỒ CÔI — hồ sơ cha KHÔNG CÒN ROW trong DB (bị xoá cứng
// ngoài luồng ứng dụng: sửa tay, nhập liệu, hoặc lúc FK từng bị tắt). ExecuteDelete đi
// thẳng SQL, KHÔNG qua change-tracker nên không bị AuditingInterceptor đổi thành xoá mềm.
//
// 🔴 F2 (reviewer đợt 1) — ĐÃ THU HẸP hai vế, đây là chỗ dễ mất dữ liệu nhất của lệnh:
// • BỎ vế `o.IsDeleted` — opinion tự nó xoá mềm trên hồ sơ CÒN SỐNG là bản ghi KIỂM
// TOÁN của tài liệu đang hiệu lực, không phải rác.
// • `!Any(p => p.Id == … && !p.IsDeleted)` → `!IgnoreQueryFilters().Any(p => p.Id == …)`
// — hồ sơ XOÁ MỀM vẫn CÒN ROW, vẫn khôi phục và xem được ⇒ chữ ký của nó KHÔNG mồ côi.
// Hai ca đó nay bị guard tầng 2 (`ListRetainedOpinionsAsync`) CHẶN admin bằng 409, kèm
// tên hồ sơ đang giữ chữ ký — thay vì xoá lén rồi chỉ để lại một con số đếm.
//
// ⇒ BẤT BIẾN sau bản vá: tới được đây nghĩa là mọi dòng còn trỏ vào `levelIds` đều có cha
// đã biến mất khỏi DB. Xoá chúng là đường DUY NHẤT để FK Restrict cho gỡ dòng Cấp, và
// không màn hình nào còn đọc được chúng nữa. Điều kiện dưới đây vẫn viết TƯỜNG MINH
// (không dựa vào việc guard đã chạy) để purge tự phòng vệ nếu hai bên có ngày lệch nhau.
private async Task<int> PurgeOrphanOpinionsAsync(List<Guid> levelIds, CancellationToken ct)
{
var n = 0;
n += await db.PurchaseEvaluationLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.PurchaseEvaluations.IgnoreQueryFilters().Any(p => p.Id == o.PurchaseEvaluationId))
.ExecuteDeleteAsync(ct);
n += await db.ContractLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.Contracts.IgnoreQueryFilters().Any(p => p.Id == o.ContractId))
.ExecuteDeleteAsync(ct);
n += await db.ProposalLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.Proposals.IgnoreQueryFilters().Any(p => p.Id == o.ProposalId))
.ExecuteDeleteAsync(ct);
n += await db.LeaveRequestLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.LeaveRequests.IgnoreQueryFilters().Any(p => p.Id == o.LeaveRequestId))
.ExecuteDeleteAsync(ct);
n += await db.OtRequestLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.OtRequests.IgnoreQueryFilters().Any(p => p.Id == o.OtRequestId))
.ExecuteDeleteAsync(ct);
n += await db.TravelRequestLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.TravelRequests.IgnoreQueryFilters().Any(p => p.Id == o.TravelRequestId))
.ExecuteDeleteAsync(ct);
n += await db.VehicleBookingLevelOpinions
.Where(o => levelIds.Contains(o.ApprovalWorkflowLevelId)
&& !db.VehicleBookings.IgnoreQueryFilters().Any(p => p.Id == o.VehicleBookingId))
.ExecuteDeleteAsync(ct);
return n;
}
// Gỡ theo dõi (detach) mọi entity chữ ký đang nằm trong change-tracker và trỏ vào các
// dòng Cấp sắp bị XOÁ CỨNG.
//
// VÌ SAO CẦN: `PurgeOrphanOpinionsAsync` dùng `ExecuteDelete` — đi thẳng SQL, KHÔNG cập
// nhật change-tracker (giới hạn có ghi trong tài liệu EF). Nếu context đang giữ một bản
// sao của chính những chữ ký vừa bị xoá (hoặc bất kỳ chữ ký nào trỏ vào dòng Cấp sắp gỡ),
// thì `db.ApprovalWorkflowLevels.Remove(level)` sẽ kích cascade-check CLIENT-SIDE
// (CascadeDeleteTiming.Immediate = mặc định): EF thấy dependent còn tracked + FK
// `Restrict` + cột không nullable ⇒ ném InvalidOperationException "association ... has
// been severed" NGAY TẠI `Remove()`, chưa kịp chạm DB ⇒ 500 thay vì thao tác hợp lệ.
//
// AN TOÀN: tới thời điểm gọi, guard F-5 đã đọc DB (AsNoTracking) và xác nhận các dòng
// này có 0 chữ ký CÒN HIỆU LỰC ⇒ mọi bản sao còn tracked đều là rác (thuộc phiếu đã xoá,
// hoặc chính nó đã bị purge khỏi DB) ⇒ gỡ theo dõi là làm tracker khớp lại với DB.
// Quét theo TÊN CỘT nên phủ đủ cả 7 bảng chữ ký (PE + HĐ + 5 module Văn phòng số) mà
// không phải liệt kê tay từng DbSet.
private static void DetachTrackedOpinionsOn(IApplicationDbContext db, HashSet<Guid> levelIds)
{
if (levelIds.Count == 0) return;
const string fkName = nameof(PurchaseEvaluationLevelOpinion.ApprovalWorkflowLevelId);
var stale = ((DbContext)db).ChangeTracker.Entries()
.Where(e => e.State != EntityState.Detached
&& e.Metadata.FindProperty(fkName) is not null
&& e.Property(fkName).CurrentValue is Guid levelId
&& levelIds.Contains(levelId))
.ToList();
foreach (var entry in stale)
entry.State = EntityState.Detached;
}
private async Task<Dictionary<Guid, string>> ResolveUserNamesAsync(List<Guid> userIds, CancellationToken ct)
{
if (userIds.Count == 0) return new Dictionary<Guid, string>();
return await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName ?? u.Email ?? u.Id.ToString(), ct);
}
private static List<string> DescribeFlagChanges(ApprovalWorkflowLevel db, UpdateAwLevelInput req)
{
var list = new List<string>();
void Cmp(string label, bool before, bool after)
{
if (before != after) list.Add($"{label} {(after ? "BẬT" : "TẮT")}");
}
Cmp("Trả lại 1 Cấp", db.AllowReturnOneLevel, req.AllowReturnOneLevel);
Cmp("Trả lại 1 Bước", db.AllowReturnOneStep, req.AllowReturnOneStep);
Cmp("Trả lại NV đã ký", db.AllowReturnToAssignee, req.AllowReturnToAssignee);
Cmp("Trả lại người soạn", db.AllowReturnToDrafter, req.AllowReturnToDrafter);
Cmp("Sửa Hạng mục/NCC/Báo giá", db.AllowApproverEditDetails, req.AllowApproverEditDetails);
Cmp("Sửa ngân sách", db.AllowApproverEditBudget, req.AllowApproverEditBudget);
Cmp("Xoá phiếu khi đang duyệt", db.AllowApproverDelete, req.AllowApproverDelete);
return list;
}
private static string FormatMoney(decimal? v) => v is decimal d ? d.ToString("#,##0.##") : "(không đặt)";
}
// ========== PATCH user-selectable toggle (Mig 25 — admin pin/unpin cho user pick) ========== // ========== PATCH user-selectable toggle (Mig 25 — admin pin/unpin cho user pick) ==========
// Independent với IsActive: cho phép multiple version cùng selectable. Default // Independent với IsActive: cho phép multiple version cùng selectable. Default
// version mới IsUserSelectable=true (mirror IsActive default), admin có thể // version mới IsUserSelectable=true (mirror IsActive default), admin có thể

View File

@ -78,8 +78,20 @@ public class ApprovalWorkflowStep : BaseEntity
public List<ApprovalWorkflowLevel> Levels { get; set; } = new(); public List<ApprovalWorkflowLevel> Levels { get; set; } = new();
} }
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. Approver = ApproverUserId // Cấp = tập các row CÙNG Order trong 1 Bước. MỖI ROW = 1 NV cụ thể
// chính xác (KHÔNG OR-of-many). Sequential trong cùng bước: cấp 1 → cấp 2 → ... // (ApproverUserId), nhưng 1 Cấp có thể có N row ⇒ N người duyệt **OR-of-N**:
// bất kỳ ai trong Cấp duyệt là Cấp đó xong. Sequential giữa các Cấp trong cùng
// bước: cấp 1 → cấp 2 → ...
//
// [F-10 S155 2026-07-27] Comment cũ ghi "Cấp = 1 NV cụ thể ... (KHÔNG OR-of-many)"
// — SAI, ngược với mã lẫn tài liệu. Bằng chứng: service group Levels theo Order
// rồi cho phép BẤT KỲ ai trong nhóm duyệt
// (`PurchaseEvaluationWorkflowService.cs:689` `GroupBy(l => l.Order)` + `:702`
// `allowedUserIds.Contains(actorUserId)`); validator chỉ cấm TRÙNG cặp
// {Order, ApproverUserId} chứ không cấm nhiều row cùng Order
// (`ApprovalWorkflowV2AdminFeatures.cs:309-314`); CLAUDE.md + skill
// contract-workflow đều ghi "N NV cụ thể (OR-of-N cùng cấp)". Ảnh UAT prod:
// Cấp 2 Phòng Cung ứng có 3 người.
public class ApprovalWorkflowLevel : BaseEntity public class ApprovalWorkflowLevel : BaseEntity
{ {
public Guid ApprovalWorkflowStepId { get; set; } public Guid ApprovalWorkflowStepId { get; set; }
@ -141,5 +153,20 @@ public class ApprovalWorkflowLevel : BaseEntity
/// nhất, KHÔNG threshold/role check). Mirror F2/F3/F4 admin opt-in per-slot pattern. /// nhất, KHÔNG threshold/role check). Mirror F2/F3/F4 admin opt-in per-slot pattern.
public bool AllowApproverFinalize { get; set; } public bool AllowApproverFinalize { get; set; }
/// F6 (Mig 68 2026-07-27 — S155, UAT Tra Sol/anh Kiệt) — Cho phép NV slot này,
/// khi đang tới lượt duyệt của mình, XÓA MỀM phiếu đang treo ở ChoDuyet. Sinh ra
/// vì phiếu bấm sai gói thầu đang **ăn lũy kế** ngân sách hạng mục sai mà không
/// có cửa nào dừng: "Trả lại" chỉ DỜI số sang dòng khác chứ không triệt tiêu
/// (`PeBudgetAccumulator.cs:104-126`), còn xóa thì allow-list chỉ nhận
/// {DangSoanThao, TuChoi} (`PurchaseEvaluationFeatures.cs:1404-1406`).
/// Default false (admin opt-in per slot). Đọc **PER-ROW** `matchingLevel.
/// AllowApproverDelete` (khuôn F5 `PurchaseEvaluationWorkflowService.cs:859`),
/// KHÔNG đọc per-Cấp `g.Any(...)` — 1 Cấp có N người (OR-of-N), đọc `Any` sẽ
/// phát quyền huỷ chứng-từ cho CẢ Cấp thay vì đúng 1 người admin chỉ đích danh
/// (owner chốt 2026-07-27). Mirror F2/F3/F4/F5 admin opt-in per-slot pattern.
/// ⚠️ Cờ này chỉ là 1 trong 3 rào: policy `PurchaseEvaluations.Delete` ∧ đúng
/// lượt ∧ F6 — xem hạng mục B spec S155 (đợt 2).
public bool AllowApproverDelete { get; set; }
public ApprovalWorkflowStep? Step { get; set; } public ApprovalWorkflowStep? Step { get; set; }
} }

View File

@ -98,5 +98,11 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
// duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false // duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false
// (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay. // (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay.
e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false); e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false);
// Mig 68 (S155 2026-07-27 — UAT Tra Sol) — F6 per-NV: cho phép NV slot này
// xóa MỀM phiếu đang treo ở ChoDuyet khi tới lượt mình duyệt (phiếu bấm sai
// gói thầu đang ăn lũy kế). Default false (admin opt-in per slot). Đọc
// per-ROW chứ KHÔNG per-Cấp — 1 Cấp có N người (OR-of-N).
e.Property(x => x.AllowApproverDelete).HasDefaultValue(false);
} }
} }

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeAllowApproverDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowApproverDelete",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowApproverDelete",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -192,6 +192,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<bool>("AllowApproverDelete")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverEditBudget") b.Property<bool>("AllowApproverEditBudget")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("bit") .HasColumnType("bit")

View File

@ -702,7 +702,18 @@ public class PurchaseEvaluationWorkflowService(
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet(); var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value)) if (!allowedUserIds.Contains(actorUserId.Value))
{ {
var names = string.Join(", ", allowedUserIds); // [S155 F-6] Trước đây in THẲNG GUID (`string.Join(", ", allowedUserIds)`)
// dù biến đã tên là `names` — người dùng cuối đọc được một dãy GUID và
// không hiểu phải nhờ AI duyệt. Nay resolve sang tên thật; fallback về
// GUID nếu không tra được để thông điệp không bao giờ rỗng.
var allowedIdList = allowedUserIds.ToList();
var resolved = await userManager.Users.AsNoTracking()
.Where(u => allowedIdList.Contains(u.Id))
.Select(u => u.FullName ?? u.Email)
.ToListAsync(ct);
var names = resolved.Count > 0
? string.Join(", ", resolved)
: string.Join(", ", allowedIdList);
throw new ForbiddenException( throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names})."); $"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
} }

View File

@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using SolutionErp.Api.Controllers; using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
namespace SolutionErp.Infrastructure.Tests.Api; namespace SolutionErp.Infrastructure.Tests.Api;
@ -88,6 +89,59 @@ public class AuthorizePolicyRegressionTests
"PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin)."); "PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin).");
} }
// ===================================================================
// F-1b (S155 2026-07-27) — PUT sửa quy trình TẠI CHỖ.
//
// 🔴 Nghiêm trọng hơn một endpoint ghi thường, vì CẢ HAI thao tác mà lệnh này
// cho phép đều là hành vi CẤP QUYỀN:
// (i) "thêm người vào Cấp" ⇒ tự thêm mình vào Cấp đang chờ ⇒ lọt guard
// match-approver ⇒ DUYỆT ĐƯỢC PHIẾU NGƯỜI KHÁC;
// (ii) "bật cờ Allow*" ⇒ gồm chính F6 AllowApproverDelete ⇒ TỰ CẤP
// QUYỀN XOÁ phiếu đang duyệt.
// Class-level chỉ [Authorize] trần (cố ý, gotcha #44) nên nó KHÔNG tự bảo vệ:
// thiếu đúng 1 dòng attribute = leo thang đặc quyền cho MỌI tài khoản đăng nhập.
// ===================================================================
[Fact]
public void ApprovalWorkflowsV2Controller_Update_PUT_RequiresWorkflowsCreatePolicy()
{
var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Update));
attr.Should().NotBeNull(
"PUT sửa-tại-chỗ phải có [Authorize(Policy = ...)] — class-level [Authorize] trần cho lọt mọi user đăng nhập");
attr!.Policy.Should().Be("Workflows.Create",
"khớp cả 3 verb ghi còn lại (POST/PATCH/DELETE) của chính controller này — sửa quy trình = quyền admin Designer.");
}
// Bẫy đi kèm (spec §B3): `Program.cs` CHỈ đăng ký policy `{menu}.{action}` cho
// menu key nằm trong `MenuKeys.All`. Gắn một policy KHÔNG được đăng ký thì
// ASP.NET ném lúc chạy — test attribute ở trên vẫn xanh mà endpoint vẫn hỏng.
// ⇒ chốt luôn rằng tên policy đang dùng là tên CÓ THẬT trong bộ sinh policy.
[Fact]
public void ApprovalWorkflowsV2Controller_WriteVerbs_PolicyName_IsActuallyRegisterable()
{
var writeActions = new[]
{
nameof(ApprovalWorkflowsV2Controller.Create),
nameof(ApprovalWorkflowsV2Controller.Update),
nameof(ApprovalWorkflowsV2Controller.SetUserSelectable),
nameof(ApprovalWorkflowsV2Controller.Delete),
};
foreach (var action in writeActions)
{
var policy = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), action)?.Policy;
policy.Should().NotBeNullOrEmpty($"action {action} phải có policy");
var parts = policy!.Split('.');
parts.Should().HaveCount(2, $"policy {policy} phải đúng dạng {{menuKey}}.{{action}}");
MenuKeys.All.Should().Contain(parts[0],
$"policy \"{policy}\" của {action} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không sinh policy đó ⇒ 500 lúc chạy (bẫy Pe_* trong spec S155 §B3)");
MenuKeys.Actions.Should().Contain(parts[1],
$"policy \"{policy}\" của {action} dùng action không có trong MenuKeys.Actions");
}
}
// =================================================================== // ===================================================================
// Coverage gap #3 (MAJOR — S35/S36 backlog, closed S45 2026-06-01). // Coverage gap #3 (MAJOR — S35/S36 backlog, closed S45 2026-06-01).
// gotcha #44 regression cho 2 controller HRM bị MISS (chỉ ApprovalWorkflowsV2 có trước đó). // gotcha #44 regression cho 2 controller HRM bị MISS (chỉ ApprovalWorkflowsV2 có trước đó).

View File

@ -0,0 +1,972 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.ApprovalWorkflowsV2;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
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.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService
namespace SolutionErp.Infrastructure.Tests.Application;
// ============================================================================
// S155 hạng mục F (2026-07-27) — lệnh UPDATE quy trình duyệt TẠI CHỖ.
// Test-before-merge: đụng CẢ security (tự thêm mình vào Cấp = duyệt phiếu người
// khác) LẪN financial (phiếu đang chạy đổi cấu hình giữa chừng) ⇒ hạng critical
// theo docs/rules.md §7.
//
// Đối tượng đo: `UpdateAwDefinitionCommandHandler`
// (src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:501-930)
//
// 🔴 MỆNH ĐỀ TRUNG TÂM (spec §F.0) mà F-12/F-15 tồn tại để chứng minh:
// POST tạo version mới sinh **Id MỚI**, phiếu đang treo pin **Id CŨ** và đọc
// workflow **SỐNG** (`PurchaseEvaluationWorkflowService.cs:671-675` — chỉ
// `w.Id == awId`, không snapshot) ⇒ cấu hình tick hôm nay KHÔNG BAO GIỜ tới
// được phiếu cần cứu. Chỉ sửa-TẠI-CHỖ (giữ nguyên Id) mới tới.
// ⇒ mọi assert "phiếu thấy thay đổi" ở đây đều đi qua `ResolveLikeRunningPeAsync`
// — bản sao Y HỆT đường resolve của service, không phải một query tiện tay.
//
// Ranh giới ĐỢT: file này KHÔNG test hành vi XOÁ phiếu khi đang duyệt (nút +
// endpoint xoá-khi-duyệt = ĐỢT 2, chưa tồn tại trên đĩa). F-15 chỉ đo tới mức
// "cờ F6 đọc được từ phía phiếu đang treo".
//
// Harness: IdentityFixture (BẮT BUỘC — `ApprovalWorkflowLevel.ApproverUserId` có
// FK Restrict sang User, `ApprovalWorkflowConfiguration.cs:70-74`, nên approver
// phải là user THẬT chứ không phải Guid ngẫu nhiên).
// ============================================================================
public class UpdateAwDefinitionTests
{
private sealed record Harness(
IdentityFixture Fix,
TestApplicationDbContext Db,
UserManager<User> Um,
FixedDateTime Clock);
private static Harness NewHarness()
{
var fix = new IdentityFixture();
return new Harness(
fix,
fix.Services.GetRequiredService<TestApplicationDbContext>(),
fix.Services.GetRequiredService<UserManager<User>>(),
new FixedDateTime(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc)));
}
private static UpdateAwDefinitionCommandHandler NewHandler(Harness h, User actor)
=> new(h.Db, new TestCurrentUser(actor.Id, actor.FullName, actor.Email), h.Clock, h.Um);
// stepSpecs[s] = các dòng Cấp của Bước (Order s+1). Mỗi phần tử = (LevelOrder, ApproverUserId).
// N dòng cùng LevelOrder = 1 Cấp có N người (OR-of-N) — đúng hình dạng prod.
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
TestApplicationDbContext db,
ApprovalWorkflowApplicableType type,
(int LevelOrder, Guid User)[][] stepSpecs,
decimal? ceoThreshold = null,
string code = "QT-UPD-V2")
{
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = type,
Name = "QT test sửa tại chỗ",
IsActive = true,
IsUserSelectable = true,
CeoApprovalThreshold = ceoThreshold,
ActivatedAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
};
for (int s = 0; s < stepSpecs.Length; s++)
{
var step = new ApprovalWorkflowStep
{
ApprovalWorkflowId = wf.Id,
Order = s + 1,
Name = $"Bước {s + 1}",
};
foreach (var (levelOrder, user) in stepSpecs[s])
{
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = levelOrder,
Name = $"Cấp {levelOrder}",
ApproverUserId = user,
});
}
wf.Steps.Add(step);
}
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
Guid workflowId,
Guid drafterId,
string code,
int stepIdx = 0,
int levelOrder = 1,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
bool isDeleted = false)
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = phase,
MaPhieu = code,
TenGoiThau = "Gói thầu test sửa quy trình tại chỗ",
ProjectId = Guid.NewGuid(),
DrafterUserId = drafterId,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = phase == PurchaseEvaluationPhase.ChoDuyet ? stepIdx : null,
CurrentApprovalLevelOrder = phase == PurchaseEvaluationPhase.ChoDuyet ? levelOrder : null,
IsDeleted = isDeleted,
DeletedAt = isDeleted ? new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc) : null,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// 🔴 BẢN SAO Y HỆT đường resolve của phiếu đang chạy
// (`PurchaseEvaluationWorkflowService.cs:671-675`): chỉ lọc theo Id đã pin,
// Include Steps/Levels đã sắp xếp, AsNoTracking. Dùng nó (chứ không phải một
// query tiện tay) để assert "phiếu THẤY thay đổi" mới là bằng chứng thật.
private static Task<ApprovalWorkflow> ResolveLikeRunningPeAsync(TestApplicationDbContext db, Guid pinnedId)
=> db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstAsync(w => w.Id == pinnedId, CancellationToken.None);
// Lệnh Update "trung thực": echo NGUYÊN TRẠNG mọi thứ đang lưu. Mọi test chỉ
// đổi ĐÚNG MỘT biến trên nền này (kỹ thuật cặp cùng-ruột-khác-1-biến) — nếu
// test đỏ thì biết chắc do biến đó chứ không do payload lệch chỗ khác.
private static UpdateAwDefinitionCommand BuildEchoCommand(ApprovalWorkflow wf, string? reason = null)
=> new(
Id: wf.Id,
Name: wf.Name,
Description: wf.Description,
Steps: wf.Steps.OrderBy(s => s.Order).Select(s => new UpdateAwStepInput(
Id: s.Id,
Order: s.Order,
Name: s.Name,
DepartmentId: s.DepartmentId,
Levels: s.Levels.OrderBy(l => l.Order).Select(EchoLevel).ToList())).ToList(),
Reason: reason,
Code: null,
ApplicableType: null,
CeoApprovalThreshold: wf.CeoApprovalThreshold);
private static UpdateAwLevelInput EchoLevel(ApprovalWorkflowLevel l) => new(
Id: l.Id,
Order: l.Order,
Name: l.Name,
ApproverUserId: l.ApproverUserId,
AllowReturnOneLevel: l.AllowReturnOneLevel,
AllowReturnOneStep: l.AllowReturnOneStep,
AllowReturnToAssignee: l.AllowReturnToAssignee,
AllowReturnToDrafter: l.AllowReturnToDrafter,
AllowApproverEditDetails: l.AllowApproverEditDetails,
AllowApproverEditBudget: l.AllowApproverEditBudget,
AllowApproverDelete: l.AllowApproverDelete,
AllowApproverSkipToFinal: null, // ECHO-only, null = không khai báo
AllowApproverFinalize: null);
private static UpdateAwLevelInput NewLevel(int order, Guid approverUserId, bool allowDelete = false)
=> new(Id: null, Order: order, Name: $"Cấp {order}", ApproverUserId: approverUserId,
AllowApproverDelete: allowDelete);
// Thay danh sách Cấp của Bước thứ `stepIdx` trong lệnh (giữ nguyên phần còn lại).
private static UpdateAwDefinitionCommand WithLevels(
UpdateAwDefinitionCommand cmd, int stepIdx, List<UpdateAwLevelInput> levels)
{
var steps = cmd.Steps.ToList();
steps[stepIdx] = steps[stepIdx] with { Levels = levels };
return cmd with { Steps = steps };
}
private static PurchaseEvaluationWorkflowService NewPeService(Harness h)
=> new(h.Db, h.Clock, new NoOpNotificationService(), h.Um);
private static Task ApproveAsync(PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorId)
=> svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
actorUserId: actorId,
actorRoles: Array.Empty<string>(), // KHÔNG Admin — phải qua guard thật
decision: ApprovalDecision.Approve,
comment: null,
ct: CancellationToken.None);
// =====================================================================
// F-12 ⭐ BÀI KIỂM CHỨNG TRUNG TÂM (spec §F.0) — sửa hạng AN TOÀN trên
// workflow ĐANG CÓ PHIẾU CHẠY ⇒ phiếu nhận thay đổi NGAY.
//
// Đo bằng HÀNH VI THẬT chứ không bằng một cột trong bảng: cùng một phiếu,
// cùng một lời gọi duyệt của cùng một người — chỉ khác nhau ở chỗ lệnh Update
// đã chạy hay chưa. Trước: Forbidden. Sau: duyệt được + con trỏ tiến.
// (Nếu chỉ assert "DB có row mới" thì test vẫn xanh kể cả khi phiếu đọc
// snapshot cũ — tức mù đúng chỗ spec sợ nhất.)
// Kèm luôn F-4 (Id/Version/IsActive bất biến) và F-6 (403 in TÊN, không GUID).
// =====================================================================
[Fact]
public async Task F12_SafeUpdate_AddsPersonToExistingLevel_RunningPeAcceptsNewApproverImmediately()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "Trần Thị An", null, []);
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Phạm Thị Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
// Bước 1: Cấp 1 = alice · Cấp 2 = carol. Phiếu treo tại (Bước 1, Cấp 1).
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F12-001");
var svc = NewPeService(h);
// --- TRƯỚC: bob KHÔNG duyệt được (nửa contrast — chống vacuous pass) ---
var beforeAct = async () => await ApproveAsync(svc, pe, bob.Id);
var forbidden = (await beforeAct.Should().ThrowAsync<ForbiddenException>(
"bob chưa nằm trong Cấp 1 nên phải bị chặn TRƯỚC khi sửa quy trình")).Which;
// F-6 (lead tự vá, chưa ai soi): message phải in TÊN người duyệt, không phải GUID trần.
forbidden.Message.Should().Contain("Trần Thị An",
"F-6: thông điệp 403 phải resolve GUID sang tên thật để người dùng biết nhờ ai duyệt");
forbidden.Message.Should().NotContain(alice.Id.ToString(),
"F-6: không được rơi lại về in GUID trần khi tra được tên");
// --- ACT: thêm bob vào CẤP CÓ SẴN (row trùng Order 1) ---
var step = wf.Steps.Single();
var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
var cmd = WithLevels(BuildEchoCommand(wf), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id), EchoLevel(lvlCarol)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
// --- F-4: giữ Id / Version / IsActive — giữ Id là TOÀN BỘ mục đích ---
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Id.Should().Be(wf.Id);
live.Version.Should().Be(1, "sửa tại chỗ KHÔNG được tăng Version");
live.IsActive.Should().BeTrue("sửa tại chỗ KHÔNG được đụng IsActive");
live.Steps.Should().HaveCount(1);
live.Steps[0].Levels.Where(l => l.Order == 1).Select(l => l.ApproverUserId)
.Should().BeEquivalentTo(new[] { alice.Id, bob.Id });
var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
peRow.ApprovalWorkflowId.Should().Be(wf.Id, "phiếu vẫn pin ĐÚNG Id cũ — không có đường re-pin");
// --- SAU: cùng phiếu, cùng người, cùng lời gọi ⇒ nay duyệt ĐƯỢC ---
await ApproveAsync(svc, pe, bob.Id);
var peAfter = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
peAfter.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
peAfter.CurrentApprovalLevelOrder.Should().Be(2,
"bob duyệt xong Cấp 1 ⇒ con trỏ tiến sang Cấp 2 — phiếu chạy trên cấu hình MỚI");
// Chữ ký gắn vào ĐÚNG dòng Cấp mới của bob (không dạt sang row alice).
var newLevelId = live.Steps[0].Levels.Single(l => l.ApproverUserId == bob.Id).Id;
var opinion = await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
.SingleAsync(o => o.PurchaseEvaluationId == pe.Id);
opinion.ApprovalWorkflowLevelId.Should().Be(newLevelId);
opinion.SignedByUserId.Should().Be(bob.Id);
}
}
// =====================================================================
// F-13 — đụng tập Order ⇒ 409 VÀ workflow trong DB KHÔNG đổi.
// Cả 2 vế: chỉ assert "có ném" là mù trước ca handler ném SAU khi đã ghi.
// =====================================================================
[Fact]
public async Task F13_AddNewLevelOrder_Rejected409_AndWorkflowUnchanged()
{
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", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var lvlAlice = wf.Steps.Single().Levels.Single();
// Thêm Cấp 2 (Order chưa từng có) = chèn chặng cho phiếu đang chạy.
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice), NewLevel(2, bob.Id)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được thêm Cấp mới*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(1, "409 rồi thì DB phải y nguyên");
live.Steps.Single().Levels.Single().ApproverUserId.Should().Be(alice.Id);
}
}
[Fact]
public async Task F13_RemoveWholeLevel_Rejected409_AndWorkflowUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.Order == 1);
// Bỏ TOÀN BỘ Cấp 2 ⇒ phiếu đang chờ ở Cấp 2 sẽ KẸT.
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*KHÔNG CÒN người duyệt*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Select(l => l.Order).Should().BeEquivalentTo(new[] { 1, 2 });
}
}
[Fact]
public async Task F13_MovePersonBetweenLevelOrders_Rejected409_AndWorkflowUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
var step = wf.Steps.Single();
var lvlAlice = step.Levels.Single(l => l.Order == 1);
var lvlCarol = step.Levels.Single(l => l.Order == 2);
// Hoán vị Cấp giữa 2 dòng: TẬP {1,2} GIỮ NGUYÊN nên set-equality một
// mình sẽ cho qua — chốt chặn thật là "Order của TỪNG dòng bất biến".
var cmd = WithLevels(BuildEchoCommand(wf), 0,
[EchoLevel(lvlAlice) with { Order = 2 }, EchoLevel(lvlCarol) with { Order = 1 }]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được chuyển người duyệt từ Cấp*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id).Order.Should().Be(1);
live.Steps.Single().Levels.Single(l => l.ApproverUserId == carol.Id).Order.Should().Be(2);
}
}
[Fact]
public async Task F13_SwapStepOrders_KeepsOrderSet_ButStillRejected409()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)], [(1, carol.Id)]]);
// Đảo Order 2 Bước: tập {Step.Order} = {1,2} KHÔNG đổi ⇒ phép thử
// set-equality "khung không đổi" theo nghĩa yếu sẽ cho lọt, nhưng con
// trỏ phiếu là INDEX vào list đã sort nên nó trỏ sang Bước KHÁC, ÂM THẦM.
var echo = BuildEchoCommand(wf);
var swapped = new List<UpdateAwStepInput>
{
echo.Steps[0] with { Order = 2 },
echo.Steps[1] with { Order = 1 },
};
var act = async () => await NewHandler(h, admin).Handle(echo with { Steps = swapped }, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được đổi thứ tự Bước*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == alice.Id)).Order.Should().Be(1);
live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == carol.Id)).Order.Should().Be(2);
}
}
// =====================================================================
// F-13b (T2) — 2 trường owner xếp PHÁ VỠ: CeoApprovalThreshold + F5.
// =====================================================================
[Fact]
public async Task F13b_ChangeCeoApprovalThreshold_Rejected409_AndValueUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], ceoThreshold: 500_000_000m);
var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = 900_000_000m };
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.CeoApprovalThreshold.Should().Be(500_000_000m);
}
}
// Vế IM LẶNG của cùng luật: client KHÔNG khai báo ngưỡng (null) trong khi DB
// đang có ngưỡng. Nếu handler đọc null là "bỏ qua" thì admin xoá trắng ô ngưỡng
// sẽ nhận 204 mà số không đổi — sai im lặng, đúng lớp lỗi spec đang đi chữa.
[Fact]
public async Task F13b_OmitCeoApprovalThreshold_WhenStored_Rejected409_NoSilentNoop()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]], ceoThreshold: 500_000_000m);
var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = null, Name = "Tên mới" };
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.CeoApprovalThreshold.Should().Be(500_000_000m);
live.Name.Should().Be("QT test sửa tại chỗ", "409 thì KHÔNG phần nào của lệnh được ghi");
}
}
[Fact]
public async Task F13b_ToggleAllowApproverFinalize_Rejected409_AndFlagUnchanged()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var lvl = wf.Steps.Single().Levels.Single();
// F5 = ĐỔI ĐIỂM KẾT THÚC của phiếu (Phase=DaDuyet + đóng băng số tiền
// ngay lúc duyệt) ⇒ không phải "quyền tại chỗ".
var cmd = WithLevels(BuildEchoCommand(wf), 0,
[EchoLevel(lvl) with { AllowApproverFinalize = true }]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Duyệt là kết thúc*");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Single().AllowApproverFinalize.Should().BeFalse();
}
}
// Nửa còn lại của "ECHO-only, KHÔNG BAO GIỜ ghi": khi client bỏ trống 2 cờ
// đổi-đường-đi thì giá trị đang lưu phải GIỮ NGUYÊN. Đây mới là chốt chặn hồi
// quy thật — nếu ai đó sau này viết `db.X = req.X ?? false` thì F5 của người
// đang giữ quyền kết-thúc bị TẮT âm thầm qua một lần đổi tên vô hại.
[Fact]
public async Task F13b_OmittedFinalizeEcho_PreservesStoredFlags_NotSilentlyCleared()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var lvl = wf.Steps.Single().Levels.Single();
lvl.AllowApproverFinalize = true;
lvl.AllowApproverSkipToFinal = true;
await h.Db.SaveChangesAsync(CancellationToken.None);
// Lệnh hợp lệ, chỉ đổi tên — 2 cờ để null (không khai báo).
var cmd = BuildEchoCommand(wf) with { Name = "Tên quy trình đã đổi" };
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Name.Should().Be("Tên quy trình đã đổi");
var liveLevel = live.Steps.Single().Levels.Single();
liveLevel.AllowApproverFinalize.Should().BeTrue("cờ đổi-đường-đi là echo-only, không được ghi đè bằng null");
liveLevel.AllowApproverSkipToFinal.Should().BeTrue();
}
}
// =====================================================================
// F-14 — bớt người khỏi Cấp mà dòng đó ĐÃ CÓ chữ ký ⇒ chặn LỊCH SỰ (409),
// KHÔNG để nổ FK 547 → 500.
// Kèm CHỨNG-NHÂN: cuối test tự tay xoá dòng đó ⇒ DB nổ thật. Không có đoạn
// này thì assert "409" chỉ chứng minh handler biết ném, chứ không chứng minh
// nó đang đỡ một quả nổ có thật.
// =====================================================================
[Fact]
public async Task F14_RemoveApproverWithLiveOpinion_BlockedPolitely_NotFkViolation()
{
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ị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
// Cấp 1 có 2 người ⇒ bỏ bob KHÔNG làm Cấp rỗng (tách bạch với F-13).
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14-001");
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = pe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Lê Văn Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"chữ ký trên phiếu CÒN SỐNG phải chặn việc bỏ người, và chặn LỊCH SỰ")).Which;
ex.Message.Should().Contain("chữ ký");
// DB nguyên vẹn: cả dòng Cấp lẫn chữ ký đều còn.
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2);
(await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
// CHỨNG-NHÂN: nếu không có guard thì đây là thứ người dùng nhận (FK Restrict).
// 🔴 Phải dọn change-tracker TRƯỚC: chữ ký ở trên do CHÍNH test `Add` vào cùng
// context nên nó vẫn đang được theo dõi. Khi dependent còn tracked, `Remove(level)`
// kích cascade-check CLIENT-SIDE của EF (CascadeDeleteTiming.Immediate = mặc định)
// và ném InvalidOperationException("...has been severed...") NGAY TẠI `Remove` —
// chưa từng chạm DB ⇒ dòng assert bên dưới KHÔNG BAO GIỜ được chấm. Dọn tracker
// để chứng-nhân đo ĐÚNG cái tầng nó khai: ràng buộc FK ở DB.
// (Prod không dính: context mỗi request không nạp opinion nào, guard đọc chữ ký
// bằng AsNoTracking ⇒ tới `Remove` là tracker vốn đã sạch.)
h.Db.ChangeTracker.Clear();
h.Db.ApprovalWorkflowLevels.Remove(lvlBob);
var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None);
await raw.Should().ThrowAsync<DbUpdateException>(
"FK Restrict LevelOpinion→Level là thật — guard 409 đang đỡ đúng quả nổ này");
}
}
// 🔴 F-2 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU #1. Cùng RUỘT với test trên,
// khác ĐÚNG MỘT BIT: phiếu mang chữ ký đó đã bị xoá MỀM.
//
// ĐẢO SO VỚI BẢN TRƯỚC (bản trước tên `..._RemovalAllowed_AndOrphanPurged`, khẳng
// định "cho gỡ + xoá cứng chữ ký"). Vì sao đảo — không phải nới/siết cho hợp code:
// xoá mềm là KHÔI PHỤC ĐƯỢC và §A spec S155 có màn "Đã xoá" phải đọc được lịch
// sử ký. Xoá cứng chữ ký của phiếu xoá mềm = phiếu khôi phục lên mất trắng lịch
// sử duyệt, IM LẶNG, không dấu vết (changelog cũ chỉ ghi một con SỐ ĐẾM, không
// ghi phiếu nào). Đó là đúng class lỗi mà cả spec S155 đang đi chữa.
// "Bị chặn OAN" (lý lẽ cũ) không phải là mất mát: admin còn 2 đường thoát rõ ràng
// (khôi phục phiếu rồi xử lý, hoặc xoá HẲN phiếu) — cả hai đều hoàn tác được.
[Fact]
public async Task F2_OpinionBelongsToSoftDeletedPe_BlockedPolitely_AndSignatureKept()
{
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", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
var deletedPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14b-001", isDeleted: true);
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = deletedPe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"chữ ký của phiếu XOÁ MỀM vẫn là lịch sử duyệt khôi phục được")).Which;
// Thông điệp phải NÊU TÊN phiếu đang giữ chữ ký — không thì admin không biết gỡ gì.
ex.Message.Should().Contain("PE-F14b-001").And.Contain("đã xoá");
// 🔴 Điều F-2 đòi: chữ ký VẪN CÒN. Đọc bằng IgnoreQueryFilters để không bị
// chính global filter che mất row (đọc thiếu sẽ trông y hệt "đã bị xoá").
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
// Và quy trình cũng không bị sứt: cả 2 dòng Cấp còn nguyên.
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2);
}
}
// 🔴 F-2 — TIÊU CHÍ NGHIỆM THU #2, vế thứ HAI của điều kiện purge cũ (`o.IsDeleted`):
// ý kiến TỰ NÓ xoá mềm, trên phiếu CÒN SỐNG. Đó là bản ghi KIỂM TOÁN của tài liệu
// đang hiệu lực — bản trước đem xoá CỨNG luôn. Cặp cùng-ruột-khác-1-bit với test
// trên: ở đó cha xoá mềm / ý kiến sống, ở đây cha sống / ý kiến xoá mềm.
[Fact]
public async Task F2_SoftDeletedOpinionOnLivePe_NotHardPurged_BlockedAndKept()
{
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", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
// Phiếu CÒN SỐNG (không isDeleted) — chỉ ý kiến bị thu hồi (xoá mềm).
var livePe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F2b-001");
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = livePe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý (sau đó thu hồi)",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
IsDeleted = true,
DeletedAt = new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc),
});
await h.Db.SaveChangesAsync(CancellationToken.None);
// Tầng 1 (`CountLiveOpinionsAsync`) CỐ TÌNH bỏ qua row này (`!o.IsDeleted`)
// ⇒ nếu tầng 2 không tồn tại thì đây rơi thẳng vào purge và mất vĩnh viễn.
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ConflictException>(
"ý kiến đã thu hồi trên phiếu đang hiệu lực là bản ghi kiểm toán")).Which;
ex.Message.Should().Contain("PE-F2b-001").And.Contain("thu hồi");
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
}
}
// 🔴 F-1 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU: purge (`ExecuteDelete`) auto-commit
// NGAY khi không có transaction ambient, mà `SaveChangesAsync` thì mãi cuối method.
// Ném ở khoảng GIỮA ⇒ mọi thứ sau rollback, chữ ký đã xoá cứng thì KHÔNG quay lại.
//
// Dựng lại đúng hình đó, KHÔNG mock:
// (1) chữ ký MỒ CÔI THẬT — phiếu cha đã không còn row. Không dựng được bằng đường
// ứng dụng (FK cha→opinion là Cascade: xoá cứng cha thì chữ ký đi theo), nên
// tắt `PRAGMA foreign_keys` đúng một câu DELETE để mô phỏng dữ liệu lệch sinh
// ngoài luồng — chính thứ mà purge tồn tại để dọn.
// (2) BẪY ném SAU purge: một changelog trỏ vào phiếu không tồn tại, gài sẵn trong
// change-tracker ⇒ `SaveChangesAsync` cuối method nổ FK. Hiện thực đúng luồng
// thật: context là scoped-theo-request, handler khác trong cùng request có thể
// để lại thay đổi đang chờ.
// Nếu rào giao dịch KHÔNG có: purge đã commit ⇒ assert dưới thấy 0 ⇒ đỏ.
[Fact]
public async Task F1_SaveChangesThrowsAfterPurge_RollsBackWholeCommand_OrphanSignaturesKept()
{
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", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (1, bob.Id)]]);
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
// Phiếu SỐNG pin quy trình này ⇒ nhánh ghi changelog (F-1c) có việc để làm,
// nên `SaveChanges` cuối method thật sự được gọi.
await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-LIVE");
var ghostPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-GHOST");
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = ghostPe.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
// (1) Xoá HẲN phiếu cha nhưng chặn cascade ⇒ còn lại chữ ký MỒ CÔI THẬT.
await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
await h.Db.Database.ExecuteSqlRawAsync(
"DELETE FROM PurchaseEvaluations WHERE Id = {0}", ghostPe.Id);
await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
h.Db.ChangeTracker.Clear(); // tracker vẫn giữ bản sao — dọn cho khớp DB
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
.Should().Be(1, "tiền đề: phải có đúng 1 chữ ký mồ côi cho purge nhắm tới");
// (2) BẪY — sẽ nổ FK tại `SaveChangesAsync`, tức SAU khi purge đã chạy.
h.Db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = Guid.NewGuid(), // phiếu KHÔNG tồn tại
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Update,
UserId = admin.Id,
UserName = "Quản trị",
Summary = "Bẫy: ép SaveChanges ném SAU khi purge đã chạy",
});
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<DbUpdateException>(
"bẫy phải nổ ở SaveChanges — nếu không thì test này không đo được cái nó khai");
// 🔴 Điều F-1 đòi: số row chữ ký KHÔNG ĐỔI sau lệnh thất bại.
h.Db.ChangeTracker.Clear();
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
.Should().Be(1, "purge phải cùng rollback với SaveChanges, không auto-commit riêng");
// Và phần còn lại của lệnh cũng rollback trọn: dòng Cấp chưa bị gỡ.
(await h.Db.ApprovalWorkflowLevels.AsNoTracking()
.CountAsync(l => l.Id == lvlBob.Id)).Should().Be(1);
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync()).Should().Be(0);
}
}
// =====================================================================
// F-15 (NỬA TRƯỚC) — bật cờ F6 `AllowApproverDelete` bằng Update trên
// workflow đang có phiếu ChoDuyet treo ⇒ cờ ĐỌC ĐƯỢC từ phía phiếu đang chạy.
//
// 🔴 GIỚI HẠN CỐ Ý: KHÔNG test hành vi XOÁ phiếu. Nút xoá ở màn duyệt +
// endpoint xoá-khi-đang-duyệt thuộc ĐỢT 2 và CHƯA TỒN TẠI trên đĩa lúc viết
// test này. Ở đây chỉ chứng minh mắt xích §F.0: cờ tick hôm nay TỚI ĐƯỢC
// phiếu treo từ hôm qua (điều mà POST-tạo-version-mới không làm được).
// =====================================================================
[Fact]
public async Task F15_EnableAllowApproverDeleteViaUpdate_FlagReachesAlreadyPendingPe_DeleteBehaviorIsWave2()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id), (2, carol.Id)]]);
// Phiếu treo TỪ TRƯỚC khi admin tick cờ.
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F15-001");
var before = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
before.Steps[0].Levels.Single(l => l.ApproverUserId == alice.Id)
.AllowApproverDelete.Should().BeFalse("nền: cờ F6 mặc định tắt");
var step = wf.Steps.Single();
var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu — cần cho phép xoá"), 0,
[EchoLevel(lvlAlice) with { AllowApproverDelete = true }, EchoLevel(lvlCarol)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
// Đọc ĐÚNG như phiếu đang chạy đọc: theo Id đã pin + con trỏ (Bước, Cấp).
var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
var live = await ResolveLikeRunningPeAsync(h.Db, peRow.ApprovalWorkflowId!.Value);
var currentStep = live.Steps.OrderBy(s => s.Order).ToList()[peRow.CurrentWorkflowStepIndex!.Value];
var matchingRow = currentStep.Levels
.Single(l => l.Order == peRow.CurrentApprovalLevelOrder && l.ApproverUserId == alice.Id);
matchingRow.AllowApproverDelete.Should().BeTrue(
"§F.0: chỉ sửa-tại-chỗ mới đưa được cờ tới phiếu đang treo — POST version mới sinh Id khác");
live.Version.Should().Be(1, "phiếu vẫn đọc ĐÚNG bản đang pin, không phải bản mới");
// Per-ROW chứ không per-Cấp: người khác trong quy trình KHÔNG tự có cờ.
live.Steps[0].Levels.Single(l => l.ApproverUserId == carol.Id)
.AllowApproverDelete.Should().BeFalse();
}
}
// =====================================================================
// F-16 — blast radius: `ApprovalWorkflow` V2 dùng chung ≥6 module. Nếu guard
// chữ ký chỉ soi bảng PE thì quy trình của Văn phòng số vẫn nổ FK 547 → 500.
// =====================================================================
[Fact]
public async Task F16_ProposalWorkflow_RemoveApproverWithLiveProposalOpinion_Blocked409()
{
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", "Bình", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
[[(1, alice.Id), (1, bob.Id)]], code: "QT-DX-V2");
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
var proposal = new Proposal
{
Id = Guid.NewGuid(),
Title = "Đề xuất mua máy tính",
Status = ProposalStatus.DaGuiDuyet,
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
CurrentApprovalLevelOrder = 1,
};
h.Db.Proposals.Add(proposal);
h.Db.ProposalLevelOpinions.Add(new ProposalLevelOpinion
{
ProposalId = proposal.Id,
ApprovalWorkflowLevelId = lvlBob.Id,
Comment = "Đồng ý",
SignedAt = h.Clock.UtcNow,
SignedByUserId = bob.Id,
SignedByFullName = "Bình",
});
await h.Db.SaveChangesAsync(CancellationToken.None);
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>(
"chữ ký của module Đề xuất cũng Restrict FK y như PE — guard phải quét CẢ 7 bảng opinion");
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Should().HaveCount(2);
}
}
[Fact]
public async Task F16_ProposalWorkflow_SafeUpdate_Succeeds_AndWritesNoPeChangelog()
{
var h = NewHarness();
using (h.Fix)
{
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
[[(1, alice.Id)]], code: "QT-DX-V2");
var proposal = new Proposal
{
Id = Guid.NewGuid(),
Title = "Đề xuất tăng ngân sách",
Status = ProposalStatus.DaGuiDuyet,
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
CurrentApprovalLevelOrder = 1,
};
h.Db.Proposals.Add(proposal);
await h.Db.SaveChangesAsync(CancellationToken.None);
// 🔴 CHỨNG-NHÂN cho cổng module (vá F11 — reviewer bắt ca này XANH GIẢ).
// Nếu KHÔNG ghim phiếu PE nào vào workflow này thì `affected` rỗng ở CẢ HAI
// thế giới — còn cổng `def.ApplicableType is DuyetNcc or DuyetNccPhuongAn`
// hay gỡ cổng đi — nên assert `Count == 0` bên dưới luôn đúng và KHÔNG đo gì.
// Ghim 1 phiếu PE (phase ChoDuyet = "chưa xong" ⇒ lọt truy vấn `affected`)
// vào đúng workflow module Đề xuất này thì phép đo mới phân biệt được:
// gỡ cổng ⇒ handler ghi changelog PE ⇒ ca này ĐỎ.
var peGateWitness = await SeedPeAsync(h.Db, wf.Id, admin.Id, "PE-F16-GATE");
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người duyệt"), 0,
[EchoLevel(lvlAlice), NewLevel(1, carol.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
live.Steps.Single().Levels.Select(l => l.ApproverUserId)
.Should().BeEquivalentTo(new[] { alice.Id, carol.Id });
live.ApplicableType.Should().Be(ApprovalWorkflowApplicableType.ProposalGeneral);
// Phép đo nay CÓ RĂNG: có đúng 1 phiếu PE ChoDuyet ghim workflow này, nên
// nếu cổng module bị gỡ thì handler sẽ ghi vết cho nó ⇒ count = 1 ⇒ ĐỎ.
(await h.Db.PurchaseEvaluations.AsNoTracking()
.CountAsync(p => p.ApprovalWorkflowId == wf.Id && p.Phase == PurchaseEvaluationPhase.ChoDuyet))
.Should().Be(1, "chứng-nhân phải tồn tại, nếu không assert dưới là rỗng nghĩa");
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync())
.Should().Be(0, "quy trình module Đề xuất KHÔNG được đẻ vết vào changelog của PE");
peGateWitness.Should().NotBeNull();
var proposalRow = await h.Db.Proposals.AsNoTracking().FirstAsync(p => p.Id == proposal.Id);
proposalRow.ApprovalWorkflowId.Should().Be(wf.Id);
}
}
// =====================================================================
// F-1c — GHI VẾT. Spec đòi changelog khi xoá phiếu (§B4) nhưng ban đầu quên
// đòi vết cho việc SỬA QUYỀN — bất đối xứng, review bắt. Vết phải rơi đúng
// phiếu ĐANG CHỊU ẢNH HƯỞNG (chưa kết thúc), không rải cho phiếu đã đóng.
// =====================================================================
[Fact]
public async Task F1c_SafeUpdate_LogsChangelogWithReason_OnlyToUnfinishedPes()
{
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("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
[[(1, alice.Id)]]);
var pending = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-PENDING");
var approved = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-DADUYET",
phase: PurchaseEvaluationPhase.DaDuyet);
var rejected = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-TUCHOI",
phase: PurchaseEvaluationPhase.TuChoi);
var lvlAlice = wf.Steps.Single().Levels.Single();
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu"), 0,
[EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
var logs = await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().ToListAsync();
logs.Select(l => l.PurchaseEvaluationId).Should().BeEquivalentTo(new[] { pending.Id },
$"chỉ phiếu chưa kết thúc mới chịu ảnh hưởng (DaDuyet {approved.MaPhieu} + TuChoi {rejected.MaPhieu} phải bị loại)");
var log = logs.Single();
log.Action.Should().Be(ChangelogAction.Update);
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
log.EntityId.Should().Be(wf.Id);
log.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
log.UserId.Should().Be(admin.Id);
log.UserName.Should().Be("Quản trị hệ thống");
log.Summary.Should().Contain("giữ nguyên phiên bản");
log.ContextNote.Should().Contain("Lý do: Bấm sai gói thầu");
log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình",
"vết phải đọc được bằng TÊN, không phải GUID");
}
}
}