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

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 dc74d9e53f
当前提交 2553955f28
共修改 9 个文件,包含 977 行新增和 21 行删除
+22 -2
查看文件
@@ -34,6 +34,7 @@ export type InvoicePoolListItem = {
totalAmount: string | null;
linkedReimbursementId: string | null;
linkedItemId: string | null;
linkedTravelOrderId: string | null;
originalName: string;
mimeType: string;
fileSize: number;
@@ -88,6 +89,7 @@ export type InvoicePoolDetail = {
totalAmount: string | null;
linkedReimbursementId: string | null;
linkedItemId: string | null;
linkedTravelOrderId: string | null;
isDuplicateOverride: boolean;
overrideRefFormNumber: string | null;
overrideReason: string | null;
@@ -104,6 +106,15 @@ export type InvoicePoolListQuery = {
ownerScope?: "own" | "all";
};
export type InvoiceBindPayload =
| {
reimbursementId: string;
linkedItemId?: string;
}
| {
travelOrderId: string;
};
function toQueryString(query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
@@ -147,10 +158,19 @@ export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeI
);
}
export async function bindInvoice(invoiceId: string, reimbursementId: string, linkedItemId?: string) {
export async function bindInvoice(
invoiceId: string,
reimbursementIdOrPayload: string | InvoiceBindPayload,
linkedItemId?: string,
) {
const payload =
typeof reimbursementIdOrPayload === "string"
? { reimbursementId: reimbursementIdOrPayload, linkedItemId }
: reimbursementIdOrPayload;
return apiRequestData<Record<string, unknown>>(`/api/invoice-pool/${invoiceId}/bind`, {
method: "POST",
body: JSON.stringify({ reimbursementId, linkedItemId }),
body: JSON.stringify(payload),
});
}
+259
查看文件
@@ -0,0 +1,259 @@
import { apiRequestData } from "./http";
export type TravelPaymentChannel =
| "bank_transfer"
| "corporate_card"
| "virtual_card"
| "manual"
| "other";
export type TravelPaymentBudgetSummaryItem = {
budgetPoolId: string;
budgetPoolCode: string | null;
budgetPoolName: string | null;
outstanding: string;
reserve?: string;
confirm?: string;
release?: string;
confirmPending?: string;
authorize?: string;
settle?: string;
reverse?: string;
settlePending?: string;
};
export type TravelPaymentAuthorization = {
id: string;
source: string;
externalAuthNo: string;
employeeId: string;
departmentId: string | null;
travelRequestId: string;
budgetPoolId: string | null;
legalEntityId: string;
costCenterId: string;
authorizedAmount: string;
settledAmount: string;
reversedAmount: string;
currency: string;
occurredAt: string | null;
expiresAt: string | null;
status: string;
createdAt: string | null;
updatedAt: string | null;
};
export type TravelPaymentMatchedOrder = {
id: string;
externalOrderNo: string;
status: string;
amount: string;
currency: string;
exactMatch: boolean;
};
export type TravelPaymentTransaction = {
id: string;
source: string;
externalTxnId: string;
cardAuthorizationId: string | null;
employeeId: string;
departmentId: string | null;
travelRequestId: string;
travelOrderId: string | null;
amount: string;
currency: string;
txnType: string;
status: string;
occurredAt: string | null;
freezeSuggestedAt: string | null;
freezeSuggestedBy: string | null;
freezeReasonCode: string | null;
freezeReasonNote: string | null;
matchedOrder: TravelPaymentMatchedOrder | null;
createdAt: string | null;
updatedAt: string | null;
};
export type TravelPaymentBoundInvoice = {
id: string;
status: string;
originalName: string | null;
totalAmount: string | null;
invoiceCode: string | null;
invoiceNumber: string | null;
invoiceDate: string | null;
linkedReimbursementId: string | null;
linkedTravelOrderId: string | null;
};
export type TravelPaymentErpStage = {
binding: {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
} | null;
outbox: {
id: string;
eventType: string;
status: string;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
} | null;
exportState: {
externalId: string | null;
lastOutboxId: string | null;
lastOutboxStatus: string | null;
lastError: string | null;
exportedAt: string | null;
};
};
export type TravelPaymentOrder = {
id: string;
externalOrderNo: string;
source: string;
amount: string;
currency: string;
status: string;
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: TravelPaymentChannel | null;
paidAt: string | null;
paidBy: string | null;
boundInvoices: TravelPaymentBoundInvoice[];
matchedTransactions: TravelPaymentTransaction[];
erpStage: TravelPaymentErpStage;
actions: {
canMarkPaid: boolean;
canBindInvoice: boolean;
};
};
export type TravelPaymentAnomaly = {
code: string;
level: "low" | "medium" | "high";
title: string;
detail: string;
transactionId: string;
travelOrderId: string | null;
};
export type TravelPaymentTrack = {
travelRequestId: string;
requestNo: string | null;
requestBudgetTrack: {
items: TravelPaymentBudgetSummaryItem[];
};
corporateBudgetTrack: {
items: TravelPaymentBudgetSummaryItem[];
};
authorizations: TravelPaymentAuthorization[];
transactions: TravelPaymentTransaction[];
travelOrders: TravelPaymentOrder[];
anomalies: TravelPaymentAnomaly[];
permissions: {
canManagePayments: boolean;
};
};
export type TravelAuthorizationListResponse = {
page: number;
pageSize: number;
total: number;
items: TravelPaymentAuthorization[];
};
export type TravelTransactionListResponse = {
page: number;
pageSize: number;
total: number;
items: TravelPaymentTransaction[];
};
function toQueryString(query: Record<string, string | number | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, typeof value === "boolean" ? (value ? "1" : "0") : String(value));
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchTravelAuthorizations(query: Record<string, string | number | boolean | undefined> = {}) {
return apiRequestData<TravelAuthorizationListResponse>(
`/api/travel/authorizations${toQueryString(query)}`,
);
}
export async function importTravelAuthorizations(payload: { authorizations: Array<Record<string, unknown>> }) {
return apiRequestData<Record<string, unknown>>("/api/travel/authorizations/import", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function fetchTravelTransactions(query: Record<string, string | number | boolean | undefined> = {}) {
return apiRequestData<TravelTransactionListResponse>(
`/api/travel/transactions${toQueryString(query)}`,
);
}
export async function importTravelTransactions(payload: { transactions: Array<Record<string, unknown>> }) {
return apiRequestData<Record<string, unknown>>("/api/travel/transactions/import", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function suggestFreezeTravelTransaction(
transactionId: string,
payload: {
reasonCode: "amount_mismatch" | "currency_mismatch" | "missing_order" | "duplicate" | "manual_review";
note?: string | null;
},
) {
return apiRequestData<TravelPaymentTransaction>(`/api/travel/transactions/${transactionId}/freeze-suggestion`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function markTravelOrderPaid(
travelOrderId: string,
payload: {
paymentVoucherNo?: string | null;
paymentDate?: string | null;
paymentChannel?: TravelPaymentChannel | null;
},
) {
return apiRequestData<{
travelOrderId: string;
travelRequestId: string | null;
status: string;
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: TravelPaymentChannel | null;
erpJournal: {
eventType: string;
outboxId: string | null;
dedupeKey: string;
enqueued: boolean;
};
}>(`/api/travel-orders/${travelOrderId}/mark-paid`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function fetchTravelPaymentTrack(travelRequestId: string) {
return apiRequestData<TravelPaymentTrack>(`/api/travel-requests/${travelRequestId}/payment-track`);
}
+6
查看文件
@@ -73,10 +73,16 @@ export type TravelOrderLink = {
externalOrderNo: string;
employeeId: string;
linkedReimbursementId: string | null;
travelRequestId: string | null;
occurredAt: string | null;
amount: string | null;
currency: string;
status: string;
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: string | null;
paidAt: string | null;
paidBy: string | null;
createdAt: string | null;
updatedAt: string | null;
};
+3
查看文件
@@ -90,6 +90,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"expense.view_all",
"procurement.view_all",
"travel.view_all",
"travel.payment.manage",
"asset.view_all",
],
finance_mgr: [
@@ -111,6 +112,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"procurement.manage",
"travel.view_all",
"travel.manage",
"travel.payment.manage",
"asset.view_all",
"asset.manage",
"asset.inventory",
@@ -124,6 +126,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"procurement.manage",
"travel.view_all",
"travel.manage",
"travel.payment.manage",
"asset.view_all",
"asset.manage",
"asset.transfer",
@@ -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 });
}}
/>
</>
);
}
+51 -4
查看文件
@@ -21,6 +21,7 @@ import {
type InvoicePoolStatus,
} from "../api/invoicePool";
import { fetchReimbursements } from "../api/reimbursements";
import { fetchTravelRequestDetail, fetchTravelRequests } from "../api/travelRequests";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { toast } from "sonner";
@@ -30,7 +31,7 @@ import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
import { InvoiceListCard } from "./invoice-pool/InvoiceListCard";
import { InvoiceBindCard } from "./invoice-pool/InvoiceBindCard";
import { InvoiceBindCard, type InvoiceBindMode } from "./invoice-pool/InvoiceBindCard";
import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel";
const PAGE_SIZE = 20;
@@ -43,6 +44,7 @@ export function InvoicePoolPage() {
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canVerifyInvoice = hasPermission(permissions, "invoice.verify");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
@@ -51,7 +53,10 @@ export function InvoicePoolPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
const [bindMode, setBindMode] = useState<InvoiceBindMode>("reimbursement");
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState("");
const [editOpen, setEditOpen] = useState(false);
const [editSetAvailable, setEditSetAvailable] = useState(false);
@@ -81,6 +86,18 @@ export function InvoicePoolPage() {
queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }),
});
const travelRequestQuery = useQuery({
enabled: canManageTravelPayments,
queryKey: ["travel-requests", "for-invoice-bind"],
queryFn: () => fetchTravelRequests({ status: "approved", mine: false, page: 1, pageSize: 50 }),
});
const travelRequestDetailQuery = useQuery({
enabled: canManageTravelPayments && bindMode === "travelOrder" && Boolean(selectedTravelRequestId),
queryKey: ["travel-requests", "for-invoice-bind", selectedTravelRequestId],
queryFn: () => fetchTravelRequestDetail(selectedTravelRequestId),
});
const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"],
@@ -132,6 +149,15 @@ export function InvoicePoolPage() {
}
}, [canManageInvoice, ownerScope]);
useEffect(() => {
if (canManageTravelPayments) {
return;
}
setBindMode("reimbursement");
setSelectedTravelRequestId("");
setSelectedTravelOrderId("");
}, [canManageTravelPayments]);
useEffect(() => {
if (!detailInvoiceId && listQuery.data?.items?.length) {
setDetailInvoiceId(listQuery.data.items[0].id);
@@ -169,14 +195,26 @@ export function InvoicePoolPage() {
const bindMutation = useMutation({
mutationFn: async () => {
if (!selectedInvoiceId || !selectedReimbursementId) {
throw new Error("请选择票据与报销单");
if (!selectedInvoiceId) {
throw new Error("请选择票据");
}
return bindInvoice(selectedInvoiceId, selectedReimbursementId);
if (bindMode === "reimbursement") {
if (!selectedReimbursementId) {
throw new Error("请选择报销单");
}
return bindInvoice(selectedInvoiceId, selectedReimbursementId);
}
if (!selectedTravelOrderId) {
throw new Error("请选择差旅订单");
}
return bindInvoice(selectedInvoiceId, { travelOrderId: selectedTravelOrderId });
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
setSelectedInvoiceId("");
setSelectedReimbursementId("");
setSelectedTravelOrderId("");
toast.success("绑定成功");
},
onError: (err) => handleError(err, "绑定失败,请稍后重试"),
@@ -380,11 +418,20 @@ export function InvoicePoolPage() {
<InvoiceBindCard
listQuery={listQuery}
reimbursementQuery={reimbursementQuery}
travelRequestQuery={travelRequestQuery}
travelRequestDetailQuery={travelRequestDetailQuery}
bindMutation={bindMutation}
bindMode={bindMode}
setBindMode={setBindMode}
canManageTravelPayments={canManageTravelPayments}
selectedInvoiceId={selectedInvoiceId}
setSelectedInvoiceId={setSelectedInvoiceId}
selectedReimbursementId={selectedReimbursementId}
setSelectedReimbursementId={setSelectedReimbursementId}
selectedTravelRequestId={selectedTravelRequestId}
setSelectedTravelRequestId={setSelectedTravelRequestId}
selectedTravelOrderId={selectedTravelOrderId}
setSelectedTravelOrderId={setSelectedTravelOrderId}
/>
<InvoiceDetailPanel
+4
查看文件
@@ -28,6 +28,7 @@ import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
import { TravelPaymentTrackCard } from "../components/travel/TravelPaymentTrackCard";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
@@ -723,6 +724,7 @@ export function TravelRequestsPage() {
<div>发生日期:{item.occurredAt ?? "-"}</div>
<div>金额:{item.amount ?? "-"} {item.currency}</div>
<div>报销关联:{item.linkedReimbursementId ?? "-"}</div>
<div>付款渠道:{item.paymentChannel ?? "-"} · 付款日期:{item.paymentDate ?? "-"}</div>
</div>
</div>
))}
@@ -732,6 +734,8 @@ export function TravelRequestsPage() {
)}
</div>
</div>
<TravelPaymentTrackCard travelRequestId={detail.id} />
</div>
) : null}
</section>
@@ -1,30 +1,68 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { InvoicePoolListResponse } from "../../api/invoicePool";
import type { ReimbursementListResponse } from "../../api/reimbursements";
import type { TravelRequestDetail, TravelRequestListResponse } from "../../api/travelRequests";
import { Button } from "../../components/ui/button";
export type InvoiceBindMode = "reimbursement" | "travelOrder";
export type InvoiceBindCardProps = {
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
travelRequestQuery: UseQueryResult<TravelRequestListResponse, Error>;
travelRequestDetailQuery: UseQueryResult<TravelRequestDetail, Error>;
bindMutation: UseMutationResult<Record<string, unknown>, Error, void>;
bindMode: InvoiceBindMode;
setBindMode: (mode: InvoiceBindMode) => void;
canManageTravelPayments: boolean;
selectedInvoiceId: string;
setSelectedInvoiceId: (id: string) => void;
selectedReimbursementId: string;
setSelectedReimbursementId: (id: string) => void;
selectedTravelRequestId: string;
setSelectedTravelRequestId: (id: string) => void;
selectedTravelOrderId: string;
setSelectedTravelOrderId: (id: string) => void;
};
export function InvoiceBindCard({
listQuery,
reimbursementQuery,
travelRequestQuery,
travelRequestDetailQuery,
bindMutation,
bindMode,
setBindMode,
canManageTravelPayments,
selectedInvoiceId,
setSelectedInvoiceId,
selectedReimbursementId,
setSelectedReimbursementId,
selectedTravelRequestId,
setSelectedTravelRequestId,
selectedTravelOrderId,
setSelectedTravelOrderId,
}: InvoiceBindCardProps) {
const bindableTravelOrders = (travelRequestDetailQuery.data?.travelOrders ?? []).filter((item) =>
["confirmed", "paid"].includes(item.status),
);
return (
<div className="card-std p-6 space-y-4">
<h3 className="card-title">绑定报销</h3>
<div className="flex items-center justify-between gap-3">
<h3 className="card-title">绑定业务单据</h3>
{canManageTravelPayments ? (
<select
className="select-std max-w-[180px]"
value={bindMode}
onChange={(event) => setBindMode(event.target.value as InvoiceBindMode)}
>
<option value="reimbursement">绑定报销</option>
<option value="travelOrder">绑定差旅订单</option>
</select>
) : null}
</div>
<select
className="select-std"
data-testid="invoice-bind-invoice"
@@ -39,25 +77,65 @@ export function InvoiceBindCard({
))}
</select>
<select
className="select-std"
data-testid="invoice-bind-reimbursement"
onChange={(event) => setSelectedReimbursementId(event.target.value)}
value={selectedReimbursementId}
>
<option value="">选择报销单</option>
{(reimbursementQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.formNumber ?? item.id} ({item.status})
</option>
))}
</select>
{bindMode === "reimbursement" ? (
<select
className="select-std"
data-testid="invoice-bind-reimbursement"
onChange={(event) => setSelectedReimbursementId(event.target.value)}
value={selectedReimbursementId}
>
<option value="">选择报销单</option>
{(reimbursementQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.formNumber ?? item.id} ({item.status})
</option>
))}
</select>
) : (
<>
<select
className="select-std"
data-testid="invoice-bind-travel-request"
onChange={(event) => {
setSelectedTravelRequestId(event.target.value);
setSelectedTravelOrderId("");
}}
value={selectedTravelRequestId}
>
<option value="">选择差旅申请</option>
{(travelRequestQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.requestNo ?? item.purpose} ({item.status})
</option>
))}
</select>
<select
className="select-std"
data-testid="invoice-bind-travel-order"
onChange={(event) => setSelectedTravelOrderId(event.target.value)}
value={selectedTravelOrderId}
disabled={!selectedTravelRequestId}
>
<option value="">选择差旅订单</option>
{bindableTravelOrders.map((item) => (
<option key={item.id} value={item.id}>
{item.externalOrderNo} ({item.status})
</option>
))}
</select>
</>
)}
<div className="pt-2">
<Button
data-testid="invoice-bind-submit"
className="btn-primary"
disabled={bindMutation.isPending || !selectedInvoiceId || !selectedReimbursementId}
disabled={
bindMutation.isPending ||
!selectedInvoiceId ||
(bindMode === "reimbursement" ? !selectedReimbursementId : !selectedTravelOrderId)
}
onClick={() => bindMutation.mutate()}
type="button"
>