[CLAUDE] PurchaseEvaluation: nut XOA phieu o man duyet + menu 3 muc + man "Da xoa" (dot 2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m47s

Dong ca UAT goc (Tra Sol): "lo bam sai cai goi thau, quay lai khong duoc, phai
xoa thi no moi ko co luy ke len". Dot 1 (b1bae77) da dung NEN (co F6 + lenh sua
quy trinh tai cho); dot 2 la phan nguoi dung cham vao.

- App: PeSoftDeleteFeatures.cs MOI
  - DELETE /api/purchase-evaluations/{id}/by-approver — 3 rao: phase ChoDuyet
    ^ dung luot actor ^ matchingLevel.AllowApproverDelete (PER-NGUOI, khong g.Any)
  - GET /api/purchase-evaluations/deleted — IgnoreQueryFilters() KEM .Where(IsDeleted)
    (IgnoreQueryFilters GO filter chu khong DAO — thieu Where la liet ke ca phieu song)
    + tai lap khoi IDOR cua list hien hanh
  - ghi vet ChangelogAction.Delete + ly do
- Api: 2 endpoint moi. 🔴 by-approver CO Y KHONG co [Authorize(Policy)] — do tren DB:
  moi vai dang lam nguoi duyet (Procurement/CostControl...) deu CanDelete=0 va khong
  seeder nao nang co do => gan policy la 403 voi dung nguoi can dung, ma FE gate cung
  quyen nen khong thay nut khong thay loi (gotcha #44). Quyen = 3 rao handler; co F6
  la admin tick dich danh tung nguoi => chat hon quyen theo vai.
- Dto: ApprovalWorkflowOptionsDto + AllowApproverDelete. Thieu field nay thi FE gate
  luon sai => nut KHONG BAO GIO hien, ma BE lan FE deu build sach (reviewer bat).
- Domain/Infra: 2 menu key Pe_{code}_Approved / _Deleted (KHONG vao MenuKeys.All =>
  giu 54/216) + seed tree + labelBackfill "Duyet" -> "Dang duyet" + 2 danh sach permission
- FE x2 app: nut Xoa trong PeWorkflowPanel (md5 mirror khop) + dialog ly do + Layout
  regex/route cho 2 muc moi + man "Da xoa" chi-xem
- Tests: +11 (562 tong, 0 fail). T27 khoa CA LOP loi entity-co-co-ma-DTO-khong-co
  (fault-inject xac nhan: go field ra => T27 do ngay)

Review: 12 FLAG, chot chan H1 (DTO thieu field) da va. Deploy KHONG co migration
nhung CO seed menu => phai restart API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-27 14:06:14 +07:00
parent 0aa8557650
commit 4464a464c5
15 changed files with 1633 additions and 33 deletions

View File

@ -104,7 +104,15 @@ function resolvePath(key: string): string | null {
}
// Pe_<Code>_<Action> cho module Duyệt NCC
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/)
// [S155 đợt 2 — owner restructure menu 2026-07-27] +`Approved` +`Deleted`. Đây là
// "chỗ thứ 4" của Pattern 16-bis (staticMap/resolvePath) — key seed ở BE mà quên nới
// regex thì `resolvePath` trả null ⇒ MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
// 🔴 CỐ Ý KHÔNG có `WfView` ở đây (khác fe-user): fe-admin không có
// `pages/pe/WorkflowMatrixViewPage.tsx` lẫn route `/purchase-evaluations/workflow-matrix`
// ⇒ thêm vào sẽ đẻ link chết rơi vào catch-all "Trang này chưa được build". Admin đã có
// Designer (`/system/approval-workflows-v2/...`) sửa được nên bản matrix chỉ-xem là thừa.
// Lead chốt 2026-07-27 = GIỮ NGUYÊN lệch này; đừng "vá" ở lượt sau.
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|Approved|Deleted)$/)
if (peMatch) {
const [, code, action] = peMatch
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
@ -116,6 +124,11 @@ function resolvePath(key: string): string | null {
// (PeDetailTabs "Sửa header" button vẫn navigate sang đó).
if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}`
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
// [S155 đợt 2] 2 mục MỚI — CẢ HAI tái dùng `PurchaseEvaluationsListPage`, 0 route mới,
// 0 page mới. "Đã duyệt" chỉ là filter phase sẵn có (`DaDuyet = 7`, đã thông 3 tầng);
// "Đã xóa" là view riêng trong cùng page, gọi endpoint `/purchase-evaluations/deleted`.
if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7`
if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1`
}
// PE workflow admin leaf: PeWf_<Code> → /system/pe-workflows/<code>
const peWfMatch = key.match(/^PeWf_(.+)$/)

View File

@ -5,7 +5,7 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Download, Eye, Paperclip, Upload, X } from 'lucide-react'
import { Download, Eye, Paperclip, Trash2, Upload, X } from 'lucide-react'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
@ -32,6 +32,7 @@ export function PeWorkflowPanel({
evaluation,
readOnly = false,
onApproved,
onDeleted,
}: {
evaluation: PeDetailBundle
/** true = ẩn Chuyển tiếp + Dialog transition (dùng cho Danh sách, không dùng Duyệt). */
@ -40,6 +41,11 @@ export function PeWorkflowPanel({
* overlay về danh sách (anh: "chọn duyệt thì nó đóng lại như ban đầu").
* KHÔNG gọi khi Trả lại / Từ chối (caller giữ overlay để xem kết quả). */
onApproved?: () => void
/** [S155 đợt 2] gọi sau khi XÓA MỀM phiếu đang duyệt thành công — caller đóng
* detail/overlay về danh sách (phiếu vừa xóa không còn đọc được: global filter
* `PurchaseEvaluationConfiguration.cs:84` loại nó khỏi `GET /{id}` ⇒ giữ overlay
* sẽ hiện "Không tìm thấy phiếu"). KHÔNG dùng lại `onApproved` vì 2 việc khác nghĩa. */
onDeleted?: () => void
}) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
@ -59,6 +65,11 @@ export function PeWorkflowPanel({
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// [S155 đợt 2 — UAT Tra Sol 2026-07-27] XÓA MỀM phiếu khi ĐANG DUYỆT: "lỡ bấm sai
// cái gói thầu / quay lại không được / phải xóa thì nó mới ko có lũy kế lên".
// Dialog xác nhận RIÊNG (không trộn vào dialog transition) + ô lý do → changelog BE.
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteReason, setDeleteReason] = useState('')
// S78 — người duyệt đính kèm file khi DUYỆT. File chọn (chưa upload) staged ở state,
// upload trong transition.mutationFn TRƯỚC khi chuyển phase (file lỗi = không duyệt).
const [approveFiles, setApproveFiles] = useState<File[]>([])
@ -102,6 +113,32 @@ export function PeWorkflowPanel({
const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level
// [S155 đợt 2] Rào hiển thị nút XÓA — dựng để khớp 1-1 với 3 rào BE
// (`PeSoftDeleteFeatures.cs:65 / :104 / :114`) + tầng authz độc lập (gotcha #82).
//
// 🔴 KHÔNG dùng `actorInV2Level` ở đây dù nó sẵn có: nó có `isAdmin ||` bypass, còn
// đường xóa BE CỐ Ý KHÔNG bypass admin (`:100-104` — bê fallback `.First()` sang sẽ
// biến "admin bất kỳ" thành người xóa được phiếu người khác). Tệ hơn, khi actor không
// match thì `currentLevelOptions` BE fallback về ROW ĐẦU của Cấp
// (`PurchaseEvaluationFeatures.cs:1084-1085`) ⇒ admin sẽ đọc cờ F6 CỦA NGƯỜI KHÁC,
// phá đúng ràng buộc PER-NGƯỜI owner chốt. Vậy dùng tập con CHẶT dưới đây.
const actorIsCurrentApprover = !!currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)
// 🔴 Cờ F6 đọc từ `currentLevelOptions` — kênh DUY NHẤT per-NGƯỜI (BE resolve slot theo
// `ApproverUserId == currentUser` rồi mới đọc cờ). So `=== true` (fail-closed) CÓ CHỦ Ý:
// field vắng ⇒ ẩn nút, KHÔNG lòi nút xoá-chứng-từ-tài-chính rồi ăn 403 hàng loạt.
const canDeleteWhileApproving =
!readOnly
&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorIsCurrentApprover
&& levelOptions?.allowApproverDelete === true
// 🔴 CỐ Ý KHÔNG gate bằng `can(MenuKeys.PurchaseEvaluations, 'Delete')` — owner chốt
// 2026-07-27. Reviewer ĐO trên DB: mọi vai đang thật sự làm người duyệt (`Procurement`,
// `CostControl`, …) đều `CanDelete = 0`, và không seeder nào nâng cờ đó ⇒ gate bằng quyền
// theo vai sẽ **ẩn nút với đúng người có quyền**, im lặng, không lỗi (gotcha #44).
// Nguồn quyền = 3 rào ở trên, khớp 1-1 với 3 rào BE trong `PeSoftDeleteFeatures.cs`.
// Rào `allowApproverDelete` là cờ per-NGƯỜI admin tick đích danh — chặt hơn quyền theo vai.
// ⚠️ Endpoint BE cũng đã gỡ `[Authorize(Policy=...)]` cho khớp; đừng khôi phục MỘT bên.
// [Mig 54] ③ CCM duyệt-done miễn CEO: chỉ đủ điều kiện khi user là CCM (CostControl) +
// workflow có ngưỡng CEO + giá gói < ngưỡng (khớp guard fail-closed BE). ① bộ chọn giá
// chốt hiện khi đây là duyệt CUỐI (Cấp cuối Bước cuối đang "Current") HOẶC CCM tích done.
@ -251,6 +288,29 @@ export function PeWorkflowPanel({
onError: e => toast.error(getErrorMessage(e)),
})
// [S155 đợt 2] XÓA MỀM phiếu đang duyệt. Route CANONICAL `.../{id}/by-approver` —
// KHÁC hẳn đường xóa nháp `DELETE /purchase-evaluations/{id}` (PeDetailTabs, giữ nguyên).
// Lý do đi trong BODY: controller bind `[FromBody] DeleteByApproverBody? body`
// (`PurchaseEvaluationsController.cs`), record `(string? Reason)` ⇒ axios `data:`.
const deleteByApprover = useMutation({
mutationFn: async () =>
api.delete(`/purchase-evaluations/${evaluation.id}/by-approver`, {
data: { reason: deleteReason.trim() || null },
}),
onSuccess: () => {
toast.success('Đã xóa phiếu — chuyển sang mục "Đã xóa", thôi tính vào lũy kế.')
setDeleteOpen(false)
setDeleteReason('')
// Phiếu rời MỌI danh sách sống (global filter) + số lũy kế đổi ⇒ invalidate cả
// pe-budget (ma trận ngân sách S133/S134 đọc lũy kế) ngoài pe-list/pe-detail.
qc.invalidateQueries({ queryKey: ['pe-list'] })
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-budget'] })
onDeleted?.()
},
onError: e => toast.error(getErrorMessage(e)),
})
// UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi".
// BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE
// (BE cũ cache / deploy lệch vẫn không lòi nút). Dialog/isCancel giữ dead-safe.
@ -455,7 +515,7 @@ export function PeWorkflowPanel({
</div>
)}
{next.length > 0 && !readOnly && (
{(next.length > 0 || canDeleteWhileApproving) && !readOnly && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
@ -501,6 +561,21 @@ export function PeWorkflowPanel({
</button>
)
})}
{/* [S155 đợt 2] Nút XÓA — CẠNH vòng `next.map`, CỐ Ý KHÔNG nằm TRONG map:
map sinh nút theo PHASE (`workflow.nextPhases`), còn Xóa KHÔNG phải một
phase — nhét vào map sẽ phải bịa 1 phase giả. Điều kiện hiện nút ở
`canDeleteWhileApproving` (4 rào). KHÔNG đụng nút xóa NHÁP ở
`PeDetailTabs.tsx:457-472` — đó là đường khác, endpoint khác, quyền khác. */}
{canDeleteWhileApproving && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo; và thôi tính vào lũy kế)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</button>
)}
</div>
</div>
)}
@ -797,6 +872,51 @@ export function PeWorkflowPanel({
)
})()}
{/* [S155 đợt 2] Dialog xác nhận XÓA — RIÊNG, không trộn vào dialog transition ở trên
(transition đi `POST /transitions` với targetPhase; xóa đi `DELETE /by-approver`).
Nội dung nói thẳng HỆ QUẢ vì màn "Đã xóa" hiện là CHỈ XEM, chưa có khôi phục
(owner chốt (6)) ⇒ với người dùng đây là thao tác một chiều. */}
{deleteOpen && (
<Dialog
open
size="sm"
onClose={() => { setDeleteOpen(false); setDeleteReason('') }}
title="🗑 Xóa phiếu đang duyệt"
footer={<>
<Button variant="ghost" onClick={() => { setDeleteOpen(false); setDeleteReason('') }}>Hủy</Button>
<Button
variant="danger"
onClick={() => deleteByApprover.mutate()}
disabled={deleteByApprover.isPending}
>
{deleteByApprover.isPending ? 'Đang xóa…' : 'Xác nhận xóa'}
</Button>
</>}
>
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-800">
<div className="font-semibold">Xóa phiếu &ldquo;{evaluation.tenGoiThau}&rdquo;?</div>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
<li>Phiếu <strong>rời khỏi quy trình duyệt</strong> chuyển sang mục <strong>&ldquo;Đã xóa&rdquo;</strong>.</li>
<li>Phiếu <strong>thôi tính vào lũy kế</strong> ngân sách của hạng mục đây do chính của nút này.</li>
<li>Mục &ldquo;Đã xóa&rdquo; hiện <strong>chỉ đ xem</strong>: chưa khôi phục. Cần lại thì phải tạo phiếu mới.</li>
<li>Thao tác đưc <strong>ghi vết</strong> (ai xóa, lúc nào, do) vào lịch sử phiếu.</li>
</ul>
</div>
<Label> do xóa (nên ghi )</Label>
<Textarea
value={deleteReason}
onChange={e => setDeleteReason(e.target.value)}
rows={3}
maxLength={2000}
placeholder="VD: chọn nhầm gói thầu — phiếu này sai hạng mục, sẽ tạo lại phiếu đúng."
/>
<p className="mt-1 text-[11px] text-slate-500">
do lưu vào lịch sử phiếu (tối đa 2000 tự). Bỏ trống vẫn xóa đưc, nhưng người
sau sẽ không biết sao phiếu biến mất.
</p>
</Dialog>
)}
{deptApprovals.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />

View File

@ -35,6 +35,12 @@ export function PurchaseEvaluationsListPage() {
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
const pendingMe = sp.get('pendingMe') === '1'
// [S155 đợt 2] Mục menu MỚI "Đã xóa" (`Pe_{code}_Deleted` → `?type=N&deleted=1`).
// View CHỈ XEM (owner chốt (6)): KHÔNG khôi phục, KHÔNG thao tác, KHÔNG mở chi tiết —
// `GET /purchase-evaluations/{id}` bị global filter `PurchaseEvaluationConfiguration.cs:84`
// loại phiếu đã xóa nên bấm vào chỉ ra "Không tìm thấy phiếu". Chặn ở nguồn thay vì để
// người dùng đâm vào ngõ cụt. Mục "Đã duyệt" KHÔNG cần cờ nào — nó chỉ là `?phase=7`.
const deletedView = sp.get('deleted') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
@ -56,8 +62,22 @@ export function PurchaseEvaluationsListPage() {
})
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId, deletedView }],
queryFn: async () => {
// [S155 đợt 2] Nhánh "Đã xóa" phải đứng TRƯỚC `pendingMe`: 2 view loại trừ nhau, và
// cả `/inbox` lẫn `/purchase-evaluations` đều bị global filter loại phiếu đã xóa ⇒
// chỉ endpoint riêng `/deleted` (chỗ DUY NHẤT toàn BE dùng `IgnoreQueryFilters`) thấy.
if (deletedView) {
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations/deleted', {
params: {
page: 1,
pageSize: 50,
type: typeFilter ?? undefined,
search: search || undefined,
},
})
return res.data
}
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: {
@ -151,6 +171,15 @@ export function PurchaseEvaluationsListPage() {
// đóng hẳn overlay + clear selection (về danh sách).
const onApproved = useCallback(() => closeDetail(), [closeDetail])
// [S155 đợt 2] Xóa mềm xong → BẮT BUỘC đóng detail/overlay: phiếu vừa xóa không đọc lại
// được (`GET /{id}` bị global filter loại) nên giữ overlay sẽ hiện "Không tìm thấy phiếu"
// ngay sau một thao tác THÀNH CÔNG — trông y như lỗi. Panel đã invalidate `pe-list`;
// gọi thêm ở đây để list nền refetch kể cả khi caller khác không invalidate.
const onDeleted = useCallback(() => {
closeDetail()
qc.invalidateQueries({ queryKey: ['pe-list'] })
}, [closeDetail, qc])
const allRows = list.data?.items ?? []
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
// Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp →
@ -247,9 +276,11 @@ export function PurchaseEvaluationsListPage() {
})
}
const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]}Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
const headerTitle = deletedView
? (typeFilter ? `${PurchaseEvaluationTypeLabel[typeFilter]}Đã xóa` : 'Duyệt NCC — Đã xóa')
: typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
// ─── S79 Focus-mode overlay (anh chốt 2026-06-19) ─────────────────────────
// Overlay full-bleed trượt từ phải CHE menu trái + TopBar + list + inline
@ -369,7 +400,7 @@ export function PurchaseEvaluationsListPage() {
{/* Mig 23 — Dropdown quy trình duyệt CHỈ ở "Duyệt" (pendingMe).
Danh sách giữ nguyên (chỉ filter trạng thái) — user feedback
muốn Danh sách show hết phiếu không filter quy trình. */}
{pendingMe && (
{pendingMe && !deletedView && (
<Select value={approvalWorkflowId} onChange={e => setParam('awId', e.target.value)}>
<option value="">Tất cả quy trình duyệt</option>
{approvalWorkflows.data?.map(w => (
@ -380,8 +411,15 @@ export function PurchaseEvaluationsListPage() {
</Select>
)}
{/* Duyệt (pendingMe) → filter cứng "Đã gửi duyệt", ẩn dropdown trạng thái.
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái. */}
{pendingMe ? (
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái.
[S155 đợt 2] "Đã xóa" → ẩn CẢ HAI: dropdown trạng thái vô nghĩa ở đây (phiếu
giữ nguyên phase lúc bị xóa, `IsDeleted` mới là thứ phân loại). */}
{deletedView ? (
<div className="rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[11px] text-red-700">
🗑 <strong>Phiếu đã xóa</strong> chỉ xem. Không khôi phục, không thao tác.
Các phiếu đây <strong>không còn tính vào lũy kế</strong> ngân sách.
</div>
) : pendingMe ? (
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
Lọc cố đnh: <strong>Đã gửi duyệt</strong> (phiếu đang chờ duyệt)
</div>
@ -482,7 +520,12 @@ export function PurchaseEvaluationsListPage() {
>
<button
onClick={() => selectRow(p.id)}
className="block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60"
// [S155 đợt 2] "Đã xóa" = CHỈ XEM ⇒ dòng không bấm được.
disabled={deletedView}
className={cn(
'block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
deletedView && 'cursor-default',
)}
>
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
<div className="flex items-start justify-between gap-2">
@ -511,6 +554,25 @@ export function PurchaseEvaluationsListPage() {
hour: '2-digit', minute: '2-digit',
})}
</span>
{/* [S155 đợt 2] "Xóa lúc" — 🔴 API `/deleted` trả DTO DÙNG CHUNG với
list sống ⇒ KHÔNG có `deletedAt`/`deletedBy`. Mốc dưới đây là
`updatedAt`, mà `AuditingInterceptor` ghi ngay lượt SaveChanges của
thao tác xóa mềm ⇒ XẤP XỈ thời điểm xóa. Nhãn "≈" để không hứa
chính xác. TÊN người xóa API KHÔNG trả — cố ý bỏ trống thay vì đoán. */}
{deletedView && p.updatedAt && (
<>
<span className="text-slate-300">·</span>
<span
className="rounded bg-red-50 px-1 py-0.5 text-[10px] font-medium text-red-700"
title="Thời điểm xóa (xấp xỉ — lấy từ mốc cập nhật cuối, ghi cùng lượt xóa mềm). Xem lịch sử phiếu để biết ai xóa và lý do."
>
🗑 xóa {new Date(p.updatedAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
</>
)}
</div>
{(p.drafterName || p.departmentName || p.contractId) && (
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px]">
@ -524,7 +586,10 @@ export function PurchaseEvaluationsListPage() {
)}
</button>
{/* "Xem mở rộng" — icon-button góc phải, lộ rõ khi hover/focus
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip). */}
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip).
[S155 đợt 2] ẨN HẲN ở view "Đã xóa": đó là thao tác, mà mục này
CHỈ XEM; hơn nữa detail của phiếu đã xóa không tải được. */}
{!deletedView && (
<button
type="button"
onClick={() => expandRow(p.id)}
@ -537,6 +602,7 @@ export function PurchaseEvaluationsListPage() {
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
))}
@ -560,7 +626,13 @@ export function PurchaseEvaluationsListPage() {
chưa mở rộng. Khi expand → overlay phủ lên (panel này vẫn ở dưới). */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
<EmptyState
icon={ClipboardCheck}
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
description={deletedView
? 'Mục này chỉ liệt kê phiếu đã xóa để đối chiếu. Không mở chi tiết, không khôi phục, không thao tác.'
: 'Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây.'}
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !detail.data && (
@ -581,7 +653,9 @@ export function PurchaseEvaluationsListPage() {
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn phiếu.
{deletedView
? 'Phiếu đã xóa không còn quy trình duyệt.'
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
</div>
)}
{selectedId && !isExpand && detail.data && (
@ -589,6 +663,7 @@ export function PurchaseEvaluationsListPage() {
evaluation={detail.data}
readOnly={!pendingMe}
onApproved={onApproved}
onDeleted={onDeleted}
/>
)}
</aside>
@ -675,6 +750,7 @@ export function PurchaseEvaluationsListPage() {
evaluation={detail.data}
readOnly={!pendingMe}
onApproved={onApproved}
onDeleted={onDeleted}
/>
</aside>
</div>
@ -706,7 +782,10 @@ export function PurchaseEvaluationDetailPage() {
return (
<div className="space-y-4 p-6">
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
<PeWorkflowPanel evaluation={detail.data} />
{/* [S155 đợt 2] Call-site THỨ BA của panel (route fullpage mobile `/purchase-evaluations/:id`).
Ở đây `readOnly` để MẶC ĐỊNH = false ⇒ nút Xóa CÓ THỂ hiện ⇒ bắt buộc truyền `onDeleted`,
nếu không trang sẽ đứng lại với phiếu vừa biến mất (fetch lại là 404). */}
<PeWorkflowPanel evaluation={detail.data} onDeleted={() => navigate('/purchase-evaluations')} />
</div>
)
}

View File

@ -425,6 +425,10 @@ export type ApprovalWorkflowOptions = {
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
// [S155 F6] slot này được XÓA MỀM phiếu ChoDuyet khi tới lượt mình duyệt.
// 🔴 PER-NGƯỜI: BE resolve slot theo `ApproverUserId == currentUser` rồi mới đọc cờ
// (PurchaseEvaluationFeatures.cs:1083-1085) — KHÔNG phải OR-of-N cả Cấp.
allowApproverDelete: boolean
}
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE

View File

@ -117,7 +117,12 @@ function resolvePath(key: string): string | null {
}
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|WfView)$/)
// [S155 đợt 2 — owner restructure menu 2026-07-27] +`Approved` +`Deleted`. Đây là
// "chỗ thứ 4" của Pattern 16-bis (staticMap/resolvePath) — key seed ở BE mà quên nới
// regex thì `resolvePath` trả null ⇒ MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
// 🔴 `WfView` GIỮ NGUYÊN chỉ ở fe-user (lead chốt đường (c)): fe-admin KHÔNG có
// `WorkflowMatrixViewPage` nên thêm `WfView` bên đó sẽ đẻ link chết.
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|WfView|Approved|Deleted)$/)
if (peMatch) {
const [, code, action] = peMatch
const typeInt = PE_CODE_TO_INT[code]
@ -132,6 +137,11 @@ function resolvePath(key: string): string | null {
// (PeDetailTabs "Sửa header" button vẫn navigate sang đó).
if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}`
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
// [S155 đợt 2] 2 mục MỚI — CẢ HAI tái dùng `PurchaseEvaluationsListPage`, 0 route mới,
// 0 page mới. "Đã duyệt" chỉ là filter phase sẵn có (`DaDuyet = 7`, đã thông 3 tầng);
// "Đã xóa" là view riêng trong cùng page, gọi endpoint `/purchase-evaluations/deleted`.
if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7`
if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1`
}
return null
}

