diff --git a/client/src/api/apOperations.ts b/client/src/api/apOperations.ts index 556a0bc7..1bc2c6a3 100644 --- a/client/src/api/apOperations.ts +++ b/client/src/api/apOperations.ts @@ -1,6 +1,48 @@ -import { apiRequestData } from "./http"; +import { apiRequestBlob, apiRequestData } from "./http"; import type { RiskFlag } from "./feeControl"; +export type ApOperationsExportFormat = "csv" | "xlsx"; +export type ApOperationsRiskBucket = "all" | "exception" | "budget_overspend"; +export type ApOperationsPendingEntityType = "reimbursement" | "travel_order"; + +export type ApPendingPaymentBatchSummary = { + id: string; + batchNo: string; + entityType: string; + name: string; + status: string; + totalAmount: string; + highRisk: boolean; + createdAt: string | null; +}; + +export type ApFailedPaymentBatchItemSummary = { + id: string; + paymentBatchId: string; + entityId: string; + entityCode: string; + entityTitle: string; + amount: string; + currencyCode: string; + failureCode: string | null; + failureMessage: string | null; + createdAt: string | null; +}; + +export type ApPartialMatchItemSummary = { + id: string; + travelReconciliationBatchId: string; + travelOrderId: string | null; + travelRequestId: string | null; + matchStatus: string; + resolutionStatus: string; + anomalyCode: string | null; + orderAmount: string | null; + transactionAmount: string | null; + currency: string; + createdAt: string | null; +}; + export type ApOperationsSummary = { summary: { pendingPaymentCount: number; @@ -8,13 +50,29 @@ export type ApOperationsSummary = { exceptionCount: number; budgetOverspendCount: number; }; - pendingPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; totalAmount: string; highRisk: boolean; createdAt: string | null }>; - failedPaymentBatchItems: Array<{ id: string; paymentBatchId: string; entityId: string; entityCode: string; entityTitle: string; amount: string; currencyCode: string; failureCode: string | null; failureMessage: string | null; createdAt: string | null }>; + pendingPaymentBatches: ApPendingPaymentBatchSummary[]; + failedPaymentBatchItems: ApFailedPaymentBatchItemSummary[]; exceptionRiskFlags: RiskFlag[]; budgetOverspendRisks: RiskFlag[]; - partialMatchItems: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>; + partialMatchItems: ApPartialMatchItemSummary[]; }; export async function fetchApOperations() { return apiRequestData("/api/ap-operations"); } + +export async function exportApOperations(params: { + format: ApOperationsExportFormat; + pendingEntityType?: ApOperationsPendingEntityType; + pendingCreatedAtFrom?: string; + pendingCreatedAtTo?: string; + riskBucket?: ApOperationsRiskBucket; +}) { + return apiRequestBlob("/api/exports/ap-operations", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(params), + }); +} diff --git a/client/src/api/feeControl.ts b/client/src/api/feeControl.ts index 4b09aa8d..77cc711b 100644 --- a/client/src/api/feeControl.ts +++ b/client/src/api/feeControl.ts @@ -35,6 +35,25 @@ export type PrecheckResponse = { nextActions: string[]; }; +export type RiskFlagListResponse = { + items: RiskFlag[]; +}; + +function buildRiskFlagsSearch(query: { + entityType?: string; + entityId?: string; + status?: "open" | "resolved" | "dismissed"; + source?: string; +}) { + const params = new URLSearchParams(); + if (query.entityType) params.set("entityType", query.entityType); + if (query.entityId) params.set("entityId", query.entityId); + if (query.status) params.set("status", query.status); + if (query.source) params.set("source", query.source); + const search = params.toString(); + return search ? `?${search}` : ""; +} + export async function precheckEntity(payload: { entityType: "reimbursement" | "purchase_request" | "travel_request"; entityId: string; @@ -44,3 +63,26 @@ export async function precheckEntity(payload: { body: JSON.stringify(payload), }); } + +export async function fetchRiskFlags(query: { + entityType?: string; + entityId?: string; + status?: "open" | "resolved" | "dismissed"; + source?: string; +} = {}) { + return apiRequestData(`/api/risk-flags${buildRiskFlagsSearch(query)}`); +} + +export async function resolveRiskFlag(riskFlagId: string, payload: { note?: string | null } = {}) { + return apiRequestData<{ item: RiskFlag }>(`/api/risk-flags/${riskFlagId}/resolve`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function dismissRiskFlag(riskFlagId: string, payload: { note?: string | null } = {}) { + return apiRequestData<{ item: RiskFlag }>(`/api/risk-flags/${riskFlagId}/dismiss`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx index 256dbee3..2029c11e 100644 --- a/client/src/pages/ApOperationsPage.tsx +++ b/client/src/pages/ApOperationsPage.tsx @@ -1,78 +1,1343 @@ -import { useQuery } from "@tanstack/react-query"; -import { fetchApOperations } from "../api/apOperations"; +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 { RiskFlagsCard } from "../components/common/RiskFlagsCard"; +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(), }); - if (summaryQuery.isLoading) { - return
加载 AP 运营视图...
; + 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 运营工作台...
; } - if (summaryQuery.isError || !summaryQuery.data) { - return
AP 运营视图加载失败。
; - } - - const data = summaryQuery.data; return (
- + + + + + + } + /> + + {summaryQuery.isError ? ( + + ) : null} + {paymentBatchesQuery.isError ? ( + + ) : null} + {riskFlagsQuery.isError ? ( + + ) : null}
-
待付款
{data.summary.pendingPaymentCount}
-
部分匹配
{data.summary.partialMatchCount}
-
异常
{data.summary.exceptionCount}
-
预算超支
{data.summary.budgetOverspendCount}
+ + + +
-
-
-

待付款批次

-
- {data.pendingPaymentBatches.length ? data.pendingPaymentBatches.map((item) => ( -
-
{item.batchNo}
-
{item.name}
-
{item.entityType} · {item.status} · {item.totalAmount}{item.highRisk ? " · 高风险" : ""}
+
+ setActiveTab(value as OperationsTab)}> + + 待付款 {pendingBatches.length} + 异常付款 {failedItems.length} + 风险标记 {filteredRiskFlags.length} + 对账异常 {partialMatchItems.length} + + + +
+
+
实体类型
+
- )) :
暂无待付款批次
} -
-
- -
-

异常付款项

-
- {data.failedPaymentBatchItems.length ? data.failedPaymentBatchItems.map((item) => ( -
-
{item.entityCode}
-
{item.failureMessage ?? item.failureCode ?? "执行失败"}
-
{item.amount} {item.currencyCode}
+
+
创建时间从
+ setPendingCreatedAtFrom(event.target.value)} + /> +
+
+
创建时间至
+ setPendingCreatedAtTo(event.target.value)} + /> +
+
+
- )) :
暂无失败项
} -
-
-
- -
- - -
- -
-

部分匹配与对账异常

-
- {data.partialMatchItems.length ? data.partialMatchItems.map((item) => ( -
-
{item.anomalyCode ?? item.matchStatus}
-
订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}
- )) :
暂无异常
} -
+ +
+ + + + 批次 + 实体 + 状态 + 金额 + 创建时间 + 操作 + + + + {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} +