import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { archivePurchaseOrder, createPurchaseRequest, deletePurchaseAttachment, fetchPurchaseOrderArchive, fetchPurchaseRequestDetail, fetchPurchaseRequestErpTrack, fetchPurchaseRequests, issuePurchaseAttachmentSignedUrl, precheckPurchaseRequest, requeuePurchaseRequestErpSync, submitPurchaseRequest, updatePurchaseRequest, uploadPurchaseAttachment, withdrawPurchaseRequest, type PurchaseErpBinding, type PurchaseRequestDetail, type PurchaseRequestErpRequeueTarget, type PurchaseRequestErpTrack, type PurchaseRequestItemPayload, type PurchaseRequestStatus, } from "../api/purchaseRequests"; import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities, fetchSuppliers, } from "../api/masterData"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; 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 { SectionPanel } from "../components/patterns/SectionPanel"; import { buildPrecheckDialogMessage } from "../lib/precheck"; const PAGE_SIZE = 20; type DraftItem = { id: string; description: string; specification: string; quantity: string; unitPrice: string; isAssetCandidate: boolean; }; function resolveError(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } 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: "已取消" }, ]; } type PurchaseErpStageKey = keyof PurchaseRequestErpTrack["stages"]; const PURCHASE_ERP_STAGE_META: Record< PurchaseErpStageKey, { title: string; target: PurchaseRequestErpRequeueTarget } > = { commitmentJournal: { title: "Commitment", target: "commitment_journal" }, accrualJournal: { title: "Accrual", target: "accrual_journal" }, archiveSummary: { title: "PO/合同同步", target: "archive_summary" }, }; function formatStageReason(reason: string | null) { switch (reason) { case "purchase_request_not_approved": return "当前采购单尚未处于已通过状态"; case "purchase_order_archive_missing": return "尚未完成 PO/合同归档"; default: return reason ?? null; } } function resolveStageTimestamp(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) { const binding = stage.binding as PurchaseErpBinding | null | undefined; const poBinding = stage.bindings?.po ?? null; const contractBinding = stage.bindings?.contract ?? null; return ( stage.outbox?.updatedAt ?? stage.outbox?.processedAt ?? binding?.lastSyncedAt ?? poBinding?.lastSyncedAt ?? contractBinding?.lastSyncedAt ?? null ); } function resolveStageExternalCode(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) { const binding = stage.binding as PurchaseErpBinding | null | undefined; return binding?.externalCode ?? stage.bindings?.po?.externalCode ?? stage.bindings?.contract?.externalCode ?? null; } function resolveStageExternalId(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) { const binding = stage.binding as PurchaseErpBinding | null | undefined; return binding?.externalId ?? stage.bindings?.po?.externalId ?? stage.bindings?.contract?.externalId ?? null; } function resolveStageSummary(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) { const preview = stage.preview as { documentNo?: string | null; requestNo?: string | null; poNo?: string | null; contractNo?: string | null; totalAmount?: string | null; supplier?: { code?: string | null; }; dimensions?: { legalEntityCode?: string | null; costCenterCode?: string | null; supplierCode?: string | null; }; }; return { documentNo: preview.documentNo ?? preview.requestNo ?? (stageKey === "archiveSummary" ? preview.poNo ?? null : null), totalAmount: preview.totalAmount ?? null, legalEntityCode: preview.dimensions?.legalEntityCode ?? null, costCenterCode: preview.dimensions?.costCenterCode ?? null, supplierCode: preview.dimensions?.supplierCode ?? preview.supplier?.code ?? null, poNo: preview.poNo ?? null, contractNo: preview.contractNo ?? null, }; } function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) { if (!stage.eligible) { return null; } if (stage.status === "failed" || stage.status === "dead_letter") { return { label: stageKey === "archiveSummary" ? "重试同步摘要" : "重试同步", target: PURCHASE_ERP_STAGE_META[stageKey].target, }; } if (stage.status === "not_triggered" || stage.status === "skipped") { return { label: stageKey === "archiveSummary" ? "补发摘要" : "补发", target: PURCHASE_ERP_STAGE_META[stageKey].target, }; } return null; } export function PurchaseRequestsPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const canCreate = hasPermission(permissions, "procurement.create"); const canManage = hasPermission(permissions, "procurement.manage"); const canIntegrationManage = hasPermission(permissions, "integration.manage"); const canViewAll = hasPermission(permissions, "procurement.view_all") || canManage; const [status, setStatus] = useState("draft"); const [mine, setMine] = useState(true); const [supplierFilter, setSupplierFilter] = useState(""); const [legalEntityFilter, setLegalEntityFilter] = useState(""); const [costCenterFilter, setCostCenterFilter] = useState(""); const [page, setPage] = useState(1); const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? ""); 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 [expectedDeliveryDate, setExpectedDeliveryDate] = useState(""); const [items, setItems] = useState([]); const [archivePoNo, setArchivePoNo] = useState(""); const [archiveContractNo, setArchiveContractNo] = useState(""); 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({ queryKey: ["purchase-requests", "document-categories"], queryFn: () => fetchDocumentCategories({ entityType: "purchase_request", enabled: true }), }); const listQuery = useQuery({ 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: Boolean(selectedId), queryKey: ["purchase-requests", "detail", selectedId], queryFn: () => fetchPurchaseRequestDetail(selectedId), }); const archiveOrderQuery = useQuery({ enabled: Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)), queryKey: ["purchase-requests", "archive-order", selectedId], queryFn: () => fetchPurchaseOrderArchive(selectedId), }); const erpTrackQuery = useQuery({ enabled: canIntegrationManage && Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)), queryKey: ["purchase-requests", "erp-track", selectedId], queryFn: () => fetchPurchaseRequestErpTrack(selectedId), }); useEffect(() => { const searchSelectedId = searchParams.get("id") ?? ""; if (searchSelectedId && searchSelectedId !== selectedId) { setSelectedId(searchSelectedId); } }, [searchParams, selectedId]); useEffect(() => { if (selectedId) { if (detailQuery.isLoading) { return; } if (!detailQuery.isError) { return; } } const nextId = listQuery.data?.items?.[0]?.id ?? ""; if (nextId !== selectedId) { setSelectedId(nextId); } }, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]); useEffect(() => { const currentId = searchParams.get("id") ?? ""; if (!selectedId && !currentId) { return; } if (selectedId === currentId) { return; } const next = new URLSearchParams(searchParams); if (selectedId) { next.set("id", selectedId); } else { next.delete("id"); } setSearchParams(next, { replace: true }); }, [searchParams, selectedId, setSearchParams]); useEffect(() => { if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) { return; } setTitle(detailQuery.data.title || ""); setReason(detailQuery.data.reason || ""); setCurrencyCode(detailQuery.data.currencyCode || "CNY"); setLegalEntityId(detailQuery.data.legalEntityId || ""); setCostCenterId(detailQuery.data.costCenterId || ""); setSupplierId(detailQuery.data.supplierId || ""); setDocumentCategoryId(detailQuery.data.documentCategoryId || ""); setExpectedDeliveryDate(detailQuery.data.expectedDeliveryDate || ""); setItems( detailQuery.data.items.map((item) => ({ id: item.id, description: item.description, specification: item.specification || "", quantity: item.quantity, unitPrice: item.unitPrice, isAssetCandidate: item.isAssetCandidate, })), ); 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 totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const purchaseItems = listQuery.data?.items ?? []; const totalAmountPreview = useMemo( () => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2), [items], ); const createMutation = useMutation({ mutationFn: () => createPurchaseRequest(), onSuccess: async (data) => { setSelectedId(data.purchaseRequest.id); setHydratedDetailId(""); await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] }); await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] }); toast.success("采购草稿已创建"); }, 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; 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, 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(""); 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"] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }), ]); setFormError(""); toast.success( data.precheckDecision === "manual_review" ? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险` : data.warnings.includes("missing_budget_pool") ? "采购申请已提交(未匹配预算池)" : "采购申请已提交", ); }, 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] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }), ]); setFormError(""); 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] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }), ]); toast.success("PO/合同归档已保存"); }, onError: (error) => { const message = resolveError(error); toast.error(message); }, }); const requeueErpMutation = useMutation({ mutationFn: async (payload: { target: PurchaseRequestErpRequeueTarget; label: string }) => { if (!selectedId) { throw new Error("请先选择采购单"); } return { label: payload.label, response: await requeuePurchaseRequestErpSync(selectedId, { targets: [payload.target] }), }; }, onSuccess: async ({ label, response }) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }), queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }), ]); const firstItem = response.items[0]; if (firstItem?.duplicated) { toast.success(`${label} 已存在待处理记录`); return; } if (firstItem?.requeued) { toast.success(`${label} 已重新入队`); return; } toast.success(`${label} 已加入同步队列`); }, 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: string) => { if (!selectedId) { throw new Error("请先选择采购单"); } return deletePurchaseAttachment(selectedId, attachmentId); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }); toast.success("附件已删除"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); async function handleDownloadAttachment(attachmentId: string) { if (!selectedId) { return; } try { const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachmentId); window.open(response.signedUrl, "_blank", "noopener,noreferrer"); } catch (error) { const message = resolveError(error); setFormError(message); toast.error(message); } } const detail = detailQuery.data; const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null; const handleSubmitAction = async () => { if (!selectedId) { return; } try { setFormError(""); validateBeforeSubmit(); await updatePurchaseRequest(selectedId, buildPayload()); 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") && !window.confirm(message)) { return; } submitMutation.mutate(); } catch (error) { const message = resolveError(error); setFormError(message); toast.error(message); } }; const erpTrack = erpTrackQuery.data ?? null; const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry); const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder)); const canEditDraft = Boolean(detail?.permissions.canEditDraft); const canSubmit = Boolean(detail?.permissions.canSubmit); const canWithdraw = Boolean(detail?.permissions.canWithdraw); const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage); return (
createMutation.mutate()} type="button" > {createMutation.isPending ? "创建中..." : "新建采购"} ) : undefined } /> {formError ? (

{formError}

) : null}
{canViewAll ? (
) : null}

采购单列表

共 {listQuery.data?.total ?? 0} 条
{listQuery.isLoading ? : null} {listQuery.isError ? (

{resolveError(listQuery.error)}

) : null} {!listQuery.isLoading && !listQuery.isError ? ( <>
{purchaseItems.length ? ( purchaseItems.map((item) => ( )) ) : (
暂无采购申请
)}
单据申请人状态金额 {purchaseItems.length ? ( purchaseItems.map((item) => ( { setSelectedId(item.id); setHydratedDetailId(""); }} >
{item.requestNo || item.title || item.id}
{item.title || "未命名采购申请"}
{item.applicant?.name ?? "-"} {item.totalAmount}
)) ) : ( 暂无采购申请 )}
) : null}
{!selectedId ? (
请先在左侧选择采购单,或新建一张采购草稿。
) : null} {selectedId && detailQuery.isLoading ? : null} {selectedId && detailQuery.isError ? (

{resolveError(detailQuery.error)}

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

{detail.requestNo ?? "采购草稿"}

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

setTitle(event.target.value)} disabled={!canEditDraft} />
setCurrencyCode(event.target.value.toUpperCase())} disabled={!canEditDraft} />
setExpectedDeliveryDate(event.target.value)} />