[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
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:
@ -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_(.+)$/)
|
||||
|
||||
@ -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 “Đã xóa” 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 “{evaluation.tenGoiThau}”?</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> và chuyển sang mục <strong>“Đã xóa”</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 là lý do chính của nút này.</li>
|
||||
<li>Mục “Đã xóa” hiện <strong>chỉ để xem</strong>: chưa có 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, lý do) vào lịch sử phiếu.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Label>Lý do xóa (nên ghi rõ)</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">
|
||||
Lý do lưu vào lịch sử phiếu (tối đa 2000 ký tự). Bỏ trống vẫn xóa được, nhưng người
|
||||
sau sẽ không biết vì 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} />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 “Đã xóa” 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 “{evaluation.tenGoiThau}”?</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> và chuyển sang mục <strong>“Đã xóa”</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 là lý do chính của nút này.</li>
|
||||
<li>Mục “Đã xóa” hiện <strong>chỉ để xem</strong>: chưa có 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, lý do) vào lịch sử phiếu.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Label>Lý do xóa (nên ghi rõ)</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">
|
||||
Lý do lưu vào lịch sử phiếu (tối đa 2000 ký tự). Bỏ trống vẫn xóa được, nhưng người
|
||||
sau sẽ không biết vì 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} />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user