文件
hyb-oa/client/src/pages/ApOperationsPage.tsx
T
wangsli 64d9ded024 R52 A1-A6:完成 AP 运营深度操作
- AP 概览页 Tab 工作台化(待付款/异常付款/风险标记/对账异常)
- AP 导出端点支持 CSV/XLSX 与筛选
- 风险标记 resolve/dismiss 支持说明审计留痕
- 支付批次深链、高风险复核提示、对账确认权限收口
- OpenClaw 浏览器级回归全部通过
2026-04-07 09:37:17 +08:00

1344 行
51 KiB
TypeScript

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 (
<div className="card-std p-5">
<div className="text-sm text-zinc-500">{title}</div>
<div className={`mt-2 text-2xl font-bold ${toneClass}`}>{value}</div>
</div>
);
}
function SectionError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{message}
</div>
);
}
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<OperationsTab>("pending");
const [pendingEntityType, setPendingEntityType] = useState<PendingEntityFilter>("all");
const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState("");
const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState("");
const [riskBucket, setRiskBucket] = useState<ApOperationsRiskBucket>("all");
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
const [reviewComment, setReviewComment] = useState("");
const [cancelTarget, setCancelTarget] = useState<PaymentBatch | null>(null);
const [retryTarget, setRetryTarget] = useState<FailedPaymentItemRow | null>(null);
const [failedDetailTarget, setFailedDetailTarget] = useState<FailedPaymentItemRow | null>(null);
const [riskAction, setRiskAction] = useState<{ type: RiskActionType; item: RiskFlag } | null>(null);
const [riskActionNote, setRiskActionNote] = useState("");
const [reconciliationDetailTarget, setReconciliationDetailTarget] =
useState<ApPartialMatchItemSummary | null>(null);
const [reconciliationConfirmTarget, setReconciliationConfirmTarget] =
useState<ApPartialMatchItemSummary | null>(null);
const [reconciliationConfirmNote, setReconciliationConfirmNote] = useState("");
const [resolvingTravelOrderId, setResolvingTravelOrderId] = useState<string | null>(null);
const [travelRequestTargets, setTravelRequestTargets] = useState<Record<string, string | null>>({});
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<FailedPaymentItemRow[]>(
() =>
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<TravelReconciliationItem | null>(() => {
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 <div className="card-std p-6 text-sm text-zinc-500">加载 AP 运营工作台...</div>;
}
return (
<div className="space-y-6">
<PageHeader
title="AP 运营"
subtitle="将待付款批次、异常付款、风险标记和对账异常收口在同一工作台内处理。"
actions={
<>
<Button
type="button"
variant="secondary"
onClick={() => refreshWorkbench()}
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching}
>
刷新
</Button>
<Button
type="button"
variant="secondary"
onClick={() => exportMutation.mutate("csv")}
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
</Button>
<Button
type="button"
onClick={() => exportMutation.mutate("xlsx")}
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
</Button>
</>
}
/>
{summaryQuery.isError ? (
<SectionError message={resolveErrorMessage(summaryQuery.error, "AP 摘要加载失败")} />
) : null}
{paymentBatchesQuery.isError ? (
<SectionError message={resolveErrorMessage(paymentBatchesQuery.error, "支付批次数据加载失败")} />
) : null}
{riskFlagsQuery.isError ? (
<SectionError message={resolveErrorMessage(riskFlagsQuery.error, "风险标记数据加载失败")} />
) : null}
<div className="grid gap-4 md:grid-cols-4">
<SummaryCard title="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
<SummaryCard
title="部分匹配"
value={summaryQuery.data?.summary.partialMatchCount ?? "-"}
tone="amber"
/>
<SummaryCard title="异常" value={summaryQuery.data?.summary.exceptionCount ?? "-"} tone="red" />
<SummaryCard
title="预算超支"
value={summaryQuery.data?.summary.budgetOverspendCount ?? "-"}
tone="emerald"
/>
</div>
<div className="card-std p-6">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="mt-6 space-y-4">
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">实体类型</div>
<select
className="select-std"
value={pendingEntityType}
onChange={(event) => setPendingEntityType(event.target.value as PendingEntityFilter)}
>
<option value="all">全部</option>
<option value="reimbursement">报销</option>
<option value="travel_order">差旅</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">创建时间从</div>
<Input
type="date"
value={pendingCreatedAtFrom}
onChange={(event) => setPendingCreatedAtFrom(event.target.value)}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">创建时间至</div>
<Input
type="date"
value={pendingCreatedAtTo}
onChange={(event) => setPendingCreatedAtTo(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setPendingEntityType("all");
setPendingCreatedAtFrom("");
setPendingCreatedAtTo("");
}}
>
清空筛选
</Button>
</div>
</div>
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>批次</TableHead>
<TableHead>实体</TableHead>
<TableHead>状态</TableHead>
<TableHead>金额</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-[280px]">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{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 (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.batchNo}</div>
<div className="text-xs text-zinc-500">{item.name}</div>
</TableCell>
<TableCell>{entityTypeLabel(item.entityType)}</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}
>
{batchStatusLabel(item.status)}
</span>
{item.highRisk ? (
<span className="inline-flex rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
高风险
</span>
) : null}
</div>
<div className="mt-1 text-xs text-zinc-500">
复核 {item.reviewRequirement.completedReviewCount}/
{item.reviewRequirement.requiredReviewCount}
</div>
</TableCell>
<TableCell>{item.totalAmount}</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => handleJumpToBatch(item.id)}
>
查看批次
</Button>
{canReviewRisk ? (
<Button
type="button"
size="sm"
disabled={!canReviewSelectedBatch}
onClick={() => {
setReviewTarget(item);
setReviewComment("");
}}
>
快捷复核
</Button>
) : null}
{canManageBatch ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={!canCancelSelectedBatch}
onClick={() => setCancelTarget(item)}
>
快捷取消
</Button>
) : null}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-sm text-zinc-500">
当前筛选下暂无待付款批次。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="failed" className="mt-6 space-y-4">
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>异常项</TableHead>
<TableHead>批次</TableHead>
<TableHead>失败原因</TableHead>
<TableHead>金额</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-[320px]">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{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 (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-xs text-zinc-500">{item.entityTitle}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{item.batchNo}</div>
<div className="text-xs text-zinc-500">{item.batchName}</div>
</TableCell>
<TableCell>
<div className="text-sm text-zinc-700">
{item.failureMessage ?? item.failureCode ?? "执行失败"}
</div>
<div className="text-xs text-zinc-500">
{item.failureCode ?? "无失败码"}
</div>
</TableCell>
<TableCell>
{item.amount} {item.currencyCode}
</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{canManageBatch ? (
<Button
type="button"
size="sm"
disabled={!canRetry}
onClick={() => setRetryTarget(item)}
>
重试执行
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setFailedDetailTarget(item)}
>
查看详情
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={jumpDisabled}
onClick={() => handleJumpToLinkedEntity(item)}
>
{item.entityType === "travel_order" &&
travelRequestTargets[item.entityId] === null
? "无法定位申请单"
: resolvingTravelOrderId === item.entityId
? "解析中..."
: "跳转关联单据"}
</Button>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-sm text-zinc-500">
当前没有失败付款项。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="risk" className="mt-6 space-y-4">
<div className="flex flex-wrap items-center gap-2">
{(
[
{ value: "all", label: "全部" },
{ value: "exception", label: "异常风险" },
{ value: "budget_overspend", label: "预算超支" },
] as Array<{ value: ApOperationsRiskBucket; label: string }>
).map((item) => (
<Button
key={item.value}
type="button"
size="sm"
variant={riskBucket === item.value ? "default" : "secondary"}
onClick={() => setRiskBucket(item.value)}
>
{item.label}
</Button>
))}
</div>
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>风险项</TableHead>
<TableHead>分组</TableHead>
<TableHead>等级</TableHead>
<TableHead>状态</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-[220px]">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{filteredRiskFlags.length ? (
filteredRiskFlags.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">
{item.detail || `${item.entityType}:${item.entityId}`}
</div>
</TableCell>
<TableCell>{riskBucketLabel(riskBucketFromCode(item.riskCode))}</TableCell>
<TableCell>{riskLevelLabel(item.level)}</TableCell>
<TableCell>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}
>
{item.status}
</span>
</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{canReviewRisk ? (
<>
<Button
type="button"
size="sm"
onClick={() => {
setRiskAction({ type: "resolve", item });
setRiskActionNote("");
}}
>
解决
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setRiskAction({ type: "dismiss", item });
setRiskActionNote("");
}}
>
驳回
</Button>
</>
) : (
<span className="text-xs text-zinc-500">当前账号仅可查看</span>
)}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-sm text-zinc-500">
当前筛选下暂无开放风险标记。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="reconciliation" className="mt-6 space-y-4">
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>异常</TableHead>
<TableHead>金额差异</TableHead>
<TableHead>状态</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-[240px]">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{partialMatchItems.length ? (
partialMatchItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">
{item.anomalyCode ?? matchStatusLabel(item.matchStatus)}
</div>
<div className="text-xs text-zinc-500">{matchStatusLabel(item.matchStatus)}</div>
</TableCell>
<TableCell>
<div>订单 {item.orderAmount ?? "-"}</div>
<div className="text-xs text-zinc-500">
交易 {item.transactionAmount ?? "-"} {item.currency}
</div>
</TableCell>
<TableCell>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.resolutionStatus)}`}
>
{resolutionStatusLabel(item.resolutionStatus)}
</span>
</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setReconciliationDetailTarget(item)}
>
查看详情
</Button>
{canManageTravelPayments ? (
<Button
type="button"
size="sm"
disabled={item.resolutionStatus !== "pending"}
onClick={() => {
setReconciliationConfirmTarget(item);
setReconciliationConfirmNote("");
}}
>
确认
</Button>
) : (
<span className="text-xs text-zinc-500">缺少 travel.payment.manage</span>
)}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-sm text-zinc-500">
当前没有待确认的对账异常。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</div>
<Dialog
open={Boolean(reviewTarget)}
onOpenChange={(open) => {
if (!open) {
setReviewTarget(null);
setReviewComment("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>快捷复核</DialogTitle>
<DialogDescription>
{reviewTarget
? `${reviewTarget.batchNo} · ${reviewTarget.name}`
: "填写复核意见后提交。"}
</DialogDescription>
</DialogHeader>
{reviewTarget?.highRisk ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
此批次为高风险,需二次复核。
</div>
) : null}
<textarea
className="min-h-32 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
placeholder="请输入复核意见"
value={reviewComment}
onChange={(event) => setReviewComment(event.target.value)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setReviewTarget(null);
setReviewComment("");
}}
>
取消
</Button>
<Button
type="button"
disabled={reviewMutation.isPending || !reviewTarget}
onClick={() => reviewTarget && reviewMutation.mutate(reviewTarget)}
>
{reviewMutation.isPending ? "提交中..." : "提交复核"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(failedDetailTarget)}
onOpenChange={(open) => {
if (!open) {
setFailedDetailTarget(null);
}
}}
>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>失败付款项详情</DialogTitle>
<DialogDescription>
{failedDetailTarget
? `${failedDetailTarget.batchNo} · ${failedDetailTarget.entityCode}`
: "查看失败快照"}
</DialogDescription>
</DialogHeader>
{failedDetailQuery.isLoading ? (
<div className="text-sm text-zinc-500">加载失败项详情...</div>
) : failedDetailQuery.isError ? (
<SectionError message={resolveErrorMessage(failedDetailQuery.error, "加载失败项详情失败")} />
) : failedDetailItem ? (
<div className="space-y-4 text-sm">
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">单据编号</div>
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.entityCode}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">金额</div>
<div className="mt-1 font-medium text-zinc-900">
{failedDetailItem.amount} {failedDetailItem.currencyCode}
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">失败码</div>
<div className="mt-1 font-medium text-zinc-900">
{failedDetailItem.failureCode ?? "-"}
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">失败原因</div>
<div className="mt-1 font-medium text-zinc-900">
{failedDetailItem.failureMessage ?? "-"}
</div>
</div>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-700">快照</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-950/95 p-4 text-xs text-zinc-100">
{prettyJson(failedDetailItem.snapshotJson)}
</pre>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
<Dialog
open={Boolean(riskAction)}
onOpenChange={(open) => {
if (!open) {
setRiskAction(null);
setRiskActionNote("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{riskAction?.type === "resolve" ? "解决风险标记" : "驳回风险标记"}</DialogTitle>
<DialogDescription>
{riskAction ? `${riskAction.item.title} · ${riskAction.item.riskCode}` : "填写说明后提交。"}
</DialogDescription>
</DialogHeader>
<textarea
className="min-h-32 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
placeholder={riskAction?.type === "resolve" ? "请输入解决说明" : "请输入驳回理由"}
value={riskActionNote}
onChange={(event) => setRiskActionNote(event.target.value)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setRiskAction(null);
setRiskActionNote("");
}}
>
取消
</Button>
<Button
type="button"
disabled={riskMutation.isPending || !riskAction}
onClick={() => riskAction && riskMutation.mutate(riskAction)}
>
{riskMutation.isPending ? "提交中..." : "确认提交"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(reconciliationDetailTarget)}
onOpenChange={(open) => {
if (!open) {
setReconciliationDetailTarget(null);
}
}}
>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>对账异常详情</DialogTitle>
<DialogDescription>
{reconciliationDetailTarget
? `${reconciliationDetailTarget.anomalyCode ?? reconciliationDetailTarget.matchStatus}`
: "查看订单金额、交易金额与快照"}
</DialogDescription>
</DialogHeader>
{reconciliationDetailQuery.isLoading ? (
<div className="text-sm text-zinc-500">加载对账异常详情...</div>
) : reconciliationDetailQuery.isError ? (
<SectionError message={resolveErrorMessage(reconciliationDetailQuery.error, "加载对账异常详情失败")} />
) : reconciliationDetailItem ? (
<div className="space-y-4 text-sm">
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">订单金额</div>
<div className="mt-1 font-medium text-zinc-900">
{reconciliationDetailItem.orderAmount ?? "-"}
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">交易金额</div>
<div className="mt-1 font-medium text-zinc-900">
{reconciliationDetailItem.transactionAmount ?? "-"}
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">异常编码</div>
<div className="mt-1 font-medium text-zinc-900">
{reconciliationDetailItem.anomalyCode ?? "-"}
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">确认状态</div>
<div className="mt-1 font-medium text-zinc-900">
{resolutionStatusLabel(reconciliationDetailItem.resolutionStatus)}
</div>
</div>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-700">快照</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-950/95 p-4 text-xs text-zinc-100">
{prettyJson(reconciliationDetailItem.snapshot)}
</pre>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
<Dialog
open={Boolean(reconciliationConfirmTarget)}
onOpenChange={(open) => {
if (!open) {
setReconciliationConfirmTarget(null);
setReconciliationConfirmNote("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>确认对账异常</DialogTitle>
<DialogDescription>
{reconciliationConfirmTarget
? `${reconciliationConfirmTarget.anomalyCode ?? reconciliationConfirmTarget.matchStatus}`
: "可选填写备注"}
</DialogDescription>
</DialogHeader>
<textarea
className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
placeholder="备注可空"
value={reconciliationConfirmNote}
onChange={(event) => setReconciliationConfirmNote(event.target.value)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setReconciliationConfirmTarget(null);
setReconciliationConfirmNote("");
}}
>
取消
</Button>
<Button
type="button"
disabled={reconciliationConfirmMutation.isPending || !reconciliationConfirmTarget}
onClick={() =>
reconciliationConfirmTarget &&
reconciliationConfirmMutation.mutate(reconciliationConfirmTarget.id)
}
>
{reconciliationConfirmMutation.isPending ? "提交中..." : "确认异常"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(cancelTarget)}
onOpenChange={(open) => {
if (!open) {
setCancelTarget(null);
}
}}
title="确认取消付款批次"
description={
cancelTarget
? `将取消 ${cancelTarget.batchNo},该操作与支付批次页保持一致。`
: undefined
}
variant="destructive"
confirmLabel="确认取消"
onConfirm={async () => {
if (!cancelTarget) {
return;
}
await cancelMutation.mutateAsync(cancelTarget.id);
}}
/>
<ConfirmDialog
open={Boolean(retryTarget)}
onOpenChange={(open) => {
if (!open) {
setRetryTarget(null);
}
}}
title="确认重试付款批次"
description={
retryTarget
? `将重新执行 ${retryTarget.batchNo},失败项 ${retryTarget.entityCode} 将按原批次流程重试。`
: undefined
}
confirmLabel="确认重试"
onConfirm={async () => {
if (!retryTarget) {
return;
}
await executeMutation.mutateAsync(retryTarget.batchId);
}}
/>
</div>
);
}