import { useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { cancelPaymentBatch, createPaymentBatch, downloadPaymentBatchReceiptAttachment, executePaymentBatch, fetchPaymentBatchCandidates, fetchPaymentBatchReceiptAttachments, fetchPaymentBatches, reviewPaymentBatch, updatePaymentBatchItem, uploadPaymentBatchReceiptAttachments, voidPaymentBatchReceiptAttachment, type PaymentBatch, type PaymentBatchReceiptAttachment, type PaymentChannel, } from "../api/paymentBatches"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { BusinessListToolbar, BusinessMobileList, BusinessTable, } from "../components/patterns/BusinessListTemplate"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges"; import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Textarea } from "../components/ui/textarea"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { DateTextInput } from "../components/common/DateTextInput"; import { downloadBlob } from "../lib/download"; import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay"; import { toast } from "sonner"; const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"]; function entityTypeLabel(value: string) { if (value === "reimbursement") return "报销"; if (value === "travel_order") return "差旅订单"; return value || "-"; } function paymentChannelLabel(value: PaymentChannel | string | null | undefined) { if (value === "bank") return "银行转账"; if (value === "alipay") return "支付宝"; if (value === "wechat") return "微信"; if (value === "cash") return "现金"; if (value === "other") return "其他"; return value || "-"; } function paymentItemStatusLabel(value: string) { if (value === "pending") return "待付款"; if (value === "paid") return "已支付"; if (value === "executed") return "已付款"; if (value === "failed") return "付款失败"; if (value === "cancelled") return "已取消"; if (value === "skipped") return "已跳过"; return value || "-"; } function formatFileSize(value: number) { if (!Number.isFinite(value) || value <= 0) return "-"; if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`; return `${(value / 1024 / 1024).toFixed(1)} MB`; } function resolveError(error: unknown) { return isApiError(error) ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` : error instanceof Error ? error.message : "操作失败"; } export function PaymentBatchesPage() { const queryClient = useQueryClient(); const { me, permissions } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const canManageBatch = hasPermission(permissions, "payment.batch.manage"); const canReviewBatch = hasPermission(permissions, "payment.batch.review") && (me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo")); const [candidateSearch, setCandidateSearch] = useState(""); const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); const receiptInputRef = useRef(null); const [error, setError] = useState(""); const [showCreatePanel, setShowCreatePanel] = useState(false); const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ kind: "financeReview" | "financePayment"; batchId: string; batchNo: string; highRisk: boolean; totalAmount: string; } | null>(null); const [reasonAction, setReasonAction] = useState< | { kind: "cancel"; batchId: string; batchNo: string } | { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string } | null >(null); const [actionReason, setActionReason] = useState(""); const listQuery = useQuery({ queryKey: ["payment-batches"], queryFn: () => fetchPaymentBatches(), }); const candidatesQuery = useQuery({ queryKey: ["payment-batch-candidates", candidateSearch], enabled: showCreatePanel && canManageBatch, queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: 1, pageSize: 50 }), }); const selectedBatchId = searchParams.get("id") ?? ""; const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); const selectedBatch = useMemo(() => { return batches.find((item) => item.id === selectedBatchId) ?? null; }, [batches, selectedBatchId]); const receiptAttachmentsQuery = useQuery({ queryKey: ["payment-batch-receipts", selectedBatch?.id], enabled: Boolean(selectedBatch?.id), queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id), }); const canEditSelectedBatch = canManageBatch && !!selectedBatch && ["draft", "reviewed", "failed"].includes(selectedBatch.status); const canEditReceiptAttachments = canManageBatch && !!selectedBatch && selectedBatch.status !== "cancelled"; const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess; const canReviewSelectedBatch = canReviewBatch && !!selectedBatch && selectedBatch.createdBy !== me?.id && ( selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review" ); const canExecuteSelectedBatch = canManageBatch && !!selectedBatch && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.reviewRequirement.nextStep === "ready_for_execute"; const requiresBankReceipt = Boolean(selectedBatch?.items.some((item) => ["pending", "failed"].includes(item.status) && (item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank", )); const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length; const needsBankReceipt = canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0; const canRegisterPaymentComplete = canExecuteSelectedBatch && (!requiresBankReceipt || (receiptAttachmentsQuery.isSuccess && activeReceiptCount > 0)); const canCancelSelectedBatch = canManageBatch && !!selectedBatch && !["executed", "cancelled"].includes(selectedBatch.status); const refresh = async () => { await queryClient.invalidateQueries({ queryKey: ["payment-batches"] }); await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] }); await queryClient.invalidateQueries({ queryKey: ["payment-batch-receipts"] }); await queryClient.invalidateQueries({ queryKey: ["ap-operations"] }); }; const returnToList = () => { const next = new URLSearchParams(searchParams); next.delete("id"); setSearchParams(next, { replace: true }); }; const createMutation = useMutation({ mutationFn: async () => createPaymentBatch({ entityType: "reimbursement", entityIds: selectedCandidateIds, }), onSuccess: async ({ item }) => { setError(""); setSelectedCandidateIds([]); setCandidateSearch(""); const next = new URLSearchParams(searchParams); next.set("id", item.id); setSearchParams(next, { replace: true }); await refresh(); toast.success("已提交付款复核,等待负责人处理"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const reviewMutation = useMutation({ mutationFn: async (batchId: string) => { const batch = listQuery.data?.items.find((item) => item.id === batchId); const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id)); return reviewPaymentBatch(batchId, { resolveRiskFlagIds }); }, onSuccess: async ({ item }) => { setError(""); await refresh(); toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const executeMutation = useMutation({ mutationFn: executePaymentBatch, onSuccess: async () => { setError(""); await refresh(); toast.success("付款已登记完成"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const cancelMutation = useMutation({ mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) => cancelPaymentBatch(batchId, reason), onSuccess: async () => { setError(""); setReasonAction(null); setActionReason(""); await refresh(); toast.success("付款批次已取消"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const updateItemMutation = useMutation({ mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; paymentChannelOverride?: PaymentChannel | null }) => updatePaymentBatchItem(payload.batchId, payload.itemId, { paymentVoucherNoOverride: payload.paymentVoucherNoOverride, paymentDateOverride: payload.paymentDateOverride, paymentChannelOverride: payload.paymentChannelOverride, }), onSuccess: async () => { setError(""); await refresh(); toast.success("批次项已更新"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const uploadReceiptMutation = useMutation({ mutationFn: async (files: File[]) => { if (!selectedBatch) { throw new Error("请先选择付款批次"); } if (!files.length) { throw new Error("请选择银行回单附件"); } return uploadPaymentBatchReceiptAttachments(selectedBatch.id, files); }, onSuccess: async () => { setError(""); if (receiptInputRef.current) { receiptInputRef.current.value = ""; } await refresh(); toast.success("银行回单附件已上传"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog(); const downloadReceiptMutation = useMutation({ mutationFn: async ({ attachment, reason }: { attachment: PaymentBatchReceiptAttachment; reason?: string }) => { if (!selectedBatch) { throw new Error("请先选择付款批次"); } const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id, reason); return { response, attachment }; }, onSuccess: ({ response, attachment }) => { downloadBlob(response.blob, response.filename ?? attachment.originalName); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); async function handleDownloadReceipt(attachment: PaymentBatchReceiptAttachment) { const reason = await requestDownloadReason(attachment); if (reason === null) { return; } downloadReceiptMutation.mutate({ attachment, reason }); } const voidReceiptMutation = useMutation({ mutationFn: async ({ batchId, attachmentId, reason }: { batchId: string; attachmentId: string; reason: string }) => { return voidPaymentBatchReceiptAttachment(batchId, attachmentId, reason); }, onSuccess: async () => { setError(""); setReasonAction(null); setActionReason(""); await refresh(); toast.success("银行回单已留痕作废"); }, onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); }, }); return ( {downloadReasonDialog} {selectedBatchId ? ( ) : null} {!selectedBatchId && canManageBatch ? ( ) : null} } /> {error ?
{error}
: null} {!selectedBatchId && showCreatePanel ? (

新建批次

{!canManageBatch ? (
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
) : null}
setCandidateSearch(event.target.value)} placeholder="按报销编号、人员或摘要搜索" />
{candidatesQuery.isLoading ?
正在加载待付款报销...
: null} {candidatesQuery.isError ?
{resolveError(candidatesQuery.error)}
: null} {(candidatesQuery.data?.items ?? []).map((candidate) => { const selected = selectedCandidateIds.includes(candidate.entityId); return ( ); })} {!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? (
没有可加入批次的已审批报销。
) : null}
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。
) : null} {selectedBatchId ? ( selectedBatch ? (
{selectedBatch.batchNo}
{selectedBatch.name}
{selectedBatch.highRisk ? ( 高风险 ) : null}
{canReviewSelectedBatch ? ( ) : null} {needsBankReceipt ? ( ) : null} {canRegisterPaymentComplete ? ( ) : null} {canManageBatch ? ( ) : null}
状态
{paymentBatchStatusLabel(selectedBatch.status)}
实体
{entityTypeLabel(selectedBatch.entityType)}
总金额
{selectedBatch.totalAmount}
默认日期
{formatDisplayDate(selectedBatch.defaultPaymentDate)}
默认渠道
{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}
复核进度
{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}
{selectedBatch.status === "draft" ? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。" : selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess ? "付款复核已通过,正在核对银行回单状态。" : selectedBatch.status === "reviewed" && needsBankReceipt ? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。" : selectedBatch.status === "reviewed" ? "付款复核已通过。请财务核对付款结果并登记付款完成。" : selectedBatch.status === "executed" ? "付款已登记完成。" : selectedBatch.status === "failed" ? "付款登记失败,请财务核对失败原因后重试。" : "该付款批次已取消。"} {selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""} {selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}

银行回单附件

先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。

{canUploadReceipt ? (
{ const files = Array.from(event.target.files ?? []); if (files.length) uploadReceiptMutation.mutate(files); }} />
) : null}
{receiptAttachmentsQuery.isError ? (
{resolveError(receiptAttachmentsQuery.error)}
) : null}
{(receiptAttachmentsQuery.data?.items ?? []).map((attachment) => (
{attachment.originalName}
{attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDisplayDateTime(attachment.createdAt)} {attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
{canManageBatch ? ( ) : null}
))} {receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
) : null}

批次项

{selectedBatch.items.map((item) => (
{item.entityCode}
{item.entityTitle}
{item.amount} {item.currencyCode} · {paymentItemStatusLabel(item.status)}
updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} /> updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} />
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
))}
) : (
未找到付款批次
) ) : ( <> void refresh()}> 刷新 } > 全部批次 {batches.length} {batches.map((item) => ( ))} {!listQuery.data?.items?.length ?
暂无付款批次
: null}
批次 实体 金额 复核 状态 操作 {batches.length ? ( batches.map((item) => (
{item.batchNo}
{item.name}
{entityTypeLabel(item.entityType)} {item.totalAmount} {item.reviewRequirement.completedReviewCount}/{item.reviewRequirement.requiredReviewCount}
{item.highRisk ? 高风险 : null}
)) ) : ( 暂无付款批次 )}
)} { if (!open) setConfirmPaymentAction(null); }} kind={confirmPaymentAction?.kind ?? "financePayment"} targetName={confirmPaymentAction?.batchNo} description={ confirmPaymentAction?.kind === "financeReview" ? confirmPaymentAction.highRisk ? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。` : "确认后将完成一次独立复核并写入付款批次审计记录。" : confirmPaymentAction?.highRisk ? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。` : "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。" } onConfirm={async () => { if (!confirmPaymentAction) return; if (confirmPaymentAction.kind === "financeReview") reviewMutation.mutate(confirmPaymentAction.batchId); if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId); }} /> { if (!open) { setReasonAction(null); setActionReason(""); } }} > {reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"} {reasonAction?.kind === "voidReceipt" ? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。` : reasonAction ? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。` : "请填写原因。"}