feat(P20T2-4): 差旅支付轨道卡片与发票绑定前端交互

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 dc74d9e53f
当前提交 2553955f28
共修改 9 个文件,包含 977 行新增和 21 行删除
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import type { TravelPaymentChannel, TravelPaymentOrder } from "../../api/travelPayments";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
const PAYMENT_CHANNEL_OPTIONS: Array<{ value: TravelPaymentChannel; label: string }> = [
{ value: "bank_transfer", label: "银行转账" },
{ value: "corporate_card", label: "企业卡" },
{ value: "virtual_card", label: "虚拟卡" },
{ value: "manual", label: "手工录入" },
{ value: "other", label: "其他" },
];
export type TravelOrderMarkPaidDialogProps = {
open: boolean;
order: TravelPaymentOrder | null;
pending?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (payload: {
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: TravelPaymentChannel | null;
}) => void;
};
export function TravelOrderMarkPaidDialog({
open,
order,
pending = false,
onOpenChange,
onConfirm,
}: TravelOrderMarkPaidDialogProps) {
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
const [paymentDate, setPaymentDate] = useState("");
const [paymentChannel, setPaymentChannel] = useState<TravelPaymentChannel | "">("bank_transfer");
useEffect(() => {
if (!open) {
return;
}
setPaymentVoucherNo(order?.paymentVoucherNo ?? "");
setPaymentDate(order?.paymentDate ?? "");
setPaymentChannel((order?.paymentChannel as TravelPaymentChannel | null) ?? "bank_transfer");
}, [open, order]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>标记差旅订单已支付</DialogTitle>
<DialogDescription>
确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有 ERP journal 出站记录。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="travel-order-payment-voucher">付款凭证号</Label>
<Input
id="travel-order-payment-voucher"
className="mt-1"
placeholder="可留空"
value={paymentVoucherNo}
onChange={(event) => setPaymentVoucherNo(event.target.value)}
/>
</div>
<div>
<Label htmlFor="travel-order-payment-date">付款日期</Label>
<Input
id="travel-order-payment-date"
className="mt-1"
type="date"
value={paymentDate}
onChange={(event) => setPaymentDate(event.target.value)}
/>
</div>
</div>
<div>
<Label htmlFor="travel-order-payment-channel">付款渠道</Label>
<select
id="travel-order-payment-channel"
className="select-std mt-1"
value={paymentChannel}
onChange={(event) => setPaymentChannel(event.target.value as TravelPaymentChannel | "")}
>
{PAYMENT_CHANNEL_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button
type="button"
onClick={() =>
onConfirm({
paymentVoucherNo: paymentVoucherNo.trim() || null,
paymentDate: paymentDate || null,
paymentChannel: paymentChannel || null,
})
}
disabled={pending}
>
{pending ? "提交中..." : "确认已支付"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,412 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
bindInvoice,
fetchInvoicePoolList,
type InvoicePoolListItem,
} from "../../api/invoicePool";
import { isApiError } from "../../api/types";
import {
fetchTravelPaymentTrack,
markTravelOrderPaid,
suggestFreezeTravelTransaction,
type TravelPaymentAnomaly,
type TravelPaymentChannel,
type TravelPaymentOrder,
} from "../../api/travelPayments";
import { CardSkeleton } from "../common/CardSkeleton";
import { StatusBadge } from "../common/StatusBadge";
import { Button } from "../ui/button";
import { TravelOrderMarkPaidDialog } from "./TravelOrderMarkPaidDialog";
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
function freezeReasonFromAnomaly(code: string): "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review" {
if (code === "amount_mismatch") return "amount_mismatch";
if (code === "currency_mismatch") return "currency_mismatch";
if (code === "missing_order") return "missing_order";
if (code === "duplicate") return "duplicate";
return "manual_review";
}
function anomalyTone(level: TravelPaymentAnomaly["level"]) {
if (level === "high") {
return "border-red-200 bg-red-50 text-red-700";
}
if (level === "medium") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
return "border-zinc-200 bg-zinc-50 text-zinc-700";
}
function paymentChannelLabel(value: TravelPaymentChannel | string | null) {
if (value === "bank_transfer") return "银行转账";
if (value === "corporate_card") return "企业卡";
if (value === "virtual_card") return "虚拟卡";
if (value === "manual") return "手工录入";
if (value === "other") return "其他";
return "-";
}
function erpStageLabel(order: TravelPaymentOrder) {
const status = order.erpStage.exportState.lastOutboxStatus;
if (status) {
return status;
}
if (order.erpStage.binding?.externalId) {
return "已同步";
}
return "ERP 出站未开启";
}
type TravelPaymentTrackCardProps = {
travelRequestId: string;
};
export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCardProps) {
const queryClient = useQueryClient();
const [markPaidOrder, setMarkPaidOrder] = useState<TravelPaymentOrder | null>(null);
const [selectedInvoiceMap, setSelectedInvoiceMap] = useState<Record<string, string>>({});
const trackQuery = useQuery({
enabled: Boolean(travelRequestId),
queryKey: ["travel-requests", "payment-track", travelRequestId],
queryFn: () => fetchTravelPaymentTrack(travelRequestId),
});
const availableInvoicesQuery = useQuery({
enabled: trackQuery.data?.permissions.canManagePayments === true,
queryKey: ["invoice-pool", "available-for-travel-bind"],
queryFn: () =>
fetchInvoicePoolList({
status: "available",
ownerScope: "all",
page: 1,
pageSize: 50,
}),
});
const markPaidMutation = useMutation({
mutationFn: ({ orderId, payload }: { orderId: string; payload: { paymentVoucherNo: string | null; paymentDate: string | null; paymentChannel: TravelPaymentChannel | null } }) =>
markTravelOrderPaid(orderId, payload),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
]);
toast.success("差旅订单已标记为已支付");
setMarkPaidOrder(null);
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const bindInvoiceMutation = useMutation({
mutationFn: ({ invoiceId, orderId }: { invoiceId: string; orderId: string }) =>
bindInvoice(invoiceId, { travelOrderId: orderId }),
onSuccess: async (_, variables) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
]);
setSelectedInvoiceMap((current) => ({ ...current, [variables.orderId]: "" }));
toast.success("发票已绑定到差旅订单");
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const freezeMutation = useMutation({
mutationFn: ({ transactionId, reasonCode }: { transactionId: string; reasonCode: "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review" }) =>
suggestFreezeTravelTransaction(transactionId, { reasonCode, note: null }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["travel-requests", "payment-track", travelRequestId] });
toast.success("已记录冻结建议");
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const anomaliesByTransactionId = useMemo(() => {
const map = new Map<string, TravelPaymentAnomaly[]>();
for (const item of trackQuery.data?.anomalies ?? []) {
const list = map.get(item.transactionId) ?? [];
list.push(item);
map.set(item.transactionId, list);
}
return map;
}, [trackQuery.data?.anomalies]);
const availableInvoices = availableInvoicesQuery.data?.items ?? [];
const canManagePayments = trackQuery.data?.permissions.canManagePayments ?? false;
if (trackQuery.isLoading) {
return <CardSkeleton lines={8} />;
}
if (trackQuery.isError) {
return (
<div className="card-std p-6">
<h3 className="text-base font-semibold text-zinc-900">对公支付轨道</h3>
<p className="mt-3 text-sm text-zinc-500">支付轨道加载失败,但不影响差旅主页面继续使用。</p>
<p className="mt-2 text-sm text-red-600">{resolveError(trackQuery.error)}</p>
</div>
);
}
const track = trackQuery.data;
if (!track) {
return null;
}
return (
<>
<div className="card-std space-y-5 p-6">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">对公支付轨道</h3>
<p className="mt-1 text-sm text-zinc-500">授权、交易、发票绑定与 ERP 支付出站在此统一查看。</p>
</div>
{canManagePayments ? <span className="text-xs text-zinc-500">具备支付管理权限</span> : null}
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-xl border border-zinc-200 p-4">
<h4 className="text-sm font-semibold text-zinc-900">申请预算账轨</h4>
{track.requestBudgetTrack.items.length ? (
<div className="mt-3 space-y-2 text-sm text-zinc-600">
{track.requestBudgetTrack.items.map((item) => (
<div key={item.budgetPoolId} className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="font-medium text-zinc-900">{item.budgetPoolName ?? item.budgetPoolCode ?? item.budgetPoolId}</div>
<div className="mt-1 text-xs">reserve {item.reserve ?? "0.00"} · confirm {item.confirm ?? "0.00"} · release {item.release ?? "0.00"}</div>
</div>
))}
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">当前没有申请预算流水。</p>
)}
</div>
<div className="rounded-xl border border-zinc-200 p-4">
<h4 className="text-sm font-semibold text-zinc-900">支付预算账轨</h4>
{track.corporateBudgetTrack.items.length ? (
<div className="mt-3 space-y-2 text-sm text-zinc-600">
{track.corporateBudgetTrack.items.map((item) => (
<div key={item.budgetPoolId} className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="font-medium text-zinc-900">{item.budgetPoolName ?? item.budgetPoolCode ?? item.budgetPoolId}</div>
<div className="mt-1 text-xs">authorize {item.authorize ?? "0.00"} · settle {item.settle ?? "0.00"} · reverse {item.reverse ?? "0.00"}</div>
</div>
))}
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">当前没有企业卡/支付预算流水。</p>
)}
</div>
</div>
{track.authorizations.length ? (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-zinc-900">企业卡授权</h4>
<div className="grid gap-3 xl:grid-cols-2">
{track.authorizations.map((item) => (
<div key={item.id} className="rounded-xl border border-zinc-200 p-4 text-sm">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.externalAuthNo}</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-2 space-y-1 text-xs text-zinc-500">
<div>来源:{item.source}</div>
<div>授权金额:{item.authorizedAmount} {item.currency}</div>
<div>已结算:{item.settledAmount} · 已冲回:{item.reversedAmount}</div>
</div>
</div>
))}
</div>
</div>
) : null}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-zinc-900">差旅订单支付闭环</h4>
{track.travelOrders.length ? (
<div className="space-y-4">
{track.travelOrders.map((order) => {
const selectedInvoiceId = selectedInvoiceMap[order.id] ?? "";
return (
<div key={order.id} className="rounded-xl border border-zinc-200 p-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<div className="flex items-center gap-3">
<p className="font-medium text-zinc-900">{order.externalOrderNo}</p>
<StatusBadge status={order.status} />
</div>
<div className="mt-2 space-y-1 text-xs text-zinc-500">
<div>来源:{order.source}</div>
<div>金额:{order.amount} {order.currency}</div>
<div>付款凭证:{order.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div>
<div>ERP 状态:{erpStageLabel(order)}</div>
</div>
</div>
{canManagePayments ? (
<div className="flex flex-wrap gap-2">
{order.actions.canMarkPaid ? (
<Button type="button" variant="secondary" onClick={() => setMarkPaidOrder(order)}>
标记已支付
</Button>
) : null}
</div>
) : null}
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<div className="space-y-2 text-sm">
<div className="font-medium text-zinc-900">已匹配交易</div>
{order.matchedTransactions.length ? (
order.matchedTransactions.map((transaction) => {
const transactionAnomalies = anomaliesByTransactionId.get(transaction.id) ?? [];
return (
<div key={transaction.id} className="rounded-lg bg-zinc-50 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{transaction.externalTxnId}</div>
<StatusBadge status={transaction.status} />
</div>
<div className="mt-1 text-xs text-zinc-500">
{transaction.amount} {transaction.currency} · {transaction.txnType}
</div>
{transaction.freezeReasonCode ? (
<div className="mt-2 text-xs text-amber-700">已建议冻结:{transaction.freezeReasonCode}</div>
) : null}
{canManagePayments && transactionAnomalies.length && !transaction.freezeReasonCode ? (
<div className="mt-3">
<Button
type="button"
size="sm"
variant="secondary"
disabled={freezeMutation.isPending}
onClick={() =>
freezeMutation.mutate({
transactionId: transaction.id,
reasonCode: freezeReasonFromAnomaly(transactionAnomalies[0].code),
})
}
>
{freezeMutation.isPending ? "提交中..." : "建议冻结"}
</Button>
</div>
) : null}
</div>
);
})
) : (
<p className="text-sm text-zinc-500">当前没有匹配到已导入交易。</p>
)}
</div>
<div className="space-y-2 text-sm">
<div className="font-medium text-zinc-900">已绑发票</div>
{order.boundInvoices.length ? (
order.boundInvoices.map((invoice) => (
<div key={invoice.id} className="rounded-lg bg-zinc-50 px-3 py-3 text-xs text-zinc-600">
<div className="font-medium text-zinc-900">{invoice.invoiceCode ?? invoice.originalName ?? invoice.id}</div>
<div className="mt-1">金额:{invoice.totalAmount ?? "-"}</div>
<div>票号:{invoice.invoiceNumber ?? "-"}</div>
</div>
))
) : (
<p className="text-sm text-zinc-500">当前没有已绑定发票。</p>
)}
{canManagePayments && order.actions.canBindInvoice ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-3">
{availableInvoicesQuery.isError ? (
<p className="text-xs text-zinc-500">可用发票列表暂不可用,可去发票池页面完成绑定。</p>
) : (
<div className="space-y-2">
<select
className="select-std"
value={selectedInvoiceId}
onChange={(event) =>
setSelectedInvoiceMap((current) => ({
...current,
[order.id]: event.target.value,
}))
}
>
<option value="">选择可用发票</option>
{availableInvoices.map((invoice: InvoicePoolListItem) => (
<option key={invoice.id} value={invoice.id}>
{invoice.invoiceCode ?? invoice.originalName ?? invoice.id} ({invoice.totalAmount ?? "-"})
</option>
))}
</select>
<Button
type="button"
size="sm"
variant="secondary"
disabled={bindInvoiceMutation.isPending || !selectedInvoiceId}
onClick={() => bindInvoiceMutation.mutate({ invoiceId: selectedInvoiceId, orderId: order.id })}
>
{bindInvoiceMutation.isPending ? "绑定中..." : "绑定发票"}
</Button>
</div>
)}
</div>
) : null}
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-zinc-500">当前差旅申请还没有进入对公支付闭环。</p>
)}
</div>
{track.anomalies.length ? (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-zinc-900">异常提示</h4>
<div className="space-y-2">
{track.anomalies.map((item) => (
<div key={`${item.transactionId}-${item.code}`} className={`rounded-lg border px-3 py-3 text-sm ${anomalyTone(item.level)}`}>
<div className="font-medium">{item.title}</div>
<div className="mt-1 text-xs">{item.detail}</div>
</div>
))}
</div>
</div>
) : null}
</div>
<TravelOrderMarkPaidDialog
open={Boolean(markPaidOrder)}
order={markPaidOrder}
pending={markPaidMutation.isPending}
onOpenChange={(open) => {
if (!open) {
setMarkPaidOrder(null);
}
}}
onConfirm={(payload) => {
if (!markPaidOrder) {
return;
}
markPaidMutation.mutate({ orderId: markPaidOrder.id, payload });
}}
/>
</>
);
}