From 2553955f28009918c8ad1ef7d4ce46456a8d307e Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 11 Mar 2026 06:41:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(P20T2-4):=20=E5=B7=AE=E6=97=85=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E8=BD=A8=E9=81=93=E5=8D=A1=E7=89=87=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E7=A5=A8=E7=BB=91=E5=AE=9A=E5=89=8D=E7=AB=AF=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/invoicePool.ts | 24 +- client/src/api/travelPayments.ts | 259 +++++++++++ client/src/api/travelRequests.ts | 6 + client/src/auth/permissions.ts | 3 + .../travel/TravelOrderMarkPaidDialog.tsx | 127 ++++++ .../travel/TravelPaymentTrackCard.tsx | 412 ++++++++++++++++++ client/src/pages/InvoicePoolPage.tsx | 55 ++- client/src/pages/TravelRequestsPage.tsx | 4 + .../pages/invoice-pool/InvoiceBindCard.tsx | 108 ++++- 9 files changed, 977 insertions(+), 21 deletions(-) create mode 100644 client/src/api/travelPayments.ts create mode 100644 client/src/components/travel/TravelOrderMarkPaidDialog.tsx create mode 100644 client/src/components/travel/TravelPaymentTrackCard.tsx diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index 8d48440a..df328127 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -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) { 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>(`/api/invoice-pool/${invoiceId}/bind`, { method: "POST", - body: JSON.stringify({ reimbursementId, linkedItemId }), + body: JSON.stringify(payload), }); } diff --git a/client/src/api/travelPayments.ts b/client/src/api/travelPayments.ts new file mode 100644 index 00000000..a68da81d --- /dev/null +++ b/client/src/api/travelPayments.ts @@ -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) { + 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 = {}) { + return apiRequestData( + `/api/travel/authorizations${toQueryString(query)}`, + ); +} + +export async function importTravelAuthorizations(payload: { authorizations: Array> }) { + return apiRequestData>("/api/travel/authorizations/import", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function fetchTravelTransactions(query: Record = {}) { + return apiRequestData( + `/api/travel/transactions${toQueryString(query)}`, + ); +} + +export async function importTravelTransactions(payload: { transactions: Array> }) { + return apiRequestData>("/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(`/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(`/api/travel-requests/${travelRequestId}/payment-track`); +} diff --git a/client/src/api/travelRequests.ts b/client/src/api/travelRequests.ts index 7941d0b4..db4d895a 100644 --- a/client/src/api/travelRequests.ts +++ b/client/src/api/travelRequests.ts @@ -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; }; diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 65f4fb95..7b1b5670 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -90,6 +90,7 @@ const ROLE_PERMISSIONS: Record = { "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 = { "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 = { "procurement.manage", "travel.view_all", "travel.manage", + "travel.payment.manage", "asset.view_all", "asset.manage", "asset.transfer", diff --git a/client/src/components/travel/TravelOrderMarkPaidDialog.tsx b/client/src/components/travel/TravelOrderMarkPaidDialog.tsx new file mode 100644 index 00000000..94bc65c3 --- /dev/null +++ b/client/src/components/travel/TravelOrderMarkPaidDialog.tsx @@ -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("bank_transfer"); + + useEffect(() => { + if (!open) { + return; + } + setPaymentVoucherNo(order?.paymentVoucherNo ?? ""); + setPaymentDate(order?.paymentDate ?? ""); + setPaymentChannel((order?.paymentChannel as TravelPaymentChannel | null) ?? "bank_transfer"); + }, [open, order]); + + return ( + + + + 标记差旅订单已支付 + + 确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有 ERP journal 出站记录。 + + + +
+
+
+ + setPaymentVoucherNo(event.target.value)} + /> +
+
+ + setPaymentDate(event.target.value)} + /> +
+
+ +
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/client/src/components/travel/TravelPaymentTrackCard.tsx b/client/src/components/travel/TravelPaymentTrackCard.tsx new file mode 100644 index 00000000..c9833b02 --- /dev/null +++ b/client/src/components/travel/TravelPaymentTrackCard.tsx @@ -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(null); + const [selectedInvoiceMap, setSelectedInvoiceMap] = useState>({}); + + 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(); + 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 ; + } + + if (trackQuery.isError) { + return ( +
+

对公支付轨道

+

支付轨道加载失败,但不影响差旅主页面继续使用。

+

{resolveError(trackQuery.error)}

+
+ ); + } + + const track = trackQuery.data; + if (!track) { + return null; + } + + return ( + <> +
+
+
+

对公支付轨道

+

授权、交易、发票绑定与 ERP 支付出站在此统一查看。

+
+ {canManagePayments ? 具备支付管理权限 : null} +
+ +
+
+

申请预算账轨

+ {track.requestBudgetTrack.items.length ? ( +
+ {track.requestBudgetTrack.items.map((item) => ( +
+
{item.budgetPoolName ?? item.budgetPoolCode ?? item.budgetPoolId}
+
reserve {item.reserve ?? "0.00"} · confirm {item.confirm ?? "0.00"} · release {item.release ?? "0.00"}
+
+ ))} +
+ ) : ( +

当前没有申请预算流水。

+ )} +
+ +
+

支付预算账轨

+ {track.corporateBudgetTrack.items.length ? ( +
+ {track.corporateBudgetTrack.items.map((item) => ( +
+
{item.budgetPoolName ?? item.budgetPoolCode ?? item.budgetPoolId}
+
authorize {item.authorize ?? "0.00"} · settle {item.settle ?? "0.00"} · reverse {item.reverse ?? "0.00"}
+
+ ))} +
+ ) : ( +

当前没有企业卡/支付预算流水。

+ )} +
+
+ + {track.authorizations.length ? ( +
+

企业卡授权

+
+ {track.authorizations.map((item) => ( +
+
+
{item.externalAuthNo}
+ +
+
+
来源:{item.source}
+
授权金额:{item.authorizedAmount} {item.currency}
+
已结算:{item.settledAmount} · 已冲回:{item.reversedAmount}
+
+
+ ))} +
+
+ ) : null} + +
+

差旅订单支付闭环

+ {track.travelOrders.length ? ( +
+ {track.travelOrders.map((order) => { + const selectedInvoiceId = selectedInvoiceMap[order.id] ?? ""; + return ( +
+
+
+
+

{order.externalOrderNo}

+ +
+
+
来源:{order.source}
+
金额:{order.amount} {order.currency}
+
付款凭证:{order.paymentVoucherNo ?? "-"}
+
付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}
+
ERP 状态:{erpStageLabel(order)}
+
+
+ {canManagePayments ? ( +
+ {order.actions.canMarkPaid ? ( + + ) : null} +
+ ) : null} +
+ +
+
+
已匹配交易
+ {order.matchedTransactions.length ? ( + order.matchedTransactions.map((transaction) => { + const transactionAnomalies = anomaliesByTransactionId.get(transaction.id) ?? []; + return ( +
+
+
{transaction.externalTxnId}
+ +
+
+ {transaction.amount} {transaction.currency} · {transaction.txnType} +
+ {transaction.freezeReasonCode ? ( +
已建议冻结:{transaction.freezeReasonCode}
+ ) : null} + {canManagePayments && transactionAnomalies.length && !transaction.freezeReasonCode ? ( +
+ +
+ ) : null} +
+ ); + }) + ) : ( +

当前没有匹配到已导入交易。

+ )} +
+ +
+
已绑发票
+ {order.boundInvoices.length ? ( + order.boundInvoices.map((invoice) => ( +
+
{invoice.invoiceCode ?? invoice.originalName ?? invoice.id}
+
金额:{invoice.totalAmount ?? "-"}
+
票号:{invoice.invoiceNumber ?? "-"}
+
+ )) + ) : ( +

当前没有已绑定发票。

+ )} + + {canManagePayments && order.actions.canBindInvoice ? ( +
+ {availableInvoicesQuery.isError ? ( +

可用发票列表暂不可用,可去发票池页面完成绑定。

+ ) : ( +
+ + +
+ )} +
+ ) : null} +
+
+
+ ); + })} +
+ ) : ( +

当前差旅申请还没有进入对公支付闭环。

+ )} +
+ + {track.anomalies.length ? ( +
+

异常提示

+
+ {track.anomalies.map((item) => ( +
+
{item.title}
+
{item.detail}
+
+ ))} +
+
+ ) : null} +
+ + { + if (!open) { + setMarkPaidOrder(null); + } + }} + onConfirm={(payload) => { + if (!markPaidOrder) { + return; + } + markPaidMutation.mutate({ orderId: markPaidOrder.id, payload }); + }} + /> + + ); +} diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index d0152314..016b0a73 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -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(""); @@ -51,7 +53,10 @@ export function InvoicePoolPage() { const [selectedFile, setSelectedFile] = useState(null); const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState(""); const [selectedInvoiceId, setSelectedInvoiceId] = useState(""); + const [bindMode, setBindMode] = useState("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() { 发生日期:{item.occurredAt ?? "-"}
金额:{item.amount ?? "-"} {item.currency}
报销关联:{item.linkedReimbursementId ?? "-"}
+
付款渠道:{item.paymentChannel ?? "-"} · 付款日期:{item.paymentDate ?? "-"}
))} @@ -732,6 +734,8 @@ export function TravelRequestsPage() { )} + + ) : null} diff --git a/client/src/pages/invoice-pool/InvoiceBindCard.tsx b/client/src/pages/invoice-pool/InvoiceBindCard.tsx index 3f2df04c..405440b4 100644 --- a/client/src/pages/invoice-pool/InvoiceBindCard.tsx +++ b/client/src/pages/invoice-pool/InvoiceBindCard.tsx @@ -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; reimbursementQuery: UseQueryResult; + travelRequestQuery: UseQueryResult; + travelRequestDetailQuery: UseQueryResult; bindMutation: UseMutationResult, 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 (
-

绑定报销

+
+

绑定业务单据

+ {canManageTravelPayments ? ( + + ) : null} +
+ setSelectedReimbursementId(event.target.value)} - value={selectedReimbursementId} - > - - {(reimbursementQuery.data?.items ?? []).map((item) => ( - - ))} - + {bindMode === "reimbursement" ? ( + + ) : ( + <> + + + + + )}