View File

@ -5,7 +5,7 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Download, Eye, Paperclip, Upload, X } from 'lucide-react'
import { Download, Eye, Paperclip, Trash2, Upload, X } from 'lucide-react'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
@ -32,6 +32,7 @@ export function PeWorkflowPanel({
evaluation,
readOnly = false,
onApproved,
onDeleted,
}: {
evaluation: PeDetailBundle
/** true = ẩn Chuyển tiếp + Dialog transition (dùng cho Danh sách, không dùng Duyệt). */
@ -40,6 +41,11 @@ export function PeWorkflowPanel({
* overlay về danh sách (anh: "chọn duyệt thì nó đóng lại như ban đầu").
* KHÔNG gọi khi Trả lại / Từ chối (caller giữ overlay để xem kết quả). */
onApproved?: () => void
/** [S155 đợt 2] gọi sau khi XÓA MỀM phiếu đang duyệt thành công — caller đóng
* detail/overlay về danh sách (phiếu vừa xóa không còn đọc được: global filter
* `PurchaseEvaluationConfiguration.cs:84` loại nó khỏi `GET /{id}` ⇒ giữ overlay
* sẽ hiện "Không tìm thấy phiếu"). KHÔNG dùng lại `onApproved` vì 2 việc khác nghĩa. */
onDeleted?: () => void
}) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
@ -59,6 +65,11 @@ export function PeWorkflowPanel({
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// [S155 đợt 2 — UAT Tra Sol 2026-07-27] XÓA MỀM phiếu khi ĐANG DUYỆT: "lỡ bấm sai
// cái gói thầu / quay lại không được / phải xóa thì nó mới ko có lũy kế lên".
// Dialog xác nhận RIÊNG (không trộn vào dialog transition) + ô lý do → changelog BE.
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteReason, setDeleteReason] = useState('')
// S78 — người duyệt đính kèm file khi DUYỆT. File chọn (chưa upload) staged ở state,
// upload trong transition.mutationFn TRƯỚC khi chuyển phase (file lỗi = không duyệt).
const [approveFiles, setApproveFiles] = useState<File[]>([])
@ -102,6 +113,32 @@ export function PeWorkflowPanel({
const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level
// [S155 đợt 2] Rào hiển thị nút XÓA — dựng để khớp 1-1 với 3 rào BE
// (`PeSoftDeleteFeatures.cs:65 / :104 / :114`) + tầng authz độc lập (gotcha #82).
//
// 🔴 KHÔNG dùng `actorInV2Level` ở đây dù nó sẵn có: nó có `isAdmin ||` bypass, còn
// đường xóa BE CỐ Ý KHÔNG bypass admin (`:100-104` — bê fallback `.First()` sang sẽ
// biến "admin bất kỳ" thành người xóa được phiếu người khác). Tệ hơn, khi actor không
// match thì `currentLevelOptions` BE fallback về ROW ĐẦU của Cấp
// (`PurchaseEvaluationFeatures.cs:1084-1085`) ⇒ admin sẽ đọc cờ F6 CỦA NGƯỜI KHÁC,
// phá đúng ràng buộc PER-NGƯỜI owner chốt. Vậy dùng tập con CHẶT dưới đây.
const actorIsCurrentApprover = !!currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)
// 🔴 Cờ F6 đọc từ `currentLevelOptions` — kênh DUY NHẤT per-NGƯỜI (BE resolve slot theo
// `ApproverUserId == currentUser` rồi mới đọc cờ). So `=== true` (fail-closed) CÓ CHỦ Ý:
// field vắng ⇒ ẩn nút, KHÔNG lòi nút xoá-chứng-từ-tài-chính rồi ăn 403 hàng loạt.
const canDeleteWhileApproving =
!readOnly
&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorIsCurrentApprover
&& levelOptions?.allowApproverDelete === true
// 🔴 CỐ Ý KHÔNG gate bằng `can(MenuKeys.PurchaseEvaluations, 'Delete')` — owner chốt
// 2026-07-27. Reviewer ĐO trên DB: mọi vai đang thật sự làm người duyệt (`Procurement`,
// `CostControl`, …) đều `CanDelete = 0`, và không seeder nào nâng cờ đó ⇒ gate bằng quyền
// theo vai sẽ **ẩn nút với đúng người có quyền**, im lặng, không lỗi (gotcha #44).
// Nguồn quyền = 3 rào ở trên, khớp 1-1 với 3 rào BE trong `PeSoftDeleteFeatures.cs`.
// Rào `allowApproverDelete` là cờ per-NGƯỜI admin tick đích danh — chặt hơn quyền theo vai.
// ⚠️ Endpoint BE cũng đã gỡ `[Authorize(Policy=...)]` cho khớp; đừng khôi phục MỘT bên.
// [Mig 54] ③ CCM duyệt-done miễn CEO: chỉ đủ điều kiện khi user là CCM (CostControl) +
// workflow có ngưỡng CEO + giá gói < ngưỡng (khớp guard fail-closed BE). ① bộ chọn giá
// chốt hiện khi đây là duyệt CUỐI (Cấp cuối Bước cuối đang "Current") HOẶC CCM tích done.
@ -251,6 +288,29 @@ export function PeWorkflowPanel({
onError: e => toast.error(getErrorMessage(e)),
})
// [S155 đợt 2] XÓA MỀM phiếu đang duyệt. Route CANONICAL `.../{id}/by-approver` —
// KHÁC hẳn đường xóa nháp `DELETE /purchase-evaluations/{id}` (PeDetailTabs, giữ nguyên).
// Lý do đi trong BODY: controller bind `[FromBody] DeleteByApproverBody? body`
// (`PurchaseEvaluationsController.cs`), record `(string? Reason)` ⇒ axios `data:`.
const deleteByApprover = useMutation({
mutationFn: async () =>
api.delete(`/purchase-evaluations/${evaluation.id}/by-approver`, {
data: { reason: deleteReason.trim() || null },
}),
onSuccess: () => {
toast.success('Đã xóa phiếu — chuyển sang mục "Đã xóa", thôi tính vào lũy kế.')
setDeleteOpen(false)
setDeleteReason('')
// Phiếu rời MỌI danh sách sống (global filter) + số lũy kế đổi ⇒ invalidate cả
// pe-budget (ma trận ngân sách S133/S134 đọc lũy kế) ngoài pe-list/pe-detail.
qc.invalidateQueries({ queryKey: ['pe-list'] })
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-budget'] })
onDeleted?.()
},
onError: e => toast.error(getErrorMessage(e)),
})
// UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi".
// BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE
// (BE cũ cache / deploy lệch vẫn không lòi nút). Dialog/isCancel giữ dead-safe.
@ -455,7 +515,7 @@ export function PeWorkflowPanel({
</div>
)}
{next.length > 0 && !readOnly && (
{(next.length > 0 || canDeleteWhileApproving) && !readOnly && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
@ -501,6 +561,21 @@ export function PeWorkflowPanel({
</button>
)
})}
{/* [S155 đợt 2] Nút XÓA — CẠNH vòng `next.map`, CỐ Ý KHÔNG nằm TRONG map:
map sinh nút theo PHASE (`workflow.nextPhases`), còn Xóa KHÔNG phải một
phase — nhét vào map sẽ phải bịa 1 phase giả. Điều kiện hiện nút ở
`canDeleteWhileApproving` (4 rào). KHÔNG đụng nút xóa NHÁP ở
`PeDetailTabs.tsx:457-472` — đó là đường khác, endpoint khác, quyền khác. */}
{canDeleteWhileApproving && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo; và thôi tính vào lũy kế)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</button>
)}
</div>
</div>
)}
@ -797,6 +872,51 @@ export function PeWorkflowPanel({
)
})()}
{/* [S155 đợt 2] Dialog xác nhận XÓA — RIÊNG, không trộn vào dialog transition ở trên
(transition đi `POST /transitions` với targetPhase; xóa đi `DELETE /by-approver`).
Nội dung nói thẳng HỆ QUẢ vì màn "Đã xóa" hiện là CHỈ XEM, chưa có khôi phục
(owner chốt (6)) ⇒ với người dùng đây là thao tác một chiều. */}
{deleteOpen && (
<Dialog
open
size="sm"
onClose={() => { setDeleteOpen(false); setDeleteReason('') }}
title="🗑 Xóa phiếu đang duyệt"
footer={<>
<Button variant="ghost" onClick={() => { setDeleteOpen(false); setDeleteReason('') }}>Hủy</Button>
<Button
variant="danger"
onClick={() => deleteByApprover.mutate()}
disabled={deleteByApprover.isPending}
>
{deleteByApprover.isPending ? 'Đang xóa…' : 'Xác nhận xóa'}
</Button>
</>}
>
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-800">
<div className="font-semibold">Xóa phiếu &ldquo;{evaluation.tenGoiThau}&rdquo;?</div>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
<li>Phiếu <strong>rời khỏi quy trình duyệt</strong> chuyển sang mục <strong>&ldquo;Đã xóa&rdquo;</strong>.</li>
<li>Phiếu <strong>thôi tính vào lũy kế</strong> ngân sách của hạng mục đây do chính của nút này.</li>
<li>Mục &ldquo;Đã xóa&rdquo; hiện <strong>chỉ đ xem</strong>: chưa khôi phục. Cần lại thì phải tạo phiếu mới.</li>
<li>Thao tác đưc <strong>ghi vết</strong> (ai xóa, lúc nào, do) vào lịch sử phiếu.</li>
</ul>
</div>
<Label> do xóa (nên ghi )</Label>
<Textarea
value={deleteReason}
onChange={e => setDeleteReason(e.target.value)}
rows={3}
maxLength={2000}
placeholder="VD: chọn nhầm gói thầu — phiếu này sai hạng mục, sẽ tạo lại phiếu đúng."
/>
<p className="mt-1 text-[11px] text-slate-500">
do lưu vào lịch sử phiếu (tối đa 2000 tự). Bỏ trống vẫn xóa đưc, nhưng người
sau sẽ không biết sao phiếu biến mất.
</p>
</Dialog>
)}
{deptApprovals.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />

View File

@ -35,6 +35,12 @@ export function PurchaseEvaluationsListPage() {
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
const pendingMe = sp.get('pendingMe') === '1'
// [S155 đợt 2] Mục menu MỚI "Đã xóa" (`Pe_{code}_Deleted` → `?type=N&deleted=1`).
// View CHỈ XEM (owner chốt (6)): KHÔNG khôi phục, KHÔNG thao tác, KHÔNG mở chi tiết —
// `GET /purchase-evaluations/{id}` bị global filter `PurchaseEvaluationConfiguration.cs:84`
// loại phiếu đã xóa nên bấm vào chỉ ra "Không tìm thấy phiếu". Chặn ở nguồn thay vì để
// người dùng đâm vào ngõ cụt. Mục "Đã duyệt" KHÔNG cần cờ nào — nó chỉ là `?phase=7`.
const deletedView = sp.get('deleted') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
@ -56,8 +62,22 @@ export function PurchaseEvaluationsListPage() {
})
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId, deletedView }],
queryFn: async () => {
// [S155 đợt 2] Nhánh "Đã xóa" phải đứng TRƯỚC `pendingMe`: 2 view loại trừ nhau, và
// cả `/inbox` lẫn `/purchase-evaluations` đều bị global filter loại phiếu đã xóa ⇒
// chỉ endpoint riêng `/deleted` (chỗ DUY NHẤT toàn BE dùng `IgnoreQueryFilters`) thấy.
if (deletedView) {
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations/deleted', {
params: {
page: 1,
pageSize: 50,
type: typeFilter ?? undefined,
search: search || undefined,
},
})
return res.data
}
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: {
@ -151,6 +171,15 @@ export function PurchaseEvaluationsListPage() {
// đóng hẳn overlay + clear selection (về danh sách).
const onApproved = useCallback(() => closeDetail(), [closeDetail])
// [S155 đợt 2] Xóa mềm xong → BẮT BUỘC đóng detail/overlay: phiếu vừa xóa không đọc lại
// được (`GET /{id}` bị global filter loại) nên giữ overlay sẽ hiện "Không tìm thấy phiếu"
// ngay sau một thao tác THÀNH CÔNG — trông y như lỗi. Panel đã invalidate `pe-list`;
// gọi thêm ở đây để list nền refetch kể cả khi caller khác không invalidate.
const onDeleted = useCallback(() => {
closeDetail()
qc.invalidateQueries({ queryKey: ['pe-list'] })
}, [closeDetail, qc])
const allRows = list.data?.items ?? []
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
// Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp →
@ -247,9 +276,11 @@ export function PurchaseEvaluationsListPage() {
})
}
const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]}Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
const headerTitle = deletedView
? (typeFilter ? `${PurchaseEvaluationTypeLabel[typeFilter]}Đã xóa` : 'Duyệt NCC — Đã xóa')
: typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
// ─── S79 Focus-mode overlay (anh chốt 2026-06-19) ─────────────────────────
// Overlay full-bleed trượt từ phải CHE menu trái + TopBar + list + inline
@ -369,7 +400,7 @@ export function PurchaseEvaluationsListPage() {
{/* Mig 23 — Dropdown quy trình duyệt CHỈ ở "Duyệt" (pendingMe).
Danh sách giữ nguyên (chỉ filter trạng thái) — user feedback
muốn Danh sách show hết phiếu không filter quy trình. */}
{pendingMe && (
{pendingMe && !deletedView && (
<Select value={approvalWorkflowId} onChange={e => setParam('awId', e.target.value)}>
<option value="">Tất cả quy trình duyệt</option>
{approvalWorkflows.data?.map(w => (
@ -380,8 +411,15 @@ export function PurchaseEvaluationsListPage() {
</Select>
)}
{/* Duyệt (pendingMe) → filter cứng "Đã gửi duyệt", ẩn dropdown trạng thái.
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái. */}
{pendingMe ? (
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái.
[S155 đợt 2] "Đã xóa" → ẩn CẢ HAI: dropdown trạng thái vô nghĩa ở đây (phiếu
giữ nguyên phase lúc bị xóa, `IsDeleted` mới là thứ phân loại). */}
{deletedView ? (
<div className="rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[11px] text-red-700">
🗑 <strong>Phiếu đã xóa</strong> chỉ xem. Không khôi phục, không thao tác.
Các phiếu đây <strong>không còn tính vào lũy kế</strong> ngân sách.
</div>
) : pendingMe ? (
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
Lọc cố đnh: <strong>Đã gửi duyệt</strong> (phiếu đang chờ duyệt)
</div>
@ -482,7 +520,12 @@ export function PurchaseEvaluationsListPage() {
>
<button
onClick={() => selectRow(p.id)}
className="block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60"
// [S155 đợt 2] "Đã xóa" = CHỈ XEM ⇒ dòng không bấm được.
disabled={deletedView}
className={cn(
'block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
deletedView && 'cursor-default',
)}
>
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
<div className="flex items-start justify-between gap-2">
@ -511,6 +554,25 @@ export function PurchaseEvaluationsListPage() {
hour: '2-digit', minute: '2-digit',
})}
</span>
{/* [S155 đợt 2] "Xóa lúc" — 🔴 API `/deleted` trả DTO DÙNG CHUNG với
list sống ⇒ KHÔNG có `deletedAt`/`deletedBy`. Mốc dưới đây là
`updatedAt`, mà `AuditingInterceptor` ghi ngay lượt SaveChanges của
thao tác xóa mềm ⇒ XẤP XỈ thời điểm xóa. Nhãn "≈" để không hứa
chính xác. TÊN người xóa API KHÔNG trả — cố ý bỏ trống thay vì đoán. */}
{deletedView && p.updatedAt && (
<>
<span className="text-slate-300">·</span>
<span
className="rounded bg-red-50 px-1 py-0.5 text-[10px] font-medium text-red-700"
title="Thời điểm xóa (xấp xỉ — lấy từ mốc cập nhật cuối, ghi cùng lượt xóa mềm). Xem lịch sử phiếu để biết ai xóa và lý do."
>
🗑 xóa {new Date(p.updatedAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
</>
)}
</div>
{(p.drafterName || p.departmentName || p.contractId) && (
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px]">
@ -524,7 +586,10 @@ export function PurchaseEvaluationsListPage() {
)}
</button>
{/* "Xem mở rộng" — icon-button góc phải, lộ rõ khi hover/focus
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip). */}
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip).
[S155 đợt 2] ẨN HẲN ở view "Đã xóa": đó là thao tác, mà mục này
CHỈ XEM; hơn nữa detail của phiếu đã xóa không tải được. */}
{!deletedView && (
<button
type="button"
onClick={() => expandRow(p.id)}
@ -537,6 +602,7 @@ export function PurchaseEvaluationsListPage() {
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
))}
@ -560,7 +626,13 @@ export function PurchaseEvaluationsListPage() {
chưa mở rộng. Khi expand → overlay phủ lên (panel này vẫn ở dưới). */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
<EmptyState
icon={ClipboardCheck}
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
description={deletedView
? 'Mục này chỉ liệt kê phiếu đã xóa để đối chiếu. Không mở chi tiết, không khôi phục, không thao tác.'
: 'Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây.'}
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !detail.data && (
@ -581,7 +653,9 @@ export function PurchaseEvaluationsListPage() {
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn phiếu.
{deletedView
? 'Phiếu đã xóa không còn quy trình duyệt.'
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
</div>
)}
{selectedId && !isExpand && detail.data && (
@ -589,6 +663,7 @@ export function PurchaseEvaluationsListPage() {
evaluation={detail.data}
readOnly={!pendingMe}
onApproved={onApproved}
onDeleted={onDeleted}
/>
)}
</aside>
@ -675,6 +750,7 @@ export function PurchaseEvaluationsListPage() {
evaluation={detail.data}
readOnly={!pendingMe}
onApproved={onApproved}
onDeleted={onDeleted}
/>
</aside>
</div>
@ -706,7 +782,10 @@ export function PurchaseEvaluationDetailPage() {
return (
<div className="space-y-4 p-6">
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
<PeWorkflowPanel evaluation={detail.data} />
{/* [S155 đợt 2] Call-site THỨ BA của panel (route fullpage mobile `/purchase-evaluations/:id`).
Ở đây `readOnly` để MẶC ĐỊNH = false ⇒ nút Xóa CÓ THỂ hiện ⇒ bắt buộc truyền `onDeleted`,
nếu không trang sẽ đứng lại với phiếu vừa biến mất (fetch lại là 404). */}
<PeWorkflowPanel evaluation={detail.data} onDeleted={() => navigate('/purchase-evaluations')} />
</div>
)
}

View File

@ -427,6 +427,10 @@ export type ApprovalWorkflowOptions = {
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
// [S155 F6] slot này được XÓA MỀM phiếu ChoDuyet khi tới lượt mình duyệt.
// 🔴 PER-NGƯỜI: BE resolve slot theo `ApproverUserId == currentUser` rồi mới đọc cờ
// (PurchaseEvaluationFeatures.cs:1083-1085) — KHÔNG phải OR-of-N cả Cấp.
allowApproverDelete: boolean
}
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE

View File

@ -143,6 +143,11 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
return NoContent();
}
// 🔴 [S155 đợt 2 — 7a] ĐƯỜNG XÓA NHÁP: GIỮ NGUYÊN, KHÔNG gắn policy.
// Gắn `[Authorize(Policy="PurchaseEvaluations.Delete")]` vào đây = role `Drafter` MẤT
// quyền xóa nháp của chính mình (đo thật DB Dev: 11/13 role `CanDelete=0`;
// `DbInitializer.cs:2515` chỉ cấp DeptManager) ⇒ regression trên chức năng đang sống.
// Rào của đường này vẫn là PHASE (`{DangSoanThao, TuChoi}`).
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
@ -150,6 +155,45 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
return NoContent();
}
// [S155 đợt 2 — 7b] ĐƯỜNG XÓA KHI ĐANG DUYỆT (mới). Endpoint RIÊNG để giữ đường xóa
// nháp ở trên nguyên vẹn.
//
// 🔴 CỐ Ý KHÔNG có `[Authorize(Policy=...)]` — owner chốt 2026-07-27 sau khi reviewer
// ĐO trên DB: mọi vai đang thật sự làm người duyệt (`Procurement`, `CostControl`, …)
// đều `CanDelete = 0`, và 2 seeder KHÔNG BAO GIỜ nâng cờ đó (`DbInitializer.cs:2139-2140`
// chỉ nâng Read/Create; `:2538` bỏ qua hàng đã tồn tại) ⇒ gắn policy vào là **403 với
// đúng người cần dùng**, mà FE gate cùng quyền nên **không thấy nút, không thấy lỗi**
// (gotcha #44 — 403 im lặng).
//
// Nguồn quyền THẬT của đường này = **3 rào nghiệp vụ trong handler** (`PeSoftDeleteFeatures.cs`):
// (i) phase == ChoDuyet · (ii) ĐÚNG LƯỢT actor · (iii) `matchingLevel.AllowApproverDelete`
// Rào (iii) là cờ F6 **per-NGƯỜI** — admin tick đích danh từng NV trong Designer quy trình.
// Đó CHẶT HƠN permission theo vai (vai gộp nhiều người; cờ chỉ đúng một người, đúng một Cấp).
// Đây KHÔNG phải "bỏ authz": handler có `ICurrentUser` và ném Forbidden — đúng khuôn cả
// controller này vẫn dùng ("class [Authorize] any-auth; handler fine-grained Forbidden",
// xem chú thích `:69-71`, `:88-90`, `:120`).
// ⚠️ Hệ quả phải nhớ: FE **KHÔNG** được gate nút này bằng `can(..., 'Delete')` nữa —
// gate đó sẽ ẩn nút với đúng người có quyền. Đã gỡ ở `PeWorkflowPanel.tsx`.
[HttpDelete("{id:guid}/by-approver")]
public async Task<IActionResult> DeleteByApprover(Guid id, [FromBody] DeleteByApproverBody? body, CancellationToken ct)
{
await mediator.Send(new DeletePurchaseEvaluationByApproverCommand(id, body?.Reason), ct);
return NoContent();
}
public record DeleteByApproverBody(string? Reason);
// [S155 đợt 2 — mục 8] List phiếu ĐÃ XÓA (màn "Đã xóa", chỉ-xem). Handler RIÊNG là chỗ
// DUY NHẤT dùng `IgnoreQueryFilters()` trong toàn `src/Backend` (D3).
[HttpGet("deleted")]
[Authorize(Policy = "PurchaseEvaluations.Read")]
public async Task<ActionResult<PagedResult<PurchaseEvaluationListItemDto>>> ListDeleted(
[FromQuery] PurchaseEvaluationType? type = null,
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListDeletedPurchaseEvaluationsQuery(type)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
// ========== Suppliers (N:M) ==========
[HttpPost("{id:guid}/suppliers")]

View File

@ -128,7 +128,14 @@ public record ApprovalWorkflowOptionsDto(
bool AllowApproverEditDetails,
bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal,
bool AllowApproverFinalize); // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
// [S155 đợt 2] F6 — slot này được XÓA phiếu khi đang duyệt. 🔴 FE gate nút Xóa bằng
// ĐÚNG field này (`PeWorkflowPanel.tsx` `levelOptions?.allowApproverDelete === true`,
// so `=== true` fail-closed). Thiếu field ⇒ vế đó LUÔN sai ⇒ nút KHÔNG BAO GIỜ hiện
// với bất kỳ ai, mà BE lẫn FE đều build sạch — reviewer bắt được đúng ca này.
// Test T27 (`PeDeleteByApproverTests`) khoá cả LỚP lỗi này: fault-inject xác nhận
// gỡ field ra ⇒ T27 ĐỎ ngay, kèm tên cờ bị thiếu.
bool AllowApproverDelete);
public record PurchaseEvaluationWorkflowSummaryDto(
string PolicyName,

View File

@ -0,0 +1,269 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Domain.Contracts; // ChangelogAction (dùng chung PE)
using SolutionErp.Domain.Identity; // AppRoles
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// =====================================================================================
// [S155 đợt 2] XÓA MỀM phiếu khi ĐANG DUYỆT + màn "Đã xóa".
//
// Vì sao có file này (UAT prod Tra Sol 2026-07-27): phiếu bấm SAI gói thầu đang treo ở
// `ChoDuyet` vẫn **ăn lũy kế** ngân sách hạng mục — `PrevSubmitted` gom `ChoDuyet||DaDuyet`
// (`PeBudgetAccumulator.cs:53-59`) và `PendingSelected` gom ChoDuyet-có-winner (`:114-126`).
// "Trả lại" chỉ DỜI số sang dòng khác chứ không triệt tiêu, còn đường xóa cũ chỉ nhận
// {DangSoanThao, TuChoi} (`PurchaseEvaluationFeatures.cs:1404-1406`) ⇒ không cửa nào dừng.
// Xóa mềm đặt `IsDeleted=true` ⇒ global filter `PurchaseEvaluationConfiguration.cs:84`
// tự loại phiếu khỏi CẢ 4 phép cộng lũy kế — KHÔNG phải sửa accumulator.
//
// 🔴 TÁCH ĐƯỜNG (owner chốt sau review H1): đường xóa NHÁP cũ
// `DELETE /api/purchase-evaluations/{id}` GIỮ NGUYÊN **không policy** — gắn policy vào đó
// làm role `Drafter` mất quyền xóa nháp của chính mình (đo thật trên DB Dev: 11/13 role
// `CanDelete=0`, `DbInitializer.cs:2515` chỉ cấp cho DeptManager). Đường MỚI ở đây là
// endpoint RIÊNG mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]`.
// =====================================================================================
// ========== A. DELETE BY APPROVER (phiếu đang duyệt) ==========
/// <param name="Reason">Lý do xóa — optional. `DeletedBy`/`DeletedAt` do `AuditingInterceptor`
/// set chỉ trả lời "ai + lúc nào", KHÔNG có LÝ DO ⇒ changelog là chỗ chứa lý do.</param>
public record DeletePurchaseEvaluationByApproverCommand(Guid Id, string? Reason = null) : IRequest;
public class DeletePurchaseEvaluationByApproverCommandValidator
: AbstractValidator<DeletePurchaseEvaluationByApproverCommand>
{
public DeletePurchaseEvaluationByApproverCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
// MaxLength MATCH EF config: `PurchaseEvaluationChangelog.ContextNote` HasMaxLength(2000)
// (`PurchaseEvaluationConfiguration.cs:184`) — EF là source of truth, không trust spec.
RuleFor(x => x.Reason).MaximumLength(2000);
}
}
public class DeletePurchaseEvaluationByApproverCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationByApproverCommand>
{
public async Task Handle(DeletePurchaseEvaluationByApproverCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var actorId = currentUser.UserId.Value;
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
// ---- Rào (i): PHASE. Đường này CHỈ cho phiếu đang treo ở bước duyệt.
// Phiếu nháp đi đường cũ (7a) — cố tình KHÔNG gộp để không đụng luồng đang sống.
if (pe.Phase != PurchaseEvaluationPhase.ChoDuyet)
throw new ConflictException(
"Đường xóa này chỉ dùng cho phiếu đang ở trạng thái Đã gửi duyệt. " +
"Phiếu nháp dùng nút Xóa ở màn soạn thảo.");
if (pe.ApprovalWorkflowId is not Guid awId)
throw new ConflictException(
"Phiếu chưa gắn quy trình duyệt (V2) nên không xác định được lượt duyệt.");
// ---- Rào (ii): ĐÚNG LƯỢT. Khuôn `ApproveV2Async`
// (`PurchaseEvaluationWorkflowService.cs:677-720`): `CurrentWorkflowStepIndex` là
// INDEX vào list Steps đã sort theo Order; `CurrentApprovalLevelOrder` so GIÁ TRỊ Order.
var steps = await db.ApprovalWorkflowSteps.AsNoTracking()
.Where(s => s.ApprovalWorkflowId == awId)
.OrderBy(s => s.Order)
.Select(s => new { s.Id, s.Order, s.Name })
.ToListAsync(ct);
if (steps.Count == 0)
throw new ConflictException("Quy trình chưa có bước nào.");
var currentIdx = pe.CurrentWorkflowStepIndex ?? 0;
if (currentIdx < 0 || currentIdx >= steps.Count)
throw new ConflictException(
$"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
var currentStep = steps[currentIdx];
var currentLevelOrder = pe.CurrentApprovalLevelOrder ?? 1;
var levelsOfStep = await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.ApprovalWorkflowStepId == currentStep.Id)
.OrderBy(l => l.Order)
.ToListAsync(ct);
var pendingLevelGroup = levelsOfStep.Where(l => l.Order == currentLevelOrder).ToList();
if (pendingLevelGroup.Count == 0)
throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");
// 🔴 PER-ROW, KHÔNG fallback `.First()`. Fallback `pendingLevelGroup.First()` ở
// `PurchaseEvaluationWorkflowService.cs:744-745` là đường Admin-KÝ-THAY khi DUYỆT —
// bê sang đây sẽ biến "admin bất kỳ" thành người xóa được phiếu người khác.
// Không match ⇒ chưa tới lượt ⇒ 403 (test 24).
var matchingLevel = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)
?? throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: " +
"chưa tới lượt duyệt của bạn nên không xóa được phiếu này.");
// ---- Rào (iii): CỜ F6 **PER-ROW** (owner chốt per-NGƯỜI sau review H2).
// Đọc `matchingLevel.AllowApproverDelete` (khuôn F5 `PurchaseEvaluationWorkflowService.cs:859`),
// KHÔNG đọc per-Cấp `g.Any(l => ...)` (`PurchaseEvaluationFeatures.cs:1182`): 1 Cấp có N
// người (OR-of-N — ảnh UAT: Cấp 2 Phòng Cung ứng 3 người) ⇒ `Any` phát quyền huỷ
// chứng-từ tài chính cho CẢ Cấp thay vì đúng 1 người admin chỉ đích danh.
if (!matchingLevel.AllowApproverDelete)
throw new ForbiddenException(
$"Cấp duyệt của bạn (Bước {currentIdx + 1} — Cấp {currentLevelOrder}) chưa được bật " +
"quyền \"Xóa phiếu khi đang duyệt\". Liên hệ quản trị để bật trong Quy trình duyệt.");
// ---- Ghi VẾT trước khi xóa (khuôn `LogTransitionAsync`
// `PurchaseEvaluationWorkflowService.cs:1169-1196`). `ChangelogAction.Delete = 3` đã tồn
// tại (`ContractChangelog.cs:38-44`) ⇒ KHÔNG cần migration.
// `PurchaseEvaluationChangelog : BaseEntity` (không `IsDeleted`) + query changelog lọc
// theo `PurchaseEvaluationId` KHÔNG join PE (`PurchaseEvaluationFeatures.cs:1424-1425`)
// ⇒ vết vẫn đọc được sau khi phiếu biến mất khỏi global filter.
var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
// 🔴 THỨ TỰ QUAN TRỌNG — `Remove()` TRƯỚC, `Add(changelog)` SAU. Đảo lại là MẤT VẾT.
// Bug T26 bắt được (S155 đợt 2): EF chạy cascade client-side NGAY tại lời gọi
// `Remove()` (`CascadeDeleteTiming.Immediate` = mặc định). Nếu changelog đã ở state
// `Added` trước đó, EF thấy cha "sắp bị xóa" nên DETACH đứa con vừa thêm ⇒ SaveChanges
// ghi 0 row, im lặng, không lỗi. Đo thật: `stateAfterAdd=Added → stateAfterRemove=Detached
// → rowsInDb=0`.
// Trớ trêu: phiếu KHÔNG hề bị xóa thật — `AuditingInterceptor` đổi Deleted→Modified
// thành xóa MỀM. Nhưng cascade chạy TRƯỚC interceptor nên nó hành động dựa trên một vụ
// xóa sẽ không bao giờ xảy ra.
//
// 🔴 CƠ CHẾ CỨU — đọc kỹ, đừng suy sai rồi dời `Add()` lên trên:
// KHÔNG phải "thêm sau khi state đã đổi". Tại thời điểm `Add()` chạy, phiếu **VẪN ĐANG
// là `Deleted`** — interceptor mãi tới `SaveChanges` mới đổi sang `Modified`. Thứ thật
// sự cứu bản ghi là: **lượt cascade tức thời ĐÃ XẢY RA XONG ngay tại `Remove()`, lúc
// chưa có đứa con nào để gỡ**. Không có lượt quét thứ hai ở `SaveChanges` gỡ lại
// (T26 đọc NGƯỢC từ DB sau khi lưu và đòi đúng 2 dòng — nếu còn lượt quét thì phải là 0).
// Chọn cách này thay vì `CascadeDeleteTiming.Never` (đụng setting toàn context) hoặc
// 2 lần SaveChanges (đẻ nhu cầu transaction) — rẻ nhất và không có tác dụng phụ.
db.PurchaseEvaluations.Remove(pe);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
EntityId = matchingLevel.Id,
Action = ChangelogAction.Delete,
PhaseAtChange = pe.Phase, // ChoDuyet — phase TẠI LÚC xóa
UserId = actorId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Xóa phiếu khi đang duyệt (Bước {currentIdx + 1} — Cấp {currentLevelOrder})",
ContextNote = reason,
});
// Nhắc lại cho người đọc sau: `.Remove()` ở TRÊN = XÓA MỀM, không phải hard-delete.
// `AuditingInterceptor.cs:54-63` đổi state Deleted→Modified rồi set
// IsDeleted/DeletedAt/DeletedBy (indirect-assignment). Row PE ở lại ⇒ FK của
// changelog vừa thêm vẫn hợp lệ.
await db.SaveChangesAsync(ct);
}
}
// ========== B. LIST "ĐÃ XÓA" ==========
/// Màn "Đã xóa" — CHỈ XEM (owner chốt (6): chưa làm khôi phục).
public record ListDeletedPurchaseEvaluationsQuery(PurchaseEvaluationType? Type = null)
: PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
public class ListDeletedPurchaseEvaluationsQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser)
: IRequestHandler<ListDeletedPurchaseEvaluationsQuery, PagedResult<PurchaseEvaluationListItemDto>>
{
public async Task<PagedResult<PurchaseEvaluationListItemDto>> Handle(
ListDeletedPurchaseEvaluationsQuery request, CancellationToken ct)
{
// 🔴 8a — `IgnoreQueryFilters()` **GỠ** filter chứ KHÔNG ĐẢO nó. Thiếu
// `.Where(x => x.IsDeleted)` ⇒ màn "Đã xóa" liệt kê TOÀN BỘ phiếu sống lẫn chết.
// 🔴 D3 — đây là chỗ DUY NHẤT trong `src/Backend` dùng `IgnoreQueryFilters`:
// CẤM đưa vào helper dùng chung trả `IQueryable`, CẤM sửa
// `PurchaseEvaluationConfiguration.cs:84`, CẤM thêm cờ `includeDeleted` vào query list
// hiện có (cờ sẽ lan tới `PeBudgetAccumulator` = phá đúng thứ owner muốn).
var q = from e in db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.Where(x => x.IsDeleted)
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
join u in db.Users.AsNoTracking() on e.DrafterUserId equals u.Id into uj
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
from wi in wij.DefaultIfEmpty()
select new { e, p, s, u, d, wi };
// 🔴 8b — handler MỚI mặc định KHÔNG có rào nào. Tái lập IDOR-scope y hệt list sống
// (`PurchaseEvaluationFeatures.cs:596-617`): non-admin chỉ thấy phiếu khi là Drafter
// ∥ role eligible theo phase ∥ là approver của workflow đã pin. Thiếu bước này = mọi
// user đọc được toàn bộ phiếu đã xóa của mọi dự án kèm `BudgetPeriodAmount` + tên gói
// thầu + NCC.
if (!currentUser.Roles.Contains(AppRoles.Admin))
{
var userId = currentUser.UserId;
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
var userApprovalWfIds = userId is null
? new List<Guid>()
: await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.ApproverUserId == userId.Value)
.Select(l => l.Step!.ApprovalWorkflowId)
.Distinct()
.ToListAsync(ct);
q = q.Where(x =>
x.e.DrafterUserId == userId
|| eligiblePhases.Contains(x.e.Phase)
|| (x.e.Phase != PurchaseEvaluationPhase.DangSoanThao
&& x.e.ApprovalWorkflowId != null
&& userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value)));
}
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x =>
(x.e.MaPhieu != null && x.e.MaPhieu.Contains(s)) ||
x.e.TenGoiThau.Contains(s) ||
x.p.Name.Contains(s) ||
x.p.Code.Contains(s));
}
// Mới xóa lên đầu: `AuditingInterceptor` set `UpdatedAt` ngay tại lượt SaveChanges
// của thao tác xóa mềm ⇒ UpdatedAt ≈ DeletedAt (DTO dùng chung không có cột DeletedAt).
q = request.SortDesc
? q.OrderByDescending(x => x.e.UpdatedAt ?? x.e.CreatedAt)
: q.OrderBy(x => x.e.UpdatedAt ?? x.e.CreatedAt);
var total = await q.CountAsync(ct);
var items = await q
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new PurchaseEvaluationListItemDto(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name, x.p.Code,
x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null,
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount,
x.e.IsUrgentByPro, x.e.IsUrgentByCcm,
(from sw in db.PurchaseEvaluationSuppliers
where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner
join sup in db.Suppliers on sw.SupplierId equals sup.Id
orderby sup.Name
select sup.Name).ToList(),
// Badge "Kết thúc trước CEO" = thông tin về ĐƯỜNG ĐI CÒN LẠI của phiếu.
// Phiếu đã xóa không còn đường đi ⇒ trả false/null tường minh thay vì tính
// lại con-trỏ (khai rõ để người sau không tưởng là bug quên map).
false, null, null))
.ToListAsync(ct);
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
}
}

View File

@ -1093,7 +1093,8 @@ public class GetPurchaseEvaluationQueryHandler(
curLevel.AllowApproverEditDetails,
curLevel.AllowApproverEditBudget,
curLevel.AllowApproverSkipToFinal,
curLevel.AllowApproverFinalize);
curLevel.AllowApproverFinalize,
curLevel.AllowApproverDelete); // [S155 đợt 2] F6 — FE gate nút Xóa bằng đúng field này
}
}

