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

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

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

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

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

View File

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