import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage"; import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { isApiError } from "../api/types"; import { archivePurchaseOrder, createPurchaseRequest, deletePurchaseAttachment, downloadPurchaseAttachment, fetchPurchaseOrderArchive, fetchPurchaseRequestDetail, fetchPurchaseRequests, precheckPurchaseRequest, submitPurchaseRequest, updatePurchaseRequest, uploadPurchaseAttachment, withdrawPurchaseRequest, type PurchaseRequestDetail, type PurchaseRequestItemPayload, type PurchaseRequestStatus, } from "../api/purchaseRequests"; import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities, fetchSuppliers, } from "../api/masterData"; import { fetchProjects } from "../api/projects"; import { remindWorkflowTask } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges"; import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog"; import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { DateTextInput } from "../components/common/DateTextInput"; import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard"; import { Pagination } from "../components/common/Pagination"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Textarea } from "../components/ui/textarea"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { BusinessListToolbar, BusinessMobileList, BusinessTableFrame, } from "../components/patterns/BusinessListTemplate"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat"; import { downloadBlob } from "../lib/download"; import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildPurchaseRequestDetailPath, readPurchaseListFilters, resolvePurchaseRequestLegacyRedirect, } from "../lib/businessDetailRoutes"; import { buildPcDetailSource, resolvePcDetailSource, type PcDetailNavigationState, } from "../lib/pcDetailNavigation"; import { formatApprovalHistoryTime, resolvePurchaseBudgetHint, resolvePurchaseDisplayCode, resolvePurchaseStatusHint, resolvePurchaseSubmitWarning, workflowActionLabel, } from "../lib/approvalDisplay"; const PAGE_SIZE = 20; const MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH = 180; type DraftItem = { id: string; description: string; specification: string; quantity: string; unitPrice: string; isAssetCandidate: boolean; }; function purchaseDraftSignature(value: { title: string; reason: string; currencyCode: string; legalEntityId: string; costCenterId: string; supplierId: string; documentCategoryId: string; projectId: string; expectedDeliveryDate: string; items: DraftItem[]; }) { return JSON.stringify(value); } function resolveError(error: unknown) { return resolveUserErrorMessage(error, "请求失败,请稍后重试。"); } function sanitizePurchaseAttachmentDownloadName(value?: string) { const cleaned = (value ?? "") .normalize("NFC") .replace(/[\u0000-\u001f\u007f-\u009f/\\]+/g, "_") .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]+/g, "_") .replace(/\s+/g, " ") .replace(/^[\s._]+|[\s._]+$/g, "") .trim(); const fallback = cleaned || "采购附件"; const characters = Array.from(fallback); if (characters.length <= MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH) { return fallback; } const extension = fallback.match(/\.[a-z0-9]{1,10}$/i)?.[0] ?? ""; const stem = extension ? fallback.slice(0, -extension.length) : fallback; const stemLength = Math.max(1, MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH - Array.from(extension).length); return `${Array.from(stem).slice(0, stemLength).join("")}${extension}`; } function moneyPreview(quantity: string, unitPrice: string) { const quantityNumber = Number(quantity); const unitPriceNumber = Number(unitPrice); if (!Number.isFinite(quantityNumber) || !Number.isFinite(unitPriceNumber)) { return "0.00"; } return (quantityNumber * unitPriceNumber).toFixed(2); } function emptyDraftItem(): DraftItem { return { id: `draft-${crypto.randomUUID()}`, description: "", specification: "", quantity: "1.00", unitPrice: "0.00", isAssetCandidate: false, }; } function statusOptions(): Array<{ value: PurchaseRequestStatus | ""; label: string }> { return [ { value: "", label: "全部状态" }, { value: "draft", label: "草稿" }, { value: "reviewing", label: "审批中" }, { value: "approved", label: "已通过" }, { value: "rejected", label: "已驳回" }, { value: "cancelled", label: "已取消" }, ]; } function formatPurchaseCurrency(value: string | number | null | undefined, currencyCode = "CNY") { const amount = Number(value ?? 0); if (!Number.isFinite(amount)) { return "-"; } return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount); } function formatDate(value: string | null | undefined) { return value ? value.slice(0, 10) : "-"; } function resolvePurchaseEvidenceHint(item: { supplierId: string | null; legalEntityId: string | null; costCenterId: string | null; documentCategoryId: string | null; budgetPoolId: string | null; projectId: string | null; status: PurchaseRequestStatus; }) { const missingFields = [ item.supplierId ? null : "供应商", item.legalEntityId ? null : "公司", item.costCenterId ? null : "成本中心", item.documentCategoryId ? null : "单据分类", ].filter(Boolean); if (missingFields.length) { return { label: `待补:${missingFields.slice(0, 2).join("、")}${missingFields.length > 2 ? "等" : ""}`, className: "text-amber-700", }; } if (!item.budgetPoolId) { return { label: "预算池未匹配", className: "text-amber-700" }; } if (!item.projectId) { return { label: "未关联项目", className: "text-zinc-500" }; } if (item.status === "approved") { return { label: "可核对验收 / 归档", className: "text-emerald-700" }; } return { label: "基础依据已齐,附件进详情核对", className: "text-emerald-700" }; } function resolvePurchaseNextStep(item: { status: PurchaseRequestStatus; pendingWorkflow?: PurchaseRequestDetail["pendingWorkflow"] }) { if (item.status === "draft") { return "继续完善材料并提交"; } if (item.status === "reviewing") { return item.pendingWorkflow?.task?.stepName ? `看审批进度:${item.pendingWorkflow.task.stepName}` : "查看审批进度"; } if (item.status === "rejected") { return "按意见补材料后重交"; } if (item.status === "approved") { return "核对到货 / 归档 PO"; } return "查看取消记录"; } function resolvePurchaseRowActionLabel(status: PurchaseRequestStatus) { if (status === "draft") return "继续编辑"; if (status === "rejected") return "补材料"; if (status === "reviewing") return "看进度"; if (status === "approved") return "验收/归档"; return "查看记录"; } function resolvePurchaseEmptyStateCopy(status: PurchaseRequestStatus | "") { if (status === "draft") { return { title: "还没有采购草稿", description: "需要采购时先新建草稿,再补供应商、明细、预算和附件。", }; } if (status === "reviewing") { return { title: "暂无审批中的采购", description: "提交后的采购会在这里显示当前审批节点。", }; } if (status === "rejected") { return { title: "暂无需要补材料的采购", description: "被退回或驳回的采购会在这里继续修改后重新提交。", }; } if (status === "approved") { return { title: "暂无已通过待跟进采购", description: "采购通过后,在这里核对到货、PO/合同归档和付款关联状态。", }; } if (status === "cancelled") { return { title: "暂无已取消采购", description: "取消的采购记录会在这里留痕。", }; } return { title: "暂无采购申请", description: "当前筛选范围内没有采购记录。", }; } function CompactBusinessCode({ value, copyable = true }: { value: string; copyable?: boolean }) { const displayValue = formatCompactIdentifier(value, { head: 12, tail: 4, minLength: 18 }); const compacted = isCompactedIdentifier(value, displayValue); async function copyBusinessCode() { if (!compacted) return; try { await navigator.clipboard.writeText(value); toast.success("已复制完整单据编号"); } catch { toast.error("复制失败,可在详情页查看完整编号"); } } return ( {displayValue} {compacted && copyable ? ( ) : null} ); } export function PurchaseRequestsPage() { const [searchParams] = useSearchParams(); const legacyRedirect = resolvePurchaseRequestLegacyRedirect(searchParams); if (legacyRedirect) { return ; } return ; } export function PurchaseRequestDetailPage() { const { purchaseRequestId = "" } = useParams(); return ; } function PurchaseRequestsWorkspace({ mode, purchaseRequestId = "", }: { mode: "list" | "detail"; purchaseRequestId?: string; }) { const queryClient = useQueryClient(); const { me, permissions } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); const canCreate = hasPermission(permissions, "procurement.create"); const canManage = hasPermission(permissions, "procurement.manage"); const canViewAll = hasPermission(permissions, "procurement.view_all") || canManage; const isPurchaseDetailMode = mode === "detail"; const selectedId = isPurchaseDetailMode ? purchaseRequestId.trim() : ""; const listFilters = useMemo( () => readPurchaseListFilters(searchParams, canViewAll), [canViewAll, searchParams], ); const { status, mine, supplierId: supplierFilter, legalEntityId: legalEntityFilter, costCenterId: costCenterFilter, page, } = listFilters; const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [hydratedDetailId, setHydratedDetailId] = useState(""); const [formError, setFormError] = useState(""); const [title, setTitle] = useState(""); const [reason, setReason] = useState(""); const [currencyCode, setCurrencyCode] = useState("CNY"); const [legalEntityId, setLegalEntityId] = useState(""); const [costCenterId, setCostCenterId] = useState(""); const [supplierId, setSupplierId] = useState(""); const [documentCategoryId, setDocumentCategoryId] = useState(""); const [projectId, setProjectId] = useState(""); const [expectedDeliveryDate, setExpectedDeliveryDate] = useState(""); const [items, setItems] = useState([]); const [savedDraftSignature, setSavedDraftSignature] = useState(() => purchaseDraftSignature({ title: "", reason: "", currencyCode: "CNY", legalEntityId: "", costCenterId: "", supplierId: "", documentCategoryId: "", projectId: "", expectedDeliveryDate: "", items: [], }), ); const [archivePoNo, setArchivePoNo] = useState(""); const [archiveContractNo, setArchiveContractNo] = useState(""); const [selectedAttachmentName, setSelectedAttachmentName] = useState(""); const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null); const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState(null); const attachmentInputRef = useRef(null); const currentSource = buildPcDetailSource(location.pathname, location.search); const navigationState = location.state as PcDetailNavigationState | null; const backSource = resolvePcDetailSource(navigationState?.from, currentSource, "purchase-request") ?? "/purchase-requests"; const backLabel = backSource.startsWith("/projects/") ? "返回项目详情" : "返回采购列表"; const listSource = buildPcDetailSource(location.pathname, location.search); function updateListSearch( updates: Record, options: { resetPage?: boolean } = {}, ) { const next = new URLSearchParams(searchParams); for (const [key, value] of Object.entries(updates)) { if (value) next.set(key, value); else next.delete(key); } if (options.resetPage) { next.delete("page"); } setSearchParams(next, { replace: true }); } const suppliersQuery = useQuery({ queryKey: ["purchase-requests", "suppliers"], queryFn: () => fetchSuppliers({ enabled: true }), }); const legalEntitiesQuery = useQuery({ queryKey: ["purchase-requests", "legal-entities"], queryFn: () => fetchLegalEntities({ enabled: true }), }); const costCentersQuery = useQuery({ queryKey: ["purchase-requests", "cost-centers"], queryFn: () => fetchCostCenters({ enabled: true }), }); const categoriesQuery = useQuery({ enabled: isPurchaseDetailMode, queryKey: ["purchase-requests", "document-categories"], queryFn: () => fetchDocumentCategories({ entityType: "purchase_request", enabled: true }), }); const projectsQuery = useQuery({ enabled: isPurchaseDetailMode, queryKey: ["projects", "purchase-request-link"], queryFn: () => fetchProjects({ mine: true }), }); const listQuery = useQuery({ enabled: !isPurchaseDetailMode, queryKey: ["purchase-requests", "list", status, mine, supplierFilter, legalEntityFilter, costCenterFilter, page], queryFn: () => fetchPurchaseRequests({ status: status || undefined, mine: canViewAll ? mine : true, supplierId: supplierFilter || undefined, legalEntityId: legalEntityFilter || undefined, costCenterId: costCenterFilter || undefined, page, pageSize: PAGE_SIZE, }), }); const detailQuery = useQuery({ enabled: isPurchaseDetailMode && Boolean(selectedId), queryKey: ["purchase-requests", "detail", selectedId], queryFn: () => fetchPurchaseRequestDetail(selectedId), }); const archiveOrderQuery = useQuery({ enabled: isPurchaseDetailMode && Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)), queryKey: ["purchase-requests", "archive-order", selectedId], queryFn: () => fetchPurchaseOrderArchive(selectedId), }); useEffect(() => { if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) { return; } const nextDraft = { title: detailQuery.data.title || "", reason: detailQuery.data.reason || "", currencyCode: detailQuery.data.currencyCode || "CNY", legalEntityId: detailQuery.data.legalEntityId || "", costCenterId: detailQuery.data.costCenterId || "", supplierId: detailQuery.data.supplierId || "", documentCategoryId: detailQuery.data.documentCategoryId || "", projectId: detailQuery.data.projectId || "", expectedDeliveryDate: detailQuery.data.expectedDeliveryDate || "", items: detailQuery.data.items.map((item) => ({ id: item.id, description: item.description, specification: item.specification || "", quantity: item.quantity, unitPrice: item.unitPrice, isAssetCandidate: item.isAssetCandidate, })), }; setTitle(nextDraft.title); setReason(nextDraft.reason); setCurrencyCode(nextDraft.currencyCode); setLegalEntityId(nextDraft.legalEntityId); setCostCenterId(nextDraft.costCenterId); setSupplierId(nextDraft.supplierId); setDocumentCategoryId(nextDraft.documentCategoryId); setProjectId(nextDraft.projectId); setExpectedDeliveryDate(nextDraft.expectedDeliveryDate); setItems(nextDraft.items); setSavedDraftSignature(purchaseDraftSignature(nextDraft)); setArchivePoNo(detailQuery.data.archiveOrder?.poNo || ""); setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || ""); setFormError(""); setHydratedDetailId(detailQuery.data.id); }, [detailQuery.data, hydratedDetailId, selectedId]); useEffect(() => { if (!archiveOrderQuery.data) { return; } setArchivePoNo((current) => current || archiveOrderQuery.data?.archiveOrder?.poNo || ""); setArchiveContractNo((current) => current || archiveOrderQuery.data?.archiveOrder?.contractNo || ""); }, [archiveOrderQuery.data]); const filteredCostCenters = useMemo(() => { if (!legalEntityId) { return []; } return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityId); }, [costCentersQuery.data?.items, legalEntityId]); const filterCostCenters = useMemo(() => { if (!legalEntityFilter) { return costCentersQuery.data?.items ?? []; } return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityFilter); }, [costCentersQuery.data?.items, legalEntityFilter]); const projectOptions = useMemo(() => { const activeItems = (projectsQuery.data?.items ?? []) .filter((item) => item.status !== "archived") .map((item) => ({ id: item.id, projectNo: item.projectNo, name: item.name, status: item.status, })); const currentProject = detailQuery.data?.project; if (currentProject && currentProject.id === projectId && !activeItems.some((item) => item.id === currentProject.id)) { return [ { id: currentProject.id, projectNo: currentProject.projectNo, name: currentProject.name, status: currentProject.status || "archived", }, ...activeItems, ]; } return activeItems; }, [detailQuery.data?.project, projectId, projectsQuery.data?.items]); const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const purchaseItems = listQuery.data?.items ?? []; const hasAdvancedFilters = Boolean(supplierFilter || legalEntityFilter || costCenterFilter || (canViewAll && !mine)); const canShowAdvancedFilters = canViewAll || Boolean((suppliersQuery.data?.items ?? []).length) || Boolean((legalEntitiesQuery.data?.items ?? []).length) || Boolean((costCentersQuery.data?.items ?? []).length); const showAdvancedFilterPanel = canShowAdvancedFilters && (showAdvancedFilters || hasAdvancedFilters); const emptyState = resolvePurchaseEmptyStateCopy(status); const totalAmountPreview = useMemo( () => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2), [items], ); const assetCandidateCount = useMemo( () => items.filter((item) => item.isAssetCandidate).length, [items], ); const createMutation = useMutation({ mutationFn: () => createPurchaseRequest(), onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] }); await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] }); toast.success("采购草稿已创建"); navigate(buildPurchaseRequestDetailPath(data.purchaseRequest.id), { state: { from: listSource }, }); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); function buildPayload(): { title: string; reason: string | null; currencyCode: string; legalEntityId: string | null; costCenterId: string | null; supplierId: string | null; documentCategoryId: string | null; projectId: string | null; expectedDeliveryDate: string | null; items: PurchaseRequestItemPayload[]; } { return { title: title.trim(), reason: reason.trim() || null, currencyCode: currencyCode.trim().toUpperCase() || "CNY", legalEntityId: legalEntityId || null, costCenterId: costCenterId || null, supplierId: supplierId || null, documentCategoryId: documentCategoryId || null, projectId: projectId || null, expectedDeliveryDate: expectedDeliveryDate || null, items: items.map((item) => ({ description: item.description.trim(), specification: item.specification.trim() || null, quantity: item.quantity || "0.00", unitPrice: item.unitPrice || "0.00", isAssetCandidate: item.isAssetCandidate, })), }; } function validateBeforeSubmit() { if (!supplierId) { throw new Error("提交前请选择供应商"); } if (!legalEntityId) { throw new Error("提交前请选择公司"); } if (!costCenterId) { throw new Error("提交前请选择成本中心"); } if (!documentCategoryId) { throw new Error("提交前请选择单据分类"); } if (!items.length) { throw new Error("提交前至少添加一条明细"); } if (items.some((item) => !item.description.trim())) { throw new Error("请补齐所有明细描述"); } } const saveMutation = useMutation({ mutationFn: async () => { if (!selectedId) { throw new Error("请先选择采购单"); } return updatePurchaseRequest(selectedId, buildPayload()); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), ]); setFormError(""); setSavedDraftSignature(currentDraftSignature); toast.success("采购草稿已保存"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const submitMutation = useMutation({ mutationFn: async () => { if (!selectedId) { throw new Error("请先选择采购单"); } return submitPurchaseRequest(selectedId); }, onSuccess: async (data) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), ]); setFormError(""); toast.success( data.precheckDecision === "manual_review" ? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险` : resolvePurchaseSubmitWarning(data.warnings), ); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const withdrawMutation = useMutation({ mutationFn: async () => { if (!selectedId) { throw new Error("请先选择采购单"); } return withdrawPurchaseRequest(selectedId); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }), ]); setFormError(""); toast.success("采购申请已撤回到草稿"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const remindMutation = useMutation({ mutationFn: (taskId: string) => remindWorkflowTask(taskId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }), queryClient.invalidateQueries({ queryKey: ["notifications"] }), ]); toast.success("已催办当前审批人"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const archiveMutation = useMutation({ mutationFn: async () => { if (!selectedId) { throw new Error("请先选择采购单"); } if (!archivePoNo.trim()) { throw new Error("请填写 PO 号"); } return archivePurchaseOrder(selectedId, { poNo: archivePoNo.trim(), contractNo: archiveContractNo.trim() || null, archivedFiles: archiveOrderQuery.data?.archiveOrder?.archivedFiles ?? [], }); }, onSuccess: async (data) => { if (selectedId) { setArchivePoNo(data.archiveOrder.poNo ?? ""); setArchiveContractNo(data.archiveOrder.contractNo ?? ""); queryClient.setQueryData<{ archiveOrder: Awaited>["archiveOrder"] }>( ["purchase-requests", "archive-order", selectedId], { archiveOrder: data.archiveOrder }, ); queryClient.setQueryData(["purchase-requests", "detail", selectedId], (current) => current ? { ...current, archiveOrder: data.archiveOrder, } : current, ); } await Promise.all([ queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }), ]); toast.success("PO/合同归档已保存"); }, onError: (error) => { const message = resolveError(error); toast.error(message); }, }); const uploadMutation = useMutation({ mutationFn: async (file: File) => { if (!selectedId) { throw new Error("请先选择采购单"); } return uploadPurchaseAttachment(selectedId, file); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }); toast.success("附件已上传"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const deleteAttachmentMutation = useMutation({ mutationFn: async ({ attachmentId, reason }: { attachmentId: string; reason: string }) => { if (!selectedId) { throw new Error("请先选择采购单"); } return deletePurchaseAttachment(selectedId, attachmentId, reason); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }); setDeleteAttachmentTarget(null); toast.success("附件已删除"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog(); async function handleDownloadAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) { if (!selectedId) { return; } if (attachment.governanceStatus === "quarantined") { toast.error("附件已隔离,无法下载,请联系管理员。"); return; } const reason = await requestDownloadReason(attachment); if (reason === null) { return; } try { const response = await downloadPurchaseAttachment(selectedId, attachment.id, reason); downloadBlob(response.blob, sanitizePurchaseAttachmentDownloadName(attachment.originalName)); } catch (error) { const message = resolveError(error); setFormError(message); toast.error(message); } } const detail = detailQuery.data; const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null; const detailAttachments = detail?.attachments ?? []; const detailApprovalHistory = detail?.approvalHistory ?? []; const detailRiskFlags = detail?.riskFlags ?? []; const handleSubmitAction = async () => { if (!selectedId) { return; } try { setFormError(""); validateBeforeSubmit(); await updatePurchaseRequest(selectedId, buildPayload()); setSavedDraftSignature(currentDraftSignature); const precheck = await precheckPurchaseRequest(selectedId); const message = buildPrecheckDialogMessage(precheck); if (precheck.decision === "block") { setFormError(message); toast.error("提交前校验未通过"); return; } if (precheck.decision === "warn" || precheck.decision === "manual_review") { setSubmitPrecheckConfirm(message); return; } submitMutation.mutate(); } catch (error) { const message = resolveError(error); setFormError(message); toast.error(message); } }; const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder)); const canEditDraft = Boolean(detail?.permissions.canEditDraft); const currentDraftSignature = useMemo( () => purchaseDraftSignature({ title, reason, currencyCode, legalEntityId, costCenterId, supplierId, documentCategoryId, projectId, expectedDeliveryDate, items, }), [ costCenterId, currencyCode, documentCategoryId, expectedDeliveryDate, items, legalEntityId, projectId, reason, supplierId, title, ], ); const runGuarded = useUnsavedChangesGuard( Boolean(selectedId) && canEditDraft && currentDraftSignature !== savedDraftSignature, "采购表单", ); const canSubmit = Boolean(detail?.permissions.canSubmit); const canWithdraw = Boolean(detail?.permissions.canWithdraw); const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage); const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null; const isPurchaseParticipant = Boolean( detail && me?.id && [detail.applicantId, detail.submitterId].includes(me.id), ); const canRemindPendingWorkflow = Boolean( detail?.status === "reviewing" && pendingTaskId && isPurchaseParticipant && detail?.pendingWorkflow?.task?.assigneeId !== me?.id, ); const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null; const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : ""; const openPurchaseDetail = (id: string) => { runGuarded(() => { navigate(buildPurchaseRequestDetailPath(id), { state: { from: listSource }, }); }); }; const returnToPurchaseList = () => { runGuarded(() => { navigate(backSource, { replace: true }); }); }; const detailAccessDenied = detailQuery.isError && isApiError(detailQuery.error) && [403, 404].includes(detailQuery.error.status); return ( {downloadReasonDialog} {isPurchaseDetailMode ? ( ) : ( <> {canCreate ? ( ) : null} )} } /> {formError ? (