View File

@ -135,6 +135,15 @@ public static class MenuKeys
public static string PurchaseEvaluationList(string typeCode) => $"Pe_{typeCode}_List";
public static string PurchaseEvaluationCreate(string typeCode) => $"Pe_{typeCode}_Create";
public static string PurchaseEvaluationPending(string typeCode) => $"Pe_{typeCode}_Pending";
// [S155 đợt 2 — owner restructure menu 2026-07-27] `Duyệt` đổi nhãn thành "Đang duyệt"
// (qua `labelBackfill`, KHÔNG đổi key) + 2 mục MỚI:
// `_Approved` → "Đã duyệt" → FE `/purchase-evaluations?type={N}&phase=7` (0 route mới)
// `_Deleted` → "Đã xóa" → FE `/purchase-evaluations?type={N}&deleted=1`
// 🔴 KHÔNG thêm vào `MenuKeys.All`: `Pe_*` là leaf sinh động qua factory (giống 5 key PE
// hiện có), `All` chỉ chứa root `PurchaseEvaluations`. Thêm vào `All` sẽ đổi 2 số canonical
// `Menu keys` / `Policies` ở `docs/STATUS.md` (Policies = |All| × |Actions|, DERIVED).
public static string PurchaseEvaluationApproved(string typeCode) => $"Pe_{typeCode}_Approved";
public static string PurchaseEvaluationDeleted(string typeCode) => $"Pe_{typeCode}_Deleted";
// [Plan AA S24 t1] User read-only view ma trận phân quyền của workflow V2
// admin Designer đã ghim (IsUserSelectable=true). Suffix `_WfView` distinct
// với admin `PeWf_*` (Designer write).

