[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
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:
@ -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 là 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">có 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 Mã / Ngưỡng CEO / cờ "Duyệt là KẾT THÚC" / "Duyệt thẳng Cấp cuối" ⇒ phải bấm <span className="font-semibold">"Tạo phiên bản mới"</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 cũ</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 và bấm <span className="font-semibold">"Sửa tại chỗ"</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>Mã 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 < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO.
|
||||
Để trống = luôn theo đủ quy trình.
|
||||
{isEdit
|
||||
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
|
||||
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* [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>Lý 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 là 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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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".
|
||||
|
||||
@ -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 là 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 là 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 là 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 có mã)" : 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ể
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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}).");
|
||||
}
|
||||
|
||||
@ -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 đó).
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user