{formError}

) : null} {!isPurchaseDetailMode ? ( <> {mine ? "我相关" : "全范围"} {canShowAdvancedFilters ? ( ) : null} } advanced={showAdvancedFilterPanel ? (
{canViewAll ? (
) : null}
) : null} > { updateListSearch( { status: value === "draft" ? null : value || "all" }, { resetPage: true }, ); }} > {statusOptions().map((item) => ( {item.label} ))}
{listQuery.isLoading ? : null} {listQuery.isError ? (

{resolveError(listQuery.error)}

) : null} {!listQuery.isLoading && !listQuery.isError ? ( <> {purchaseItems.length ? ( purchaseItems.map((item) => { const evidenceHint = resolvePurchaseEvidenceHint(item); return ( ); }) ) : (
{emptyState.title}
{emptyState.description}
)}
单据 / 对象金额 / 供应商项目 / 合同状态 / 证据操作 {purchaseItems.length ? ( purchaseItems.map((item) => { const evidenceHint = resolvePurchaseEvidenceHint(item); return ( openPurchaseDetail(item.id)} >
{formatTextWithCompactLongNumbers(item.title || "未命名采购申请")}
申请人:{item.applicant?.name ?? "-"}
{formatPurchaseCurrency(item.totalAmount, item.currencyCode)}
{item.supplier?.name ?? "待补供应商"}
到货:{formatDate(item.expectedDeliveryDate)}
{item.project ? (
{formatTextWithCompactLongNumbers(item.project.projectNo)}
{item.project.name}
) : ( 未关联项目 )}
{evidenceHint.label}
); }) ) : (
{emptyState.title}
{emptyState.description}
)}
) : null} updateListSearch({ page: nextPage > 1 ? String(nextPage) : null })} testIdPrefix="purchase-requests" /> ) : (
{selectedId && detailQuery.isLoading ? : null} {isPurchaseDetailMode && (!selectedId || detailQuery.isError) ? (

{!selectedId || detailAccessDenied ? "无法查看该记录,记录可能不存在或你无权访问。" : resolveError(detailQuery.error)}

) : null} {selectedId && detail ? ( <>

申请人:{detail.applicant?.name ?? "-"}

当前状态与下一步

{resolvePurchaseStatusHint(detail)}

下一步:{detailNextStepText}

{canEditDraft ? ( ) : null} {canSubmit ? ( ) : null} {canWithdraw ? ( ) : null} {canRemindPendingWorkflow && pendingTaskId ? ( ) : null}
总金额
{formatPurchaseCurrency(totalAmountPreview, currencyCode)}
当前节点
{detail.pendingWorkflow?.task?.stepName ?? "-"}
材料摘要
{detailEvidenceHint?.label ?? "-"} · 附件 {detailAttachments.length} 个
资产候选
{assetCandidateCount > 0 ? `${assetCandidateCount} 项需后续人工建卡判断` : "无资产候选"}
setTitle(event.target.value)} disabled={!canEditDraft} />
setCurrencyCode(event.target.value.toUpperCase())} disabled={!canEditDraft} />
setExpectedDeliveryDate(event.target.value)} />