View File

@ -1874,7 +1874,13 @@ public static class DbInitializer
tree.Add((MenuKeys.PurchaseEvaluationWorkflowView(code), "Luồng duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Network"));
tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List"));
tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus"));
tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2"));
// [S155 đợt 2] Nhãn ở đây CHỈ có tác dụng cho DB TRỐNG. Với DB đang chạy, upsert
// `:1893-1905` gặp key đã tồn tại thì chỉ update `Order` rồi `continue` — KHÔNG
// đụng Label ⇒ rename "Duyệt" → "Đang duyệt" phải qua `labelBackfill` bên dưới.
tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Đang duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2"));
// 2 mục MỚI (owner chốt 2026-07-27) — key mới ⇒ đi nhánh INSERT bình thường.
tree.Add((MenuKeys.PurchaseEvaluationApproved(code), "Đã duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCheck"));
tree.Add((MenuKeys.PurchaseEvaluationDeleted(code), "Đã xóa", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Trash2"));
}
// PE workflow admin leaves dưới `PeWorkflows`
@ -1921,6 +1927,11 @@ public static class DbInitializer
[MenuKeys.PurchaseEvaluationGroup("DuyetNccPhuongAn")] = "Duyệt NCC và Giải pháp",
[MenuKeys.PeWorkflowTypeLeaf("DuyetNcc")] = "Duyệt NCC",
[MenuKeys.PeWorkflowTypeLeaf("DuyetNccPhuongAn")] = "Duyệt NCC và Giải pháp",
// [S155 đợt 2] 🔴 Rename "Duyệt" → "Đang duyệt" (owner, ảnh menu 2026-07-27).
// BẮT BUỘC đi qua đây: sửa chữ trong `tree.Add(...)` là VÔ TÁC DỤNG trên DB đang
// chạy vì upsert `:1893-1905` chỉ update `Order` cho key đã tồn tại.
[MenuKeys.PurchaseEvaluationPending("DuyetNcc")] = "Đang duyệt",
[MenuKeys.PurchaseEvaluationPending("DuyetNccPhuongAn")] = "Đang duyệt",
};
var updatedLabels = 0;
foreach (var (key, expectedLabel) in labelBackfill)
@ -2094,6 +2105,10 @@ public static class DbInitializer
peKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
peKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
peKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));
// [S155 đợt 2] 2 mục menu mới — phải có mặt ở CẢ HAI danh sách permission
// (đây + `SeedPurchaseEvaluationPermissionDefaultsAsync`), quên 1 bên = quyền lệch.
peKeys.Add(MenuKeys.PurchaseEvaluationApproved(typeCode));
peKeys.Add(MenuKeys.PurchaseEvaluationDeleted(typeCode));
}
var reviewKeys = MenuKeys.All.Where(InReviewScope)
@ -2494,6 +2509,10 @@ public static class DbInitializer
menuKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));
// [S155 đợt 2] 2 mục menu mới ("Đã duyệt" / "Đã xóa") — mirror danh sách
// `peKeys` trong `UpgradeReviewModulePermissionsForAllRolesAsync` (~:2092).
menuKeys.Add(MenuKeys.PurchaseEvaluationApproved(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationDeleted(typeCode));
}
int added = 0;

View File

@ -0,0 +1,822 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Api.Controllers; // Pattern 10 — reflection authz (T25)
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Application.PurchaseEvaluations.Dtos; // ApprovalWorkflowOptionsDto (T27)
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ChangelogAction (dùng chung PE)
using SolutionErp.Domain.Identity; // AppRoles + MenuKeys
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// =============================================================================================
// S155 ĐỢT 2 (2026-07-27) — XÓA MỀM phiếu khi ĐANG DUYỆT + màn "Đã xóa".
//
// Vật đo: `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs`
// A. `DeletePurchaseEvaluationByApproverCommandHandler` (:50-145)
// B. `ListDeletedPurchaseEvaluationsQueryHandler` (:153-248)
// + `PurchaseEvaluationsController` :163 (`by-approver`) / :174 (`deleted`) — Pattern 10.
//
// 🔴 VÌ SAO FILE NÀY DỰNG FIXTURE RIÊNG (`SoftDeleteFixture`) THAY VÌ DÙNG `IdentityFixture`
// `TestApplicationDbContext` KHÔNG wire `AuditingInterceptor` (`Common/SqliteDbFixture.cs:12-32`;
// prod wire ở `Infrastructure/DependencyInjection.cs:61-62`). 7 file test hiện có né bằng cách
// set `IsDeleted = true` THỦ CÔNG. Đợt này KHÔNG né được: thứ cần đo là chính lời gọi
// `db.PurchaseEvaluations.Remove(pe)` — mà Ý NGHĨA của nó do interceptor quyết định:
// · CÓ interceptor ⇒ Deleted→Modified + IsDeleted=true ⇒ ROW Ở LẠI ⇒ màn "Đã xóa" có gì để hiện.
// · KHÔNG interceptor ⇒ XÓA CỨNG ⇒ mọi assert của T22b/T22c chạy trên list RỖNG ⇒ đúng ở CẢ HAI
// thế giới ⇒ đo được số không. Đó đúng là bẫy "assert rỗng nghĩa".
// ⇒ fixture này = bản sao `IdentityFixture` + `.AddInterceptors(new AuditingInterceptor(...))`.
// KHÔNG sửa `IdentityFixture` dùng chung (500+ test đang dựa vào hành vi hiện tại của nó).
//
// Test theo CODE (single source of truth). Test-after: code đợt 2 đã land. KHÔNG chạm production.
// =============================================================================================
public class PeDeleteByApproverTests
{
// Mốc thời gian: peer seed TRƯỚC `CurCreatedAt`; accumulator lấy window `CreatedAt < CurCreatedAt`.
private static readonly DateTime BaseT = new(2026, 7, 27, 8, 0, 0, DateTimeKind.Utc);
private static readonly DateTime CurCreatedAt = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc);
// ============================ HẠ TẦNG ============================
/// Bản sao `IdentityFixture` + AuditingInterceptor để `Remove()` = XÓA MỀM đúng như prod.
/// `Actor` mutable — đổi người thao tác giữa 2 lời gọi trong cùng test (T24b: bob rồi alice).
private sealed class SoftDeleteFixture : IDisposable
{
private readonly SqliteConnection _conn;
private readonly ServiceProvider _root;
public IServiceProvider Services { get; }
public TestApplicationDbContext Db { get; }
public TestCurrentUser Actor { get; } = new();
public FixedDateTime Clock { get; } = new(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc));
public SoftDeleteFixture()
{
_conn = new SqliteConnection("DataSource=:memory:");
_conn.Open();
var services = new ServiceCollection();
services.AddLogging();
var conn = _conn;
var actor = Actor;
var clock = Clock;
services.AddScoped<ApplicationDbContext>(_ =>
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(conn)
.EnableSensitiveDataLogging()
.AddInterceptors(new AuditingInterceptor(actor, clock)) // 🔴 khác IdentityFixture
.Options;
return new TestApplicationDbContext(options);
});
services.AddScoped<TestApplicationDbContext>(sp =>
(TestApplicationDbContext)sp.GetRequiredService<ApplicationDbContext>());
services.AddScoped<IApplicationDbContext>(sp =>
sp.GetRequiredService<TestApplicationDbContext>());
services.AddIdentityCore<User>(opt =>
{
opt.Password.RequireDigit = false;
opt.Password.RequireLowercase = false;
opt.Password.RequireUppercase = false;
opt.Password.RequireNonAlphanumeric = false;
opt.Password.RequiredLength = 4;
})
.AddRoles<Role>()
.AddEntityFrameworkStores<ApplicationDbContext>();
_root = services.BuildServiceProvider();
Services = _root.CreateScope().ServiceProvider;
Db = Services.GetRequiredService<TestApplicationDbContext>();
Db.Database.EnsureCreated();
}
public async Task<User> CreateUserAsync(string email, string fullName, params string[] roles)
{
var um = Services.GetRequiredService<UserManager<User>>();
var rm = Services.GetRequiredService<RoleManager<Role>>();
foreach (var role in roles)
if (!await rm.RoleExistsAsync(role))
await rm.CreateAsync(new Role { Id = Guid.NewGuid(), Name = role });
var user = new User
{
Id = Guid.NewGuid(),
UserName = email,
Email = email,
EmailConfirmed = true,
FullName = fullName,
IsActive = true,
};
var created = await um.CreateAsync(user, "Test@123");
if (!created.Succeeded)
throw new InvalidOperationException("CreateUserAsync failed: "
+ string.Join(",", created.Errors.Select(e => e.Description)));
if (roles.Length > 0) await um.AddToRolesAsync(user, roles);
return user;
}
/// Đặt người đang thao tác cho CẢ handler LẪN interceptor (DeletedBy đọc từ đây).
public TestCurrentUser ActAs(User u, params string[] roles)
{
Actor.UserId = u.Id;
Actor.FullName = u.FullName;
Actor.Email = u.Email;
Actor.Roles = roles;
return Actor;
}
public void Dispose()
{
_root.Dispose();
_conn.Dispose();
}
}
private static DeletePurchaseEvaluationByApproverCommandHandler NewDeleteHandler(SoftDeleteFixture f)
=> new(f.Db, f.Actor);
private static ListDeletedPurchaseEvaluationsQueryHandler NewDeletedListHandler(
SoftDeleteFixture f, ICurrentUser who) => new(f.Db, who);
// ---- seed helpers (mirror PePendingAccumulationTests :58-142) ----
private static async Task<(Project project, WorkItem wi)> SeedProjectAndWorkItemAsync(
TestApplicationDbContext db, string suffix)
{
var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-DEL-" + suffix, Name = "Dự án " + suffix };
var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-DEL-" + suffix, Name = "Hạng mục " + suffix, IsActive = true };
db.Projects.Add(project);
db.WorkItems.Add(wi);
await db.SaveChangesAsync(CancellationToken.None);
return (project, wi);
}
/// 1 đơn vị IsWinner + 1 hạng mục + 1 báo giá IsSelected (nuôi PrevSelected/PendingSelected).
private static async Task SeedWinnerQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal thanhTien)
{
var sup = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
SupplierId = Guid.NewGuid(),
Order = 0,
IsWinner = true,
};
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
GroupCode = "A.I",
GroupName = "Bê tông",
NoiDung = "Concrete",
Order = 0,
};
db.PurchaseEvaluationSuppliers.Add(sup);
db.PurchaseEvaluationDetails.Add(detail);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = sup.Id,
ThanhTien = thanhTien,
IsSelected = true,
});
await db.SaveChangesAsync(CancellationToken.None);
}
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
Guid projectId, Guid? workItemId, Guid drafterUserId, string code,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
Guid? approvalWorkflowId = null,
int stepIdx = 0, int levelOrder = 1,
decimal? budgetPeriod = null, decimal? winnerQuote = null,
DateTime? createdAt = null)
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = phase,
MaPhieu = code,
TenGoiThau = "Gói thầu " + code,
ProjectId = projectId,
WorkItemId = workItemId,
DrafterUserId = drafterUserId,
ApprovalWorkflowId = approvalWorkflowId,
CurrentWorkflowStepIndex = approvalWorkflowId is null ? null : stepIdx,
CurrentApprovalLevelOrder = approvalWorkflowId is null ? null : levelOrder,
BudgetPeriodAmount = budgetPeriod,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// Interceptor đóng dấu CreatedAt=clock lúc Add ⇒ ghi đè SAU (lượt 2 = Modified, chỉ đụng UpdatedAt).
pe.CreatedAt = createdAt ?? BaseT;
await db.SaveChangesAsync(CancellationToken.None);
if (winnerQuote is decimal wq) await SeedWinnerQuoteAsync(db, pe, wq);
return pe;
}
/// stepSpecs[s] = các dòng Cấp của Bước (Order s+1): (LevelOrder, ApproverUserId, AllowApproverDelete).
/// N dòng cùng LevelOrder = 1 Cấp N người (OR-of-N) — đúng hình dạng prod (ảnh UAT Cấp 2 có 3 người).
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
TestApplicationDbContext db,
(int LevelOrder, Guid User, bool AllowDelete)[][] stepSpecs,
string code = "QT-DEL-V2")
{
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
Name = "QT test xóa khi đang duyệt",
IsActive = true,
IsUserSelectable = true,
};
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, allowDelete) in stepSpecs[s])
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = levelOrder,
Name = $"Cấp {levelOrder}",
ApproverUserId = user,
AllowApproverDelete = allowDelete,
});
wf.Steps.Add(step);
}
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static Task<PeBudgetAccumulation> ExactAsync(TestApplicationDbContext db, Guid projectId, Guid wiId)
=> PeBudgetAccumulator.ComputeAsync(
db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, Array.Empty<Guid>(), CancellationToken.None);
private static Task<PePendingAccumulation> PendingAsync(TestApplicationDbContext db, Guid projectId, Guid wiId)
=> PeBudgetAccumulator.ComputePendingAsync(
db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, CancellationToken.None);
/// Nền chung T21/T22: 3 phiếu cùng cặp (Dự án × Hạng mục), tất cả TRƯỚC `CurCreatedAt`.
/// 🔴 CHỦ Ý: peer DaDuyet + peer TraLai tồn tại để 2 đại lượng "KHÔNG đổi" giữ giá trị ≠ 0 ở
/// CẢ HAI phía. Nếu chỉ seed mỗi phiếu bị xóa thì "không đổi" hóa ra 0 → 0 = không đo gì.
private static async Task<(Project project, WorkItem wi, PurchaseEvaluation target, User approver)>
SeedAccumulationSceneAsync(SoftDeleteFixture f, string suffix)
{
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, suffix);
var alice = await f.CreateUserAsync($"alice.{suffix}@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync($"drafter.{suffix}@t.vn", "Người soạn", AppRoles.Drafter);
// (1) DaDuyet — nuôi PrevSubmitted (100tr) + PrevSelected (90tr). KHÔNG bị đụng khi xóa target.
await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-DD",
phase: PurchaseEvaluationPhase.DaDuyet, budgetPeriod: 100_000_000m,
winnerQuote: 90_000_000m, createdAt: BaseT.AddDays(-3));
// (2) TraLai — nuôi PendingSubmitted (60tr) + một phần PendingSelected (20tr).
await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-TL",
phase: PurchaseEvaluationPhase.TraLai, budgetPeriod: 60_000_000m,
winnerQuote: 20_000_000m, createdAt: BaseT.AddDays(-2));
// (3) TARGET ChoDuyet — vào PrevSubmitted (30tr) + PendingSelected (50tr). Đây là phiếu bị xóa.
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-" + suffix);
var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-TARGET",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id,
budgetPeriod: 30_000_000m, winnerQuote: 50_000_000m, createdAt: BaseT.AddDays(-1));
return (project, wi, target, alice);
}
// =====================================================================
// T21 ⭐ LŨY KẾ — xóa phiếu ChoDuyet qua `/by-approver` ⇒ 4 đại lượng đi 4 HƯỚNG KHÁC NHAU.
//
// 🔴 KHÔNG phải "cả 4 cùng giảm". Số học từ code:
// PrevSubmitted ↓ (`PeBudgetAccumulator.cs:53-59` gom ChoDuyet ∥ DaDuyet)
// PrevSelected = (`:61-62` đòi DaDuyet ⇒ phiếu ChoDuyet chưa từng góp mặt)
// PendingSubmitted = (`:107` loại TƯỜNG MINH ChoDuyet để tránh double-count)
// PendingSelected ↓ (`:114-126` nhận ChoDuyet-có-winner — đúng gap UAT)
// PriorPes mất đúng 1 phần tử (`:128-133`)
// Cơ chế: `Remove` → IsDeleted=true → global filter `PurchaseEvaluationConfiguration.cs:84`
// loại phiếu khỏi CẢ 4 phép cộng. Accumulator KHÔNG bị sửa dòng nào.
// =====================================================================
[Fact]
public async Task T21_DeleteByApprover_ChoDuyetPe_MovesFourAccumulatorsInFourDirections()
{
using var f = new SoftDeleteFixture();
var (project, wi, target, alice) = await SeedAccumulationSceneAsync(f, "T21");
// ---------- TRƯỚC ----------
var exactBefore = await ExactAsync(f.Db, project.Id, wi.Id);
var pendBefore = await PendingAsync(f.Db, project.Id, wi.Id);
exactBefore.PrevSubmittedTotal.Should().Be(130_000_000m, "ChoDuyet 30tr + DaDuyet 100tr");
exactBefore.PrevSubmittedCount.Should().Be(2);
exactBefore.PrevSelectedTotal.Should().Be(90_000_000m, "chỉ DaDuyet+winner");
exactBefore.PrevSelectedCount.Should().Be(1);
pendBefore.PendingSubmittedTotal.Should().Be(60_000_000m, "chỉ TraLai (ChoDuyet/DaDuyet bị loại)");
pendBefore.PendingSubmittedCount.Should().Be(1);
pendBefore.PendingSelectedTotal.Should().Be(70_000_000m, "TraLai 20tr + ChoDuyet 50tr");
pendBefore.PendingSelectedCount.Should().Be(2);
pendBefore.PriorPes.Select(p => p.Id).Should().Contain(target.Id,
"tiền đề: phiếu sắp xóa PHẢI đang nằm trong danh sách phiếu trước cần lưu ý");
var priorCountBefore = pendBefore.PriorPes.Count;
priorCountBefore.Should().Be(2, "TraLai + ChoDuyet (DaDuyet là terminal nên không tính)");
// ---------- ACT: đúng người, đúng lượt, đúng cờ ----------
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id, "Bấm sai gói thầu"), CancellationToken.None);
// Neo chống-vacuous: phải là XÓA MỀM thật (row còn, cờ bật) — không phải xóa cứng.
var row = await f.Db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == target.Id);
row.IsDeleted.Should().BeTrue("`Remove` phải đi qua AuditingInterceptor thành xóa MỀM");
// ---------- SAU ----------
var exactAfter = await ExactAsync(f.Db, project.Id, wi.Id);
var pendAfter = await PendingAsync(f.Db, project.Id, wi.Id);
// ↓ giảm ĐÚNG phần đóng góp của phiếu bị xóa, không hơn không kém.
exactAfter.PrevSubmittedTotal.Should().Be(100_000_000m,
"PrevSubmitted phải GIẢM đúng 30tr = BudgetPeriodAmount của phiếu vừa xóa");
exactAfter.PrevSubmittedCount.Should().Be(1);
pendAfter.PendingSelectedTotal.Should().Be(20_000_000m,
"PendingSelected phải GIẢM đúng 50tr = báo giá đã chọn của phiếu vừa xóa");
pendAfter.PendingSelectedCount.Should().Be(1);
// = BẤT ĐỘNG, và cả hai đều ≠ 0 nên phép đo có nội dung.
exactAfter.PrevSelectedTotal.Should().Be(90_000_000m,
"PrevSelected đòi phase DaDuyet ⇒ phiếu ChoDuyet chưa bao giờ nằm trong đó ⇒ KHÔNG đổi");
exactAfter.PrevSelectedCount.Should().Be(1);
pendAfter.PendingSubmittedTotal.Should().Be(60_000_000m,
"PendingSubmitted loại TƯỜNG MINH ChoDuyet (PeBudgetAccumulator.cs:107) ⇒ KHÔNG đổi");
pendAfter.PendingSubmittedCount.Should().Be(1);
// PriorPes: mất ĐÚNG một phần tử, và đúng phần tử vừa xóa.
pendAfter.PriorPes.Should().HaveCount(priorCountBefore - 1, "mất đúng 1 phần tử");
pendAfter.PriorPes.Should().NotContain(p => p.Id == target.Id);
pendAfter.PriorPes.Should().ContainSingle().Which.MaPhieu.Should().Be("PE-T21-TL",
"phần tử còn lại phải là phiếu TraLai — không phải mất nhầm phiếu khác");
}
// =====================================================================
// T22 — gọi màn "Đã xóa" (`IgnoreQueryFilters`) KHÔNG làm phiếu đã xóa lọt NGƯỢC vào lũy kế.
// Ranh giới D3: `IgnoreQueryFilters` chỉ được sống trong CHÍNH query đó. Nếu ai đó gom nó
// vào helper trả `IQueryable` dùng chung / thêm cờ `includeDeleted` lan sang accumulator thì
// 4 con số quay lại giá trị TRƯỚC-XÓA ⇒ ca này đỏ.
// Chứng-nhân: list "Đã xóa" phải THẬT SỰ trả về phiếu đó — nếu list rỗng thì "không rò rỉ"
// chỉ là hệ quả của việc không có gì để rò.
// =====================================================================
[Fact]
public async Task T22_ListingDeletedScreen_DoesNotLeakDeletedPeBackIntoAccumulator()
{
using var f = new SoftDeleteFixture();
var (project, wi, target, alice) = await SeedAccumulationSceneAsync(f, "T22");
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
var deleted = await NewDeletedListHandler(f, admin)
.Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);
deleted.Items.Select(i => i.Id).Should().Contain(target.Id,
"chứng-nhân: màn Đã xóa PHẢI thấy phiếu vừa xóa, nếu không thì assert dưới rỗng nghĩa");
// Ngay SAU khi query bỏ-lọc chạy, lũy kế vẫn phải là số hậu-xóa.
var exact = await ExactAsync(f.Db, project.Id, wi.Id);
var pend = await PendingAsync(f.Db, project.Id, wi.Id);
exact.PrevSubmittedTotal.Should().Be(100_000_000m, "phiếu đã xóa KHÔNG được quay lại PrevSubmitted");
exact.PrevSubmittedCount.Should().Be(1);
pend.PendingSelectedTotal.Should().Be(20_000_000m, "phiếu đã xóa KHÔNG được quay lại PendingSelected");
pend.PendingSelectedCount.Should().Be(1);
pend.PriorPes.Should().NotContain(p => p.Id == target.Id);
}
// =====================================================================
// T22b — list "Đã xóa" KHÔNG chứa phiếu SỐNG.
// 🔴 `IgnoreQueryFilters()` GỠ bộ lọc chứ không ĐẢO nó. Thiếu `.Where(x => x.IsDeleted)`
// (`PeSoftDeleteFeatures.cs:167-168`) thì màn này liệt kê TOÀN BỘ phiếu sống — mà build,
// mọi test khác và cả compiler đều xanh.
// Chứng-nhân: bảng có 3 row (2 sống + 1 xóa) nên nếu gỡ `.Where` thì kết quả là 3 ⇒ đỏ.
// =====================================================================
[Fact]
public async Task T22b_DeletedList_ExcludesLivePes_IgnoreQueryFiltersRemovesFilterNotInvertsIt()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T22b");
var alice = await f.CreateUserAsync("alice.t22b@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t22b@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T22b");
// 2 phiếu SỐNG — Project THẬT (handler INNER JOIN Projects, phiếu không có dự án sẽ tự rơi
// khỏi kết quả và làm phép đo mất răng).
var live1 = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-LIVE1");
var live2 = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-LIVE2",
phase: PurchaseEvaluationPhase.DangSoanThao);
var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-DEL",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);
// Chứng-nhân: bảng CÓ 3 row; chỉ 1 mang cờ IsDeleted.
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters().CountAsync())
.Should().Be(3, "tiền đề: 2 phiếu sống + 1 phiếu đã xóa cùng tồn tại trong bảng");
(await db.PurchaseEvaluations.AsNoTracking().CountAsync())
.Should().Be(2, "global filter cho 2 phiếu sống đi qua");
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
var result = await NewDeletedListHandler(f, admin)
.Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);
result.Total.Should().Be(1, "màn Đã xóa chỉ đếm phiếu đã xóa — bỏ `.Where(IsDeleted)` sẽ ra 3");
result.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id);
result.Items.Select(i => i.Id).Should().NotContain(live1.Id).And.NotContain(live2.Id,
"phiếu đang sống KHÔNG được xuất hiện ở màn Đã xóa");
}
// =====================================================================
// T22c — IDOR: user ngoài phạm vi KHÔNG thấy phiếu đã xóa của người khác.
// Handler MỚI mặc định 0 rào ⇒ khối `PeSoftDeleteFeatures.cs:185-202` là thứ duy nhất chặn.
// 🔴 Chống assert-rỗng-nghĩa: CÙNG dữ liệu, CÙNG câu truy vấn, chỉ đổi NGƯỜI GỌI —
// người ngoài thấy 0, còn drafter và approver đều thấy 1. Nếu gỡ khối IDOR ⇒ dave thấy 1 ⇒ đỏ.
// =====================================================================
[Fact]
public async Task T22c_DeletedList_OutsiderSeesNothing_WhileDrafterAndApproverSeeIt()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T22c");
var alice = await f.CreateUserAsync("alice.t22c@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t22c@t.vn", "Người soạn", AppRoles.Drafter);
var dave = await f.CreateUserAsync("dave.t22c@t.vn", "Người ngoài"); // 0 role, 0 dính líu
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T22c");
var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22c-DEL",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id,
budgetPeriod: 777_000_000m);
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);
var q = new ListDeletedPurchaseEvaluationsQuery();
// (a) NGƯỜI NGOÀI — không phải drafter, không role eligible, không approver quy trình đã pin.
var outsider = await NewDeletedListHandler(f, new TestCurrentUser(dave.Id, dave.FullName, dave.Email))
.Handle(q, CancellationToken.None);
outsider.Total.Should().Be(0,
"gỡ khối IDOR ⇒ mọi user đọc được phiếu đã xóa của dự án khác kèm ngân sách + tên gói thầu");
outsider.Items.Should().BeEmpty();
// (b) ĐỐI CHỨNG 1 — người soạn phiếu (cond `DrafterUserId == userId`).
var asDrafter = await NewDeletedListHandler(f,
new TestCurrentUser(drafter.Id, drafter.FullName, drafter.Email, AppRoles.Drafter))
.Handle(q, CancellationToken.None);
asDrafter.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id,
"chứng-nhân: cùng dữ liệu + cùng truy vấn, người trong cuộc PHẢI thấy — nên số 0 ở trên là do rào, không do rỗng");
// (c) ĐỐI CHỨNG 2 — người duyệt của quy trình đã pin (cond V2-approver).
var asApprover = await NewDeletedListHandler(f,
new TestCurrentUser(alice.Id, alice.FullName, alice.Email))
.Handle(q, CancellationToken.None);
asApprover.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id);
}
// =====================================================================
// T23 — Cấp KHÔNG bật cờ `AllowApproverDelete` ⇒ từ chối, KỂ CẢ khi đúng lượt.
// Biến duy nhất khác T25b là cái cờ (cặp cùng-ruột-khác-1-bit).
// =====================================================================
[Fact]
public async Task T23_ApproverAtCurrentTurn_ButFlagOff_IsForbidden_AndPeSurvives()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T23");
var alice = await f.CreateUserAsync("alice.t23@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t23@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, false)]], "QT-DEL-T23"); // 🔴 cờ TẮT
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T23",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
var act = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ForbiddenException>(
"đúng lượt vẫn chưa đủ — cờ F6 là rào riêng")).Which;
ex.Message.Should().Contain("Xóa phiếu khi đang duyệt",
"thông điệp phải chỉ đúng cái cờ cần bật, không phải lỗi 'chưa tới lượt'");
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted
.Should().BeFalse("ném rồi thì phiếu phải còn nguyên — không xóa nửa vời");
}
// =====================================================================
// T24 — đúng cờ nhưng KHÔNG đúng lượt ⇒ từ chối.
// Cờ của carol để BẬT có chủ ý: để ca này đo đúng MỘT biến "lượt", không lẫn với ca cờ.
// =====================================================================
[Fact]
public async Task T24_ApproverOfLaterLevel_WithFlagOn_ButNotCurrentTurn_IsForbidden()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T24");
var alice = await f.CreateUserAsync("alice.t24@t.vn", "Trần Thị An");
var carol = await f.CreateUserAsync("carol.t24@t.vn", "Phạm Thị Cúc");
var drafter = await f.CreateUserAsync("drafter.t24@t.vn", "Người soạn", AppRoles.Drafter);
// Cấp 1 = alice, Cấp 2 = carol — CẢ HAI đều bật cờ. Con trỏ đang ở Cấp 1.
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true), (2, carol.Id, true)]], "QT-DEL-T24");
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T24",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id, levelOrder: 1);
f.ActAs(carol);
var act = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ForbiddenException>(
"carol có cờ nhưng phiếu chưa tới Cấp 2 — cờ không cấp quyền vượt lượt")).Which;
ex.Message.Should().Contain("chưa tới lượt");
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted.Should().BeFalse();
}
// =====================================================================
// T24b ⭐ PER-NGƯỜI, KHÔNG per-Cấp (owner chốt).
// Cấp 1 có 2 người CÙNG đúng lượt: alice bật cờ, bob KHÔNG. Cùng một phiếu, cùng một lời gọi
// — chỉ khác người thao tác: bob bị chặn, alice xóa được.
// 🔴 Đây là ca phân biệt `matchingLevel.AllowApproverDelete` (đúng) với `g.Any(l => ...)` (sai):
// với `Any`, bob sẽ xóa được ⇒ ca này đỏ ngay. Không có ca này thì hai cách đọc không phân biệt nổi.
// Thứ tự bob-trước-alice là bắt buộc: alice xóa xong thì phiếu biến mất khỏi global filter
// và lượt của bob sẽ nhận NotFound thay vì Forbidden — tức đo nhầm rào.
// =====================================================================
[Fact]
public async Task T24b_SameLevelTwoPeople_OnlyTheTickedOneCanDelete_PerRowNotPerLevel()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T24b");
var alice = await f.CreateUserAsync("alice.t24b@t.vn", "Trần Thị An");
var bob = await f.CreateUserAsync("bob.t24b@t.vn", "Lê Văn Bình");
var drafter = await f.CreateUserAsync("drafter.t24b@t.vn", "Người soạn", AppRoles.Drafter);
// 1 Cấp — 2 dòng cùng Order 1 (OR-of-N). Chỉ alice được tick.
var wf = await SeedWorkflowAsync(db,
[[(1, alice.Id, true), (1, bob.Id, false)]], "QT-DEL-T24b");
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T24b",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
// ---- bob: đúng lượt, KHÔNG được tick ⇒ chặn ----
f.ActAs(bob);
var bobAct = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
var ex = (await bobAct.Should().ThrowAsync<ForbiddenException>(
"quyền huỷ chứng-từ phát theo TỪNG NGƯỜI; đọc per-Cấp bằng `Any` sẽ cho bob lọt")).Which;
ex.Message.Should().Contain("Xóa phiếu khi đang duyệt", "bob bị chặn vì CỜ, không phải vì lượt");
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted
.Should().BeFalse("bob không được xóa gì cả");
// ---- alice: cùng Cấp, cùng lượt, CÓ tick ⇒ xóa được ----
f.Db.ChangeTracker.Clear();
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted
.Should().BeTrue("cùng Cấp mà khác người ⇒ khác kết quả — đó chính là 'per-người'");
}
// =====================================================================
// T25 — quyền ở tầng API (Pattern 10 reflection, không dựng WebApplicationFactory).
// 3 mệnh đề, mất bất kỳ cái nào cũng là lỗ hổng thật:
// (i) `by-approver` PHẢI mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]` —
// thiếu thì mọi tài khoản đăng nhập gọi được (class-level chỉ `[Authorize]` trần).
// (ii) tên policy phải CÓ THẬT trong bộ sinh của `Program.cs` (`MenuKeys.All × Actions`) —
// gắn tên không đăng ký thì ASP.NET ném lúc chạy mà test attribute vẫn xanh.
// (iii) đường xóa NHÁP cũ PHẢI vẫn KHÔNG có policy — gắn vào đó là cướp mất quyền xóa nháp
// của chính Drafter (11/13 role có CanDelete=0).
// =====================================================================
[Fact]
public void T25_DeleteByApprover_MustStayPolicyFree_AndDraftDeleteStaysUngated()
{
var t = typeof(PurchaseEvaluationsController);
// 🔴 ĐỔI SPEC 2026-07-27 (owner chốt sau đo của reviewer) — ca này TRƯỚC ĐÂY đòi
// `Policy == "PurchaseEvaluations.Delete"`. Nay đòi NGƯỢC LẠI: KHÔNG được có policy.
//
// Vì sao: đo trên DB thật, MỌI vai đang làm người duyệt (`Procurement`, `CostControl`, …)
// đều `CanDelete = 0`, và không seeder nào nâng cờ đó (`DbInitializer.cs:2139-2140` chỉ
// nâng Read/Create; `:2538` bỏ qua hàng đã tồn tại). Gắn policy ⇒ **403 với đúng người
// cần dùng**, mà FE gate cùng quyền nên không thấy nút, không thấy lỗi (gotcha #44).
//
// Nguồn quyền THẬT = 3 rào nghiệp vụ trong handler: phase ChoDuyet ∧ đúng lượt ∧
// `matchingLevel.AllowApproverDelete` (cờ per-NGƯỜI admin tick đích danh) — CHẶT HƠN
// permission theo vai. T23 / T24 / T24b / T25b là chứng-nhân cho 3 rào đó.
//
// ⚠️ Ca này là CHỐT CHẶN chống hồi quy: ai đó "siết bảo mật" bằng cách thêm lại
// `[Authorize(Policy=...)]` sẽ làm tính năng CHẾT IM LẶNG trên prod — và test này đỏ ngay.
GetActionAuthorize(t, nameof(PurchaseEvaluationsController.DeleteByApprover))
.Should().BeNull(
"đường xóa-khi-đang-duyệt CỐ Ý không gate bằng quyền theo vai — approver thật đều "
+ "CanDelete=0 nên gắn policy là chặn đúng người cần dùng; quyền nằm ở 3 rào handler");
GetActionAuthorize(t, nameof(PurchaseEvaluationsController.Delete))
.Should().BeNull(
"đường xóa NHÁP cũ phải giữ nguyên KHÔNG policy — gắn vào đó là chặn Drafter xóa nháp của chính mình");
GetActionAuthorize(t, nameof(PurchaseEvaluationsController.ListDeleted))!.Policy
.Should().Be("PurchaseEvaluations.Read", "màn Đã xóa là màn đọc, gate bằng quyền Read");
}
// =====================================================================
// T27 ⭐ HỢP-ĐỒNG ENTITY ⟷ DTO — sinh ra từ một bug THẬT của đợt này.
//
// Chuyện đã xảy ra: cờ `AllowApproverDelete` được thêm vào entity + migration + FE type,
// FE gate nút bằng `levelOptions?.allowApproverDelete === true` — nhưng **DTO không có
// field đó** nên BE không bao giờ gửi ⇒ vế so sánh LUÔN sai ⇒ **nút không hiện với bất
// kỳ ai**. BE build sạch, FE build sạch, 561 test xanh, `grep` tên ROUTE cũng khớp —
// không lưới nào bắt được, vì lỗ nằm ở tầng **tên FIELD**, không phải tên route.
//
// Ca này đóng CẢ LỚP đó: mọi cờ `Allow*` trên slot Cấp duyệt PHẢI có đường ra DTO.
// Thêm cờ mới vào entity mà quên DTO ⇒ đỏ NGAY, kèm tên cờ bị thiếu.
// =====================================================================
[Fact]
public void T27_EveryLevelAllowFlag_MustBeExposedOnOptionsDto()
{
var entityFlags = typeof(ApprovalWorkflowLevel)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.PropertyType == typeof(bool) && p.Name.StartsWith("Allow", StringComparison.Ordinal))
.Select(p => p.Name)
.OrderBy(n => n)
.ToList();
entityFlags.Should().NotBeEmpty("nếu rỗng thì phép đo này vô nghĩa — chứng-nhân cho chính nó");
var dtoMembers = typeof(ApprovalWorkflowOptionsDto)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.ToHashSet(StringComparer.Ordinal);
var missing = entityFlags.Where(f => !dtoMembers.Contains(f)).ToList();
missing.Should().BeEmpty(
"mỗi cờ Allow* trên ApprovalWorkflowLevel phải có đường ra ApprovalWorkflowOptionsDto — "
+ "thiếu field nào thì FE đọc `undefined`, gate fail-closed, nút/quyền tương ứng "
+ "KHÔNG BAO GIỜ hiện, mà cả BE lẫn FE đều build sạch. Cờ đang thiếu: {0}",
string.Join(", ", missing));
}
private static AuthorizeAttribute? GetActionAuthorize(Type controllerType, string methodName)
=> controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.FirstOrDefault(m => m.Name == methodName)
?.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
.FirstOrDefault();
// =====================================================================
// T25b ⭐ CHIỀU THUẬN — đủ quyền ∧ đúng lượt ∧ đúng cờ ⇒ THÀNH CÔNG.
// Không có ca này thì T23/T24/T24b vẫn xanh trọn vẹn kể cả khi tính năng CHẾT HẲN
// (một handler `throw` vô điều kiện làm mọi ca phủ định pass).
// Đo trọn hợp đồng: không ném · row Ở LẠI với IsDeleted/DeletedAt/DeletedBy · biến mất khỏi
// truy vấn thường · hiện ra ở màn "Đã xóa".
// =====================================================================
[Fact]
public async Task T25b_RightPersonRightTurnRightFlag_SoftDeletesSuccessfully_AndShowsInDeletedScreen()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T25b");
var alice = await f.CreateUserAsync("alice.t25b@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t25b@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T25b");
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T25b",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
var act = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
await act.Should().NotThrowAsync("đủ cả 3 điều kiện thì đường này phải đi được — nếu không, tính năng chết");
var row = await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id);
row.IsDeleted.Should().BeTrue();
row.DeletedAt.Should().Be(f.Clock.UtcNow, "AuditingInterceptor đóng dấu thời điểm xóa");
row.DeletedBy.Should().Be(alice.Id, "phải ghi ĐÚNG người bấm xóa");
row.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "xóa mềm KHÔNG đổi trạng thái phiếu");
(await db.PurchaseEvaluations.AsNoTracking().AnyAsync(p => p.Id == pe.Id))
.Should().BeFalse("global filter phải giấu phiếu khỏi mọi truy vấn thường");
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
var deleted = await NewDeletedListHandler(f, admin)
.Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);
deleted.Items.Should().ContainSingle().Which.Id.Should().Be(pe.Id,
"xóa mềm = phiếu đi sang màn Đã xóa, không bốc hơi");
}
// =====================================================================
// T26 — GHI VẾT. `PurchaseEvaluationChangelog` là chỗ DUY NHẤT chứa LÝ DO
// (`DeletedBy`/`DeletedAt` chỉ trả lời ai + lúc nào). Xóa một chứng-từ tài chính mà mất vết
// lý do thì màn "Đã xóa" chỉ còn là danh sách xác không giải thích được.
// Đối chứng trong cùng ca: xóa KHÔNG nhập lý do ⇒ `ContextNote` phải là null (nhánh trim/null),
// để "có ContextNote" ở phiếu trên không thể là hằng số.
//
// 🔴🔴 CA NÀY ĐANG ĐỎ — BUG PRODUCTION, CỐ Ý GIỮ ĐỎ (test-specialist KHÔNG sửa src/Backend).
// Vết KHÔNG BAO GIỜ được ghi. Cơ chế đã đo bằng chẩn đoán riêng (ChangeTracker), không suy đoán:
// `PeSoftDeleteFeatures.cs:126` Add changelog → state = Added, EF fixup gắn nó vào
// `pe.Changelogs` (navigation 2 chiều, `PurchaseEvaluationConfiguration.cs:78`).
// `PeSoftDeleteFeatures.cs:142` Remove(pe) → `CascadeDeleteTiming.Immediate` (mặc định)
// cascade ngay TẠI DÒNG NÀY; dependent đang ở `Added` khi bị cascade thì EF chuyển
// thành **Detached** (xóa một entity Added = gỡ theo dõi). ⇒ đo được: Added → Detached.
// `:143` SaveChanges → 0 row changelog. `AuditingInterceptor` chạy ở SavingChanges, tức SAU
// khi cascade đã xảy ra ⇒ việc nó đổi Deleted→Modified KHÔNG cứu được vết.
// ⇒ Prod dính y hệt: khác biệt duy nhất giữa harness và prod là interceptor, mà interceptor
// nằm SAU điểm mất mát. Xóa mềm vẫn chạy đúng (T25b xanh) — chỉ LÝ DO bị nuốt IM LẶNG.
// Đã grep cùng lớp: đây là site DUY NHẤT trong `src/Backend` có hình "Add con → Remove cha →
// 1 SaveChanges" (đường xóa nháp cũ `PurchaseEvaluationFeatures.cs:1408` không ghi changelog).
// =====================================================================
[Fact]
public async Task T26_Delete_WritesChangelogWithDeleteAction_AndCarriesReasonWhenProvided()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T26");
var alice = await f.CreateUserAsync("alice.t26@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t26@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T26");
var levelId = wf.Steps.Single().Levels.Single().Id;
var withReason = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T26-REASON",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
var noReason = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T26-NOREASON",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(withReason.Id, " Bấm sai gói thầu "),
CancellationToken.None);
f.Db.ChangeTracker.Clear();
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(noReason.Id, " "),
CancellationToken.None);
var logs = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.Action == ChangelogAction.Delete)
.ToListAsync(CancellationToken.None);
logs.Should().HaveCount(2, "mỗi lần xóa phải để lại đúng 1 vết");
var a = logs.Single(l => l.PurchaseEvaluationId == withReason.Id);
a.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
a.EntityId.Should().Be(levelId, "vết trỏ đúng dòng Cấp mà người xóa đang đứng");
a.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "trạng thái TẠI LÚC xóa");
a.UserId.Should().Be(alice.Id);
a.UserName.Should().Be("Trần Thị An");
a.Summary.Should().Contain("Xóa phiếu khi đang duyệt");
a.ContextNote.Should().Be("Bấm sai gói thầu", "lý do phải được lưu và cắt khoảng trắng thừa");
var b = logs.Single(l => l.PurchaseEvaluationId == noReason.Id);
b.ContextNote.Should().BeNull("chuỗi toàn khoảng trắng = không nhập lý do ⇒ lưu null, không lưu rỗng");
// Vết phải ĐỌC ĐƯỢC sau khi phiếu biến mất khỏi global filter (changelog không có IsDeleted
// và truy vấn changelog lọc theo PurchaseEvaluationId chứ không join phiếu).
(await db.PurchaseEvaluations.AsNoTracking().AnyAsync(p => p.Id == withReason.Id))
.Should().BeFalse("tiền đề: phiếu đã bị giấu — vết vẫn phải còn đọc được ở trên");
}
}