import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { exportApOperations, fetchApOperations, type ApOperationsPendingEntityType, type ApOperationsRiskBucket, type ApPartialMatchItemSummary, } from "../api/apOperations"; import { mapApiErrorMessage } from "../api/errorMessages"; import { dismissRiskFlag, fetchRiskFlags, resolveRiskFlag, type RiskFlag, } from "../api/feeControl"; import { cancelPaymentBatch, executePaymentBatch, fetchPaymentBatchDetail, fetchPaymentBatches, reviewPaymentBatch, type PaymentBatch, type PaymentBatchEntityType, type PaymentBatchItem, } from "../api/paymentBatches"; import { fetchTravelTransactions } from "../api/travelPayments"; import { confirmTravelReconciliationItem, fetchTravelReconciliationBatchDetail, type TravelReconciliationItem, } from "../api/travelReconciliation"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { downloadBlob } from "../lib/download"; type OperationsTab = "pending" | "failed" | "risk" | "reconciliation"; type RiskActionType = "resolve" | "dismiss"; type PendingEntityFilter = ApOperationsPendingEntityType | "all"; type FailedPaymentItemRow = PaymentBatchItem & { batchId: string; batchNo: string; batchName: string; entityType: PaymentBatchEntityType; batchStatus: PaymentBatch["status"]; highRisk: boolean; reviewRequirement: PaymentBatch["reviewRequirement"]; }; const BUDGET_RISK_CODES = new Set(["budget_exceeded", "budget_threshold_exceeded"]); function resolveErrorMessage(error: unknown, fallback = "操作失败") { return isApiError(error) ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` : error instanceof Error ? error.message : fallback; } function formatDateTime(value: string | null) { if (!value) { return "-"; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } function statusBadgeClass(status: string) { if (status === "open" || status === "pending" || status === "draft") { return "border-amber-200 bg-amber-50 text-amber-700"; } if (status === "resolved" || status === "confirmed" || status === "reviewed") { return "border-emerald-200 bg-emerald-50 text-emerald-700"; } if (status === "dismissed" || status === "cancelled") { return "border-zinc-200 bg-zinc-100 text-zinc-600"; } if (status === "failed") { return "border-red-200 bg-red-50 text-red-700"; } return "border-zinc-200 bg-zinc-50 text-zinc-600"; } function entityTypeLabel(value: string) { if (value === "reimbursement") return "报销"; if (value === "travel_order") return "差旅"; if (value === "purchase_request") return "采购"; if (value === "travel_request") return "差旅申请"; return value || "-"; } function batchStatusLabel(value: string) { if (value === "draft") return "草稿"; if (value === "reviewed") return "已复核"; if (value === "executed") return "已执行"; if (value === "failed") return "执行失败"; if (value === "cancelled") return "已取消"; return value || "-"; } function riskBucketLabel(value: ApOperationsRiskBucket) { if (value === "budget_overspend") return "预算超支"; if (value === "exception") return "异常风险"; return "全部"; } function riskLevelLabel(value: string) { if (value === "high") return "高"; if (value === "medium") return "中"; if (value === "low") return "低"; return value || "-"; } function matchStatusLabel(value: string) { if (value === "amount_mismatch") return "金额不匹配"; if (value === "currency_mismatch") return "币种不匹配"; if (value === "missing_transaction") return "缺少交易"; if (value === "duplicate_charge") return "重复扣款"; if (value === "orphan_transaction") return "孤儿交易"; if (value === "reversed_after_payment") return "付款后冲正"; if (value === "matched") return "已匹配"; return value || "-"; } function resolutionStatusLabel(value: string) { if (value === "pending") return "待确认"; if (value === "confirmed") return "已确认"; return value || "-"; } function matchesCreatedAtRange(createdAt: string | null, dateFrom: string, dateTo: string) { if (!createdAt) { return !dateFrom && !dateTo; } const dateOnly = createdAt.slice(0, 10); if (dateFrom && dateOnly < dateFrom) { return false; } if (dateTo && dateOnly > dateTo) { return false; } return true; } function riskBucketFromCode(riskCode: string) { return BUDGET_RISK_CODES.has(riskCode) ? "budget_overspend" : "exception"; } function prettyJson(value: unknown) { try { return JSON.stringify(value ?? {}, null, 2); } catch { return "{}"; } } function SummaryCard({ title, value, tone = "zinc", }: { title: string; value: number | string; tone?: "zinc" | "amber" | "red" | "emerald"; }) { const toneClass = tone === "amber" ? "text-amber-700" : tone === "red" ? "text-red-700" : tone === "emerald" ? "text-emerald-700" : "text-zinc-900"; return (
{title}
{value}
); } function SectionError({ message }: { message: string }) { return (
{message}
); } export function ApOperationsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { permissions } = useAuth(); const canManageBatch = hasPermission(permissions, "payment.batch.manage"); const canReviewRisk = hasPermission(permissions, "risk.review.manage"); const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage"); const [activeTab, setActiveTab] = useState("pending"); const [pendingEntityType, setPendingEntityType] = useState("all"); const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState(""); const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState(""); const [riskBucket, setRiskBucket] = useState("all"); const [reviewTarget, setReviewTarget] = useState(null); const [reviewComment, setReviewComment] = useState(""); const [cancelTarget, setCancelTarget] = useState(null); const [retryTarget, setRetryTarget] = useState(null); const [failedDetailTarget, setFailedDetailTarget] = useState(null); const [riskAction, setRiskAction] = useState<{ type: RiskActionType; item: RiskFlag } | null>(null); const [riskActionNote, setRiskActionNote] = useState(""); const [reconciliationDetailTarget, setReconciliationDetailTarget] = useState(null); const [reconciliationConfirmTarget, setReconciliationConfirmTarget] = useState(null); const [reconciliationConfirmNote, setReconciliationConfirmNote] = useState(""); const [resolvingTravelOrderId, setResolvingTravelOrderId] = useState(null); const [travelRequestTargets, setTravelRequestTargets] = useState>({}); const summaryQuery = useQuery({ queryKey: ["ap-operations"], queryFn: () => fetchApOperations(), }); const paymentBatchesQuery = useQuery({ queryKey: ["payment-batches"], queryFn: () => fetchPaymentBatches(), }); const riskFlagsQuery = useQuery({ queryKey: ["risk-flags", "open"], queryFn: () => fetchRiskFlags({ status: "open" }), }); const failedDetailQuery = useQuery({ enabled: Boolean(failedDetailTarget?.batchId), queryKey: ["payment-batches", "detail", failedDetailTarget?.batchId], queryFn: () => fetchPaymentBatchDetail(failedDetailTarget!.batchId), }); const reconciliationDetailQuery = useQuery({ enabled: Boolean(reconciliationDetailTarget?.travelReconciliationBatchId), queryKey: [ "travel-reconciliation", "detail", reconciliationDetailTarget?.travelReconciliationBatchId, ], queryFn: () => fetchTravelReconciliationBatchDetail(reconciliationDetailTarget!.travelReconciliationBatchId), }); const paymentBatches = paymentBatchesQuery.data?.items ?? []; const openRiskFlags = riskFlagsQuery.data?.items ?? []; const partialMatchItems = summaryQuery.data?.partialMatchItems ?? []; const pendingBatches = useMemo( () => paymentBatches.filter( (item) => ["draft", "reviewed", "failed"].includes(item.status) && (pendingEntityType === "all" || item.entityType === pendingEntityType) && matchesCreatedAtRange(item.createdAt, pendingCreatedAtFrom, pendingCreatedAtTo), ), [paymentBatches, pendingCreatedAtFrom, pendingCreatedAtTo, pendingEntityType], ); const failedItems = useMemo( () => paymentBatches .flatMap((batch) => batch.items .filter((item) => item.status === "failed") .map((item) => ({ ...item, batchId: batch.id, batchNo: batch.batchNo, batchName: batch.name, entityType: batch.entityType, batchStatus: batch.status, highRisk: batch.highRisk, reviewRequirement: batch.reviewRequirement, })), ) .sort((left, right) => { const leftTs = new Date(left.createdAt ?? 0).getTime(); const rightTs = new Date(right.createdAt ?? 0).getTime(); return rightTs - leftTs; }), [paymentBatches], ); const filteredRiskFlags = useMemo( () => openRiskFlags.filter((item) => { if (riskBucket === "all") { return true; } return riskBucketFromCode(item.riskCode) === riskBucket; }), [openRiskFlags, riskBucket], ); const failedDetailItem = useMemo(() => { if (!failedDetailTarget) { return null; } return ( failedDetailQuery.data?.item.items.find((item) => item.id === failedDetailTarget.id) ?? failedDetailTarget ); }, [failedDetailQuery.data?.item.items, failedDetailTarget]); const reconciliationDetailItem = useMemo(() => { if (!reconciliationDetailTarget) { return null; } return ( reconciliationDetailQuery.data?.items.find((item) => item.id === reconciliationDetailTarget.id) ?? null ); }, [reconciliationDetailQuery.data?.items, reconciliationDetailTarget]); const refreshWorkbench = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["ap-operations"] }), queryClient.invalidateQueries({ queryKey: ["payment-batches"] }), queryClient.invalidateQueries({ queryKey: ["risk-flags"] }), failedDetailTarget?.batchId ? queryClient.invalidateQueries({ queryKey: ["payment-batches", "detail", failedDetailTarget.batchId], }) : Promise.resolve(), reconciliationDetailTarget?.travelReconciliationBatchId ? queryClient.invalidateQueries({ queryKey: [ "travel-reconciliation", "detail", reconciliationDetailTarget.travelReconciliationBatchId, ], }) : Promise.resolve(), ]); }; const reviewMutation = useMutation({ mutationFn: async (batch: PaymentBatch) => { const comment = reviewComment.trim(); if (!comment) { throw new Error("快捷复核必须填写复核意见"); } const resolveRiskFlagIds = Array.from( new Set(batch.items.flatMap((item) => item.openRiskFlags.map((flag) => flag.id))), ); return reviewPaymentBatch(batch.id, { comment, resolveRiskFlagIds }); }, onSuccess: async ({ item }) => { await refreshWorkbench(); setReviewTarget(null); setReviewComment(""); toast.success( item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核", ); }, onError: (error) => { toast.error(resolveErrorMessage(error, "快捷复核失败")); }, }); const cancelMutation = useMutation({ mutationFn: (batchId: string) => cancelPaymentBatch(batchId), onSuccess: async () => { await refreshWorkbench(); setCancelTarget(null); toast.success("付款批次已取消"); }, onError: (error) => { toast.error(resolveErrorMessage(error, "取消批次失败")); }, }); const executeMutation = useMutation({ mutationFn: (batchId: string) => executePaymentBatch(batchId), onSuccess: async () => { await refreshWorkbench(); setRetryTarget(null); toast.success("付款批次已重新执行"); }, onError: (error) => { toast.error(resolveErrorMessage(error, "重试执行失败")); }, }); const riskMutation = useMutation({ mutationFn: async (payload: { type: RiskActionType; item: RiskFlag }) => { const note = riskActionNote.trim(); if (!note) { throw new Error(payload.type === "resolve" ? "解决说明不能为空" : "驳回理由不能为空"); } if (payload.type === "resolve") { return resolveRiskFlag(payload.item.id, { note }); } return dismissRiskFlag(payload.item.id, { note }); }, onSuccess: async (_, payload) => { await refreshWorkbench(); setRiskAction(null); setRiskActionNote(""); toast.success(payload.type === "resolve" ? "风险标记已解决" : "风险标记已驳回"); }, onError: (error) => { toast.error(resolveErrorMessage(error, "处理风险标记失败")); }, }); const reconciliationConfirmMutation = useMutation({ mutationFn: (itemId: string) => confirmTravelReconciliationItem(itemId, { note: reconciliationConfirmNote.trim() || null, }), onSuccess: async () => { await refreshWorkbench(); setReconciliationConfirmTarget(null); setReconciliationConfirmNote(""); toast.success("对账异常已确认"); }, onError: (error) => { toast.error(resolveErrorMessage(error, "确认对账异常失败")); }, }); const exportMutation = useMutation({ mutationFn: (format: "csv" | "xlsx") => exportApOperations({ format, pendingEntityType: pendingEntityType === "all" ? undefined : pendingEntityType, pendingCreatedAtFrom: pendingCreatedAtFrom || undefined, pendingCreatedAtTo: pendingCreatedAtTo || undefined, riskBucket, }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `ap-operations.${format}`); toast.success(`AP 运营数据已导出为 ${format.toUpperCase()}`); }, onError: (error) => { toast.error(resolveErrorMessage(error, "导出失败")); }, }); const handleJumpToBatch = (batchId: string) => { navigate(`/payment-batches?id=${encodeURIComponent(batchId)}`); }; const handleJumpToLinkedEntity = async (item: FailedPaymentItemRow) => { if (item.entityType === "reimbursement") { navigate(`/reimbursements/${item.entityId}`); return; } if (!canManageTravelPayments) { toast.error("当前账号无权限解析差旅付款关联申请"); return; } const cachedTarget = travelRequestTargets[item.entityId]; if (cachedTarget) { navigate(`/travel-requests?tab=requests&id=${encodeURIComponent(cachedTarget)}`); return; } if (cachedTarget === null) { return; } setResolvingTravelOrderId(item.entityId); try { const response = await fetchTravelTransactions({ travelOrderId: item.entityId, page: 1, pageSize: 1, }); const targetId = response.items[0]?.travelRequestId ?? null; setTravelRequestTargets((current) => ({ ...current, [item.entityId]: targetId })); if (!targetId) { toast.error("未找到关联差旅申请,无法跳转"); return; } navigate(`/travel-requests?tab=requests&id=${encodeURIComponent(targetId)}`); } catch (error) { toast.error(resolveErrorMessage(error, "解析关联差旅申请失败")); } finally { setResolvingTravelOrderId(null); } }; if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading) { return
加载 AP 运营工作台...
; } return (
} /> {summaryQuery.isError ? ( ) : null} {paymentBatchesQuery.isError ? ( ) : null} {riskFlagsQuery.isError ? ( ) : null}
setActiveTab(value as OperationsTab)}> 待付款 {pendingBatches.length} 异常付款 {failedItems.length} 风险标记 {filteredRiskFlags.length} 对账异常 {partialMatchItems.length}
实体类型
创建时间从
setPendingCreatedAtFrom(event.target.value)} />
创建时间至
setPendingCreatedAtTo(event.target.value)} />
批次实体状态金额创建时间操作 {pendingBatches.length ? ( pendingBatches.map((item) => { const canReviewSelectedBatch = canReviewRisk && ((item.status === "draft" && item.reviewRequirement.nextStep === "first_review") || (item.status === "reviewed" && item.reviewRequirement.nextStep === "second_review")); const canCancelSelectedBatch = canManageBatch && !["executed", "cancelled"].includes(item.status); return (
{item.batchNo}
{item.name}
{entityTypeLabel(item.entityType)}
{batchStatusLabel(item.status)} {item.highRisk ? ( 高风险 ) : null}
复核 {item.reviewRequirement.completedReviewCount}/ {item.reviewRequirement.requiredReviewCount}
{item.totalAmount} {formatDateTime(item.createdAt)}
{canReviewRisk ? ( ) : null} {canManageBatch ? ( ) : null}
); }) ) : ( 当前筛选下暂无待付款批次。 )}
异常项批次失败原因金额创建时间操作 {failedItems.length ? ( failedItems.map((item) => { const canRetry = canManageBatch && ["reviewed", "failed"].includes(item.batchStatus) && item.reviewRequirement.nextStep === "ready_for_execute"; const cachedTravelRequestId = item.entityType === "travel_order" ? travelRequestTargets[item.entityId] : undefined; const jumpDisabled = item.entityType === "travel_order" ? !canManageTravelPayments || cachedTravelRequestId === null || resolvingTravelOrderId === item.entityId : false; return (
{item.entityCode}
{item.entityTitle}
{item.batchNo}
{item.batchName}
{item.failureMessage ?? item.failureCode ?? "执行失败"}
{item.failureCode ?? "无失败码"}
{item.amount} {item.currencyCode} {formatDateTime(item.createdAt)}
{canManageBatch ? ( ) : null}
); }) ) : ( 当前没有失败付款项。 )}
{( [ { value: "all", label: "全部" }, { value: "exception", label: "异常风险" }, { value: "budget_overspend", label: "预算超支" }, ] as Array<{ value: ApOperationsRiskBucket; label: string }> ).map((item) => ( ))}
风险项分组等级状态创建时间操作 {filteredRiskFlags.length ? ( filteredRiskFlags.map((item) => (
{item.title}
{item.detail || `${item.entityType}:${item.entityId}`}
{riskBucketLabel(riskBucketFromCode(item.riskCode))} {riskLevelLabel(item.level)} {item.status} {formatDateTime(item.createdAt)}
{canReviewRisk ? ( <> ) : ( 当前账号仅可查看 )}
)) ) : ( 当前筛选下暂无开放风险标记。 )}
异常金额差异状态创建时间操作 {partialMatchItems.length ? ( partialMatchItems.map((item) => (
{item.anomalyCode ?? matchStatusLabel(item.matchStatus)}
{matchStatusLabel(item.matchStatus)}
订单 {item.orderAmount ?? "-"}
交易 {item.transactionAmount ?? "-"} {item.currency}
{resolutionStatusLabel(item.resolutionStatus)} {formatDateTime(item.createdAt)}
{canManageTravelPayments ? ( ) : ( 缺少 travel.payment.manage )}
)) ) : ( 当前没有待确认的对账异常。 )}
{ if (!open) { setReviewTarget(null); setReviewComment(""); } }} > 快捷复核 {reviewTarget ? `${reviewTarget.batchNo} · ${reviewTarget.name}` : "填写复核意见后提交。"} {reviewTarget?.highRisk ? (
此批次为高风险,需二次复核。
) : null}