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 (
);
}
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}
实体类型
批次
实体
状态
金额
创建时间
操作
{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) {
setCancelTarget(null);
}
}}
title="确认取消付款批次"
description={
cancelTarget
? `将取消 ${cancelTarget.batchNo},该操作与支付批次页保持一致。`
: undefined
}
variant="destructive"
confirmLabel="确认取消"
onConfirm={async () => {
if (!cancelTarget) {
return;
}
await cancelMutation.mutateAsync(cancelTarget.id);
}}
/>
{
if (!open) {
setRetryTarget(null);
}
}}
title="确认重试付款批次"
description={
retryTarget
? `将重新执行 ${retryTarget.batchNo},失败项 ${retryTarget.entityCode} 将按原批次流程重试。`
: undefined
}
confirmLabel="确认重试"
onConfirm={async () => {
if (!retryTarget) {
return;
}
await executeMutation.mutateAsync(retryTarget.batchId);
}}
/>
);
}