[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 { useParams } from 'react-router-dom'
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 { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
@ -52,6 +52,10 @@ type LevelDto = {
allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean
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).
canEditProBudget: boolean
canEditCcmBudget: boolean
@ -94,6 +98,12 @@ type TypeSummaryDto = {
type LevelOrder = 1 | 2 | 3
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
approverUserId: string
// Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ
@ -108,8 +118,20 @@ type EditLevelEntry = {
allowApproverEditBudget: boolean
allowApproverSkipToFinal: 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 }
@ -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.
function copyFromDefinition(d: DefinitionDto): EditStep[] {
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,
departmentId: s.departmentId,
levelEntries: s.levels
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
.map(l => ({
id: l.id,
order: l.order as LevelOrder,
approverUserId: l.approverUserId,
allowReturnOneLevel: l.allowReturnOneLevel ?? false,
@ -157,6 +185,9 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
allowApproverEditBudget: l.allowApproverEditBudget ?? false,
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? 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,
allowApproverSkipToFinal: 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 }) {
const [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id +
// 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 del = useMutation({
@ -299,7 +358,8 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<DefinitionCard
def={type.active}
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({
id: type.active!.id,
isUserSelectable: !type.active!.isUserSelectable,
@ -318,7 +378,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<div className="flex items-center justify-between">
<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" />
Tạo quy trình mới
</Button>
@ -336,7 +396,11 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
key={d.id}
def={d}
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({
id: d.id,
isUserSelectable: !d.isUserSelectable,
@ -353,8 +417,9 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel}
cloneFrom={cloneFrom}
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
editTarget={editTarget}
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null); onSaved() }}
/>
)}
</div>
@ -367,12 +432,14 @@ function DefinitionCard({
def,
isActive,
onClone,
onEditInPlace,
onToggleSelectable,
onDelete,
}: {
def: DefinitionDto
isActive: boolean
onClone: (d: DefinitionDto) => void
onEditInPlace: (d: DefinitionDto) => void
onToggleSelectable: () => void
onDelete: () => void
}) {
@ -475,10 +542,26 @@ function DefinitionCard({
))}
</ol>
</div>
<div className="flex flex-col gap-1.5">
<Button variant="outline" size="sm" onClick={() => onClone(def)}>
<div className="flex w-44 shrink-0 flex-col gap-1.5">
{/* [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" />
Tạo từ bản này
Tạo phiên bản mới
</Button>
{/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */}
<Button
@ -506,28 +589,45 @@ function Designer({
applicableType,
applicableTypeLabel,
cloneFrom,
editTarget,
onClose,
onSaved,
}: {
applicableType: number
applicableTypeLabel: string
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
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(
() => (cloneFrom ? copyFromDefinition(cloneFrom) : [makeEmptyStep(1)]),
[cloneFrom],
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source],
)
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '')
const [code, setCode] = useState(source?.code ?? defaultCode)
const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(source?.description ?? '')
// 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(
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)
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
@ -569,12 +669,94 @@ function Designer({
if (!e.approverUserId) {
throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`)
}
// [F-7 S155] Ràng "NV phải thuộc Phòng đã chọn" CHỈ áp cho đường tạo mới.
// 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
// 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', {
applicableType,
code,
@ -595,27 +777,37 @@ function Designer({
order: e.order,
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
// [F6 S155 → F-7 S155] 🔴 DÂY DỄ QUÊN NHẤT: payload liệt kê tay từng
// field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick
// vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT),
// 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi.
...levelFlagsPayload(e),
})),
})),
})
},
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()
},
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) {
e.preventDefault()
setServerError(null)
if (steps.length === 0) {
toast.error('Phải có ít nhất 1 bước')
return
@ -635,23 +827,74 @@ function Designer({
<Dialog
open
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"
footer={
<>
<Button variant="outline" onClick={onClose}>Hủy</Button>
<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>
</>
}
>
<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="space-y-1.5">
<Label> quy trình *</Label>
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
<div className="text-[11px] text-slate-400">Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.</div>
<Input
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 className="space-y-1.5">
<Label>Tên hiển thị *</Label>
@ -669,12 +912,33 @@ function Designer({
value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')}
onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))}
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">
Gói &lt; ngưỡng: CCM duyệt xong, không cần CEO. ngưỡng: phải lên CEO.
Đ trống = luôn theo đ quy trình.
{isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
</div>
</div>
{/* [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>
{/* 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>
</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>
)

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 />
{/* [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 />
{/* [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>
</td>
</tr>

View File

@ -2,7 +2,8 @@
// ApprovalWorkflowV2AdminFeatures.cs). Chỉ subset cần cho fe-user read-only
// 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 = {
id: string
@ -20,6 +21,10 @@ export type AwLevelDto = {
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).
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 = {

View File

@ -33,6 +33,23 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
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);
// 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 Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.ApprovalWorkflowsV2;
@ -39,6 +41,7 @@ public record AwLevelDto(
bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal,
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
// 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.
@ -185,7 +188,7 @@ public class GetAwAdminOverviewQueryHandler(
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
l.AllowApproverSkipToFinal, l.AllowApproverFinalize,
l.AllowApproverSkipToFinal, l.AllowApproverFinalize, l.AllowApproverDelete,
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
}).ToList()
)).ToList());
@ -227,7 +230,10 @@ public record CreateAwLevelInput(
bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = 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(
int Order,
@ -366,6 +372,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditBudget = l.AllowApproverEditBudget,
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
AllowApproverFinalize = l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).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) ==========
// Independent với IsActive: cho phép multiple version cùng selectable. Default
// 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();
}
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. Approver = ApproverUserId
// chính xác (KHÔNG OR-of-many). Sequential trong cùng bước: cấp 1 → cấp 2 → ...
// Cấp = tập các row CÙNG Order trong 1 Bước. MỖI ROW = 1 NV cụ thể
// (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 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.
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; }
}

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
// (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay.
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()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AllowApproverDelete")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverEditBudget")
.ValueGeneratedOnAdd()
.HasColumnType("bit")

View File

@ -702,7 +702,18 @@ public class PurchaseEvaluationWorkflowService(
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
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(
$"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 Microsoft.AspNetCore.Authorization;
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
namespace SolutionErp.Infrastructure.Tests.Api;
@ -88,6 +89,59 @@ public class AuthorizePolicyRegressionTests
"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).
// 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");
}
}
}