import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchExpenseTypes } from "../api/expenseTypes"; import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl"; import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations"; import { fetchProjects } from "../api/projects"; import { createReimbursementItem, deleteReimbursementItem, fetchReimbursementDetail, payReimbursement, precheckReimbursement, submitReimbursement, updateReimbursementItem, withdrawReimbursement, linkReimbursementProject, type ReimbursementItem, } from "../api/reimbursements"; import { bindInvoice, fetchBindableInvoices, fetchInvoicePoolDetail, unbindInvoice, uploadInvoice, } from "../api/invoicePool"; import { toast } from "sonner"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { CardSkeleton } from "../components/common/CardSkeleton"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr"; import { TEST_IDS } from "../lib/testIds"; import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions"; import { ReimbursementItemEditor, type ItemEditorInitialValues, type ItemEditorSaveData, } from "./reimbursement-detail/ReimbursementItemEditor"; import { buildPrecheckDialogMessage } from "../lib/precheck"; type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; type SelectedInvoiceMap = Record; function formatDateTime(value: string | null) { if (!value) return "-"; return value.slice(0, 19).replace("T", " "); } function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function buildActionErrorMessage(error: unknown, fallbackMessage: string) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return fallbackMessage; } function resolveErpSyncStatus( exportState: | { exportedAt: string | null; lastOutboxStatus: string | null; } | null, ) { if (!exportState) { return "未同步"; } if (exportState.lastOutboxStatus === "pending") { return "已入队"; } if (exportState.lastOutboxStatus === "processing") { return "处理中"; } if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") { return "已失败"; } if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) { return "已成功"; } return "未同步"; } export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const { permissions, me } = useAuth(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(""); const [itemEditor, setItemEditor] = useState(null); const [itemError, setItemError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false); const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); const [invoiceSearchInput, setInvoiceSearchInput] = useState(""); const [invoiceSearch, setInvoiceSearch] = useState(""); const [selectedInvoices, setSelectedInvoices] = useState({}); const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); const [invoiceActionError, setInvoiceActionError] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); const [editorInitialValues, setEditorInitialValues] = useState({ expenseTypeId: "", expenseDate: "", amount: "", description: "", }); const detailQuery = useQuery({ enabled: Boolean(reimbursementId), queryKey: ["reimbursements", reimbursementId], queryFn: () => fetchReimbursementDetail(reimbursementId), }); const expenseTypesQuery = useQuery({ queryKey: ["expenseTypes", "enabled"], queryFn: () => fetchExpenseTypes({ enabled: true }), }); const projectsQuery = useQuery({ queryKey: ["projects", "reimbursement-link"], queryFn: () => fetchProjects({ mine: true }), }); const canManageIntegration = hasPermission(permissions, "integration.manage"); const canSubmitExpense = hasPermission(permissions, "expense.submit"); const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); const canViewAllExpense = hasPermission(permissions, "expense.view_all"); const reimbursementPrecheckQuery = useQuery({ enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""), queryKey: ["reimbursements", reimbursementId, "precheck"], queryFn: () => precheckReimbursement(reimbursementId), }); const erpPreviewQuery = useQuery({ enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid", queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"], queryFn: () => previewReimbursementJournal(reimbursementId), }); const bindableInvoicesQuery = useQuery({ enabled: Boolean(reimbursementId) && isInvoiceDialogOpen && canUploadInvoice && canViewOwnInvoice && Boolean(detailQuery.data) && ["draft", "rejected"].includes(detailQuery.data?.status ?? "") && Boolean(me?.id) && [detailQuery.data?.ownerId, detailQuery.data?.submitterId].includes(me?.id ?? ""), queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch], queryFn: () => fetchBindableInvoices({ reimbursementId, search: invoiceSearch || undefined, limit: 20, }), refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, }); useEffect(() => { const timerId = window.setTimeout(() => { setInvoiceSearch(invoiceSearchInput.trim()); }, 300); return () => window.clearTimeout(timerId); }, [invoiceSearchInput]); useEffect(() => { const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id)); setSelectedInvoices((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))), ); }, [bindableInvoicesQuery.data?.items]); const mutation = useMutation({ mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } if (params.action === "submit") { return submitReimbursement(reimbursementId); } if (params.action === "withdraw") { return withdrawReimbursement(reimbursementId, params.comment); } return payReimbursement(reimbursementId, { paymentVoucherNo: params.payload.paymentVoucherNo || undefined, paymentDate: params.payload.paymentDate || undefined, paymentChannel: params.payload.paymentChannel, }); }, onSuccess: async (_data, params) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setActionError(""); if (params.action === "submit") { setSubmitConfirmPrecheck(null); } const actionMessages: Record = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" }; toast.success(actionMessages[params.action] ?? "操作成功"); }, onError: (error) => { if (isApiError(error)) { const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; setActionError(msg); toast.error(msg); return; } if (error instanceof Error) { setActionError(error.message); toast.error(error.message); return; } setActionError("操作失败,请稍后重试"); toast.error("操作失败,请稍后重试"); }, }); const itemSaveMutation = useMutation({ mutationFn: async (data: ItemEditorSaveData) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } if (!itemEditor) { throw new Error("missing itemEditor"); } const payload = { expenseTypeId: data.expenseTypeId, expenseDate: data.expenseDate, amount: data.amount, description: data.description.trim() || undefined, }; if (itemEditor.mode === "create") { return createReimbursementItem(reimbursementId, payload); } return updateReimbursementItem(reimbursementId, itemEditor.itemId, payload); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setItemEditor(null); setItemError(""); toast.success("明细已保存"); }, onError: (error) => { if (isApiError(error)) { const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; setItemError(msg); toast.error(msg); return; } if (error instanceof Error) { setItemError(error.message); toast.error(error.message); return; } setItemError("保存明细失败,请稍后重试"); toast.error("保存明细失败,请稍后重试"); }, }); const itemDeleteMutation = useMutation({ mutationFn: async (itemId: string) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } return deleteReimbursementItem(reimbursementId, itemId); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setItemError(""); toast.success("明细已删除"); }, onError: (error) => { if (isApiError(error)) { const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; setItemError(msg); toast.error(msg); return; } if (error instanceof Error) { setItemError(error.message); toast.error(error.message); return; } setItemError("删除明细失败,请稍后重试"); toast.error("删除明细失败,请稍后重试"); }, }); const projectLinkMutation = useMutation({ mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["projects"] }), queryClient.invalidateQueries({ queryKey: ["portal-home"] }), ]); toast.success("已更新关联项目"); }, onError: (error) => { const message = isApiError(error) ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` : error instanceof Error ? error.message : "关联项目失败"; toast.error(message); }, }); const erpExportMutation = useMutation({ mutationFn: async () => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } return exportReimbursementJournal(reimbursementId, true); }, onSuccess: async (result) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), ]); setActionError(""); const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队"; toast.success(`财务桥接出队${suffix}`); }, onError: (error) => { if (isApiError(error)) { const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; setActionError(msg); toast.error(msg); return; } if (error instanceof Error) { setActionError(error.message); toast.error(error.message); return; } setActionError("财务桥接出队失败,请稍后重试"); toast.error("财务桥接出队失败,请稍后重试"); }, }); const bindInvoicesMutation = useMutation({ mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => { if (!reimbursementId) { throw new Error("missing reimbursementId"); } const results: Array<{ invoiceId: string; success: boolean; message?: string }> = []; for (const selection of invoiceSelections) { try { await bindInvoice(selection.invoiceId, { reimbursementId, ...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : {}), }); results.push({ invoiceId: selection.invoiceId, success: true }); } catch (error) { results.push({ invoiceId: selection.invoiceId, success: false, message: buildActionErrorMessage(error, "绑定发票失败"), }); } } return results; }, onSuccess: async (results) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), ]); const failedResults = results.filter((item) => !item.success); const succeededIds = results.filter((item) => item.success).map((item) => item.invoiceId); setSelectedInvoices((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), ); if (!failedResults.length) { setInvoiceActionError(""); setIsInvoiceDialogOpen(false); setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoices({}); setSelectedInvoiceFiles([]); setPendingOcrInvoiceIds([]); toast.success(`已绑定 ${succeededIds.length} 张发票`); return; } const summary = failedResults .map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "绑定失败"}`) .join(";"); setInvoiceActionError(summary); if (succeededIds.length) { toast.success(`已绑定 ${succeededIds.length} 张,${failedResults.length} 张失败`); } else { toast.error("发票绑定失败"); } }, onError: (error) => { const message = buildActionErrorMessage(error, "发票绑定失败"); setInvoiceActionError(message); toast.error(message); }, }); const unbindInvoiceMutation = useMutation({ mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), ]); setInvoiceActionError(""); toast.success("已解绑发票"); }, onError: (error) => { const message = buildActionErrorMessage(error, "解绑发票失败"); setInvoiceActionError(message); toast.error(message); }, }); const uploadInvoiceMutation = useMutation({ mutationFn: async (files: File[]) => { const ownerEmployeeId = detailQuery.data?.ownerId; if (!ownerEmployeeId) { throw new Error("missing ownerEmployeeId"); } if (!files.length) { throw new Error("请选择发票文件"); } const uploadFailures: Array<{ file: File; message: string }> = []; const pendingById = new Map(); const availableIds: string[] = []; const ocrFailures: string[] = []; setInvoiceUploadProgress({ completed: 0, total: files.length }); for (const [index, file] of files.entries()) { try { const result = await uploadInvoice(file, "vat", ownerEmployeeId); if (result.status === "available") { availableIds.push(result.invoicePoolId); } else if (result.status === "pending_ocr") { pendingById.set(result.invoicePoolId, file.name); } } catch (error) { uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") }); } setInvoiceUploadProgress({ completed: index + 1, total: files.length }); } setPendingOcrInvoiceIds(Array.from(pendingById.keys())); const deadline = Date.now() + 60_000; while (pendingById.size && Date.now() < deadline) { await sleep(2_000); const invoiceResults = await Promise.all( Array.from(pendingById.keys()).map(async (invoiceId) => { try { return await fetchInvoicePoolDetail(invoiceId); } catch { return null; } }), ); for (const invoice of invoiceResults) { if (!invoice) { continue; } if (invoice.status === "available") { availableIds.push(invoice.id); pendingById.delete(invoice.id); } else if (invoice.status === "ocr_failed") { const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8); ocrFailures.push(`${fileName}: ${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`); pendingById.delete(invoice.id); } } setPendingOcrInvoiceIds(Array.from(pendingById.keys())); } return { uploadedCount: files.length - uploadFailures.length, uploadFailures, availableIds, pendingIds: Array.from(pendingById.keys()), ocrFailures, }; }, onSuccess: async (result) => { setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file)); setPendingOcrInvoiceIds(result.pendingIds); await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); const refreshed = await bindableInvoicesQuery.refetch(); const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id)); const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId)); if (selectableIds.length) { setSelectedInvoices((previous) => ({ ...previous, ...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])), })); } const messages = [ ...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`), ...result.ocrFailures, ]; if (result.pendingIds.length) { messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`); } if (messages.length) { const message = messages.join(";"); setInvoiceActionError(message); toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`); return; } setInvoiceActionError(""); toast.success(`发票上传完成:${selectableIds.length || result.uploadedCount} 张可绑定`); }, onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), onError: (error) => { const message = buildActionErrorMessage(error, "上传发票失败"); setInvoiceActionError(message); toast.error(message); }, }); if (!reimbursementId) { return (

缺少 reimbursementId 参数。

); } if (detailQuery.isLoading) { return ; } if (detailQuery.isError || !detailQuery.data) { const message = isApiError(detailQuery.error) ? `${detailQuery.error.code}: ${mapApiErrorMessage( detailQuery.error.code, detailQuery.error.details, detailQuery.error.message, )}` : "加载报销详情失败"; return (

{message}

); } const detail = detailQuery.data; const isPaidReimbursement = detail.status === "paid"; const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null); const budgetHint = detail.budgetHint; const canPay = hasPermission(permissions, "expense.finance.pay"); const canEditItems = ["draft", "rejected"].includes(detail.status); const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? ""); const canOpenInvoiceBindDialog = canEditItems && isReimbursementParticipant && canUploadInvoice && canViewOwnInvoice; const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense); const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); const missingInvoiceSubmitMessage = "提交前需先绑定至少 1 张发票,可在“关联票据”区域添加。"; const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : ""; const selectedInvoiceEntries = Object.entries(selectedInvoices); const expenseTypes = expenseTypesQuery.data?.items ?? []; const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => { const defaultDate = item?.expenseDate ?? detail.reimbursementDate ?? new Date().toISOString().slice(0, 10); const defaultExpenseTypeId = item?.expenseTypeId ?? expenseTypes[0]?.id ?? ""; return { expenseDate: defaultDate, expenseTypeId: defaultExpenseTypeId, amount: item?.amount ?? "", description: item?.description ?? "", }; }; const startCreateItem = () => { setItemEditor({ mode: "create" }); setEditorInitialValues(computeEditorDefaults()); setItemError(""); }; const startEditItem = (item: ReimbursementItem) => { setItemEditor({ mode: "edit", itemId: item.id }); setEditorInitialValues(computeEditorDefaults(item)); setItemError(""); }; const handleItemSave = (data: ItemEditorSaveData) => { itemSaveMutation.mutate(data); }; const handleItemCancel = () => { setItemEditor(null); setItemError(""); }; const handleSubmitAction = async () => { if (!reimbursementId) { return; } try { setActionError(""); if (detail.invoices.length < 1) { setActionError(missingInvoiceSubmitMessage); toast.error(missingInvoiceSubmitMessage); return; } const precheck = await precheckReimbursement(reimbursementId); const message = buildPrecheckDialogMessage(precheck); if (precheck.decision === "block") { setActionError(message); toast.error("提交前校验未通过"); return; } if (precheck.decision === "warn" || precheck.decision === "manual_review") { setSubmitConfirmPrecheck(precheck); return; } mutation.mutate({ action: "submit" }); } catch (error) { if (isApiError(error)) { const message = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; setActionError(message); toast.error(message); return; } if (error instanceof Error) { setActionError(error.message); toast.error(error.message); return; } setActionError("提交前校验失败,请稍后重试"); toast.error("提交前校验失败,请稍后重试"); } }; const handleInvoiceDialogOpenChange = (open: boolean) => { setIsInvoiceDialogOpen(open); if (!open) { setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoices({}); setSelectedInvoiceFiles([]); setInvoiceActionError(""); setPendingOcrInvoiceIds([]); } }; const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => { setSelectedInvoices((previous) => { if (!checked) { const next = { ...previous }; delete next[invoiceId]; return next; } return { ...previous, [invoiceId]: previous[invoiceId] ?? "", }; }); }; const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => { setSelectedInvoices((previous) => ({ ...previous, [invoiceId]: linkedItemId, })); }; const handleBindSelectedInvoices = () => { if (!selectedInvoiceEntries.length) { return; } bindInvoicesMutation.mutate( selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({ invoiceId, linkedItemId: linkedItemId || undefined, })), ); }; const resolveLinkedItemLabel = (linkedItemId: string | null) => { if (!linkedItemId) { return "整张报销单"; } const linkedItem = detail.items.find((item) => item.id === linkedItemId); if (!linkedItem) { return linkedItemId; } return `${linkedItem.expenseType?.name ?? linkedItem.expenseTypeId} · ${linkedItem.description ?? "未填写说明"}`; }; return ( 报销日期:{detail.reimbursementDate ?? "-"} 报销人:{detail.owner?.name ?? "-"} 提交人:{detail.submitter?.name ?? "-"} 金额:{detail.totalAmount ?? "-"} )} actions={(
)} />
当前状态
关联项目
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
明细数
{detail.items.length}
关联票据
{detail.invoices.length}
状态:{detail.status}
报销ID:{detail.id}
报销日期:{detail.reimbursementDate ?? "-"}
金额:{detail.totalAmount ?? "-"}
提交人:{detail.submitter?.name ?? "-"}
报销人:{detail.owner?.name ?? "-"}
说明:{detail.description || "-"}
{detail.linkedTravelRequest ? (

关联差旅申请:{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}

{detail.linkedTravelRequest.purpose || "未填写出差目的"}

查看申请
日期范围
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
预估金额
¥{detail.linkedTravelRequest.estimatedAmount}
申请编号
{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
) : null}

关联项目

报销可选挂到项目,用于项目执行面板汇总。

{detail.project ? ( 查看项目 ) : null}

报销明细

明细变更会自动重算总金额。

{canEditItems ? ( ) : (

当前状态不可编辑明细

)}
{itemError ? (

{itemError}

) : null} {reimbursementPrecheckQuery.isLoading && canEditItems ? (

正在刷新费用标准校验结果...

) : null}
费用类型发生日期金额说明操作 {detail.items.length ? ( detail.items.map((item) => ( check.itemId === item.id) ? "bg-red-50/70" : warnStandardChecks.some((check) => check.itemId === item.id) ? "bg-amber-50/70" : "" } >
{item.expenseType?.name ?? item.expenseTypeId}
{item.expenseType?.code ?? "-"}
{item.expenseDate ?? "-"}
{item.amount}
{precheckStandardChecks .filter((check) => check.itemId === item.id) .map((check) => (
标准 ¥{check.maxAmount}/{check.perUnit} {check.exceeded ? `,超出 ¥${check.exceededAmount}` : ""}
))}
{item.description ?? "-"}
{precheckStandardChecks .filter((check) => check.itemId === item.id && check.exceeded) .map((check) => (
{check.standardName}:当前金额超出费用标准。
))}
{canEditItems ? (
) : null}
)) ) : ( 暂无明细 )}
{itemEditor ? ( ) : null}

关联票据

可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。

{canOpenInvoiceBindDialog ? ( ) : canEditItems ? (

当前账号不可在此页绑定发票

) : null}
{invoiceActionError ? (

{invoiceActionError}

) : null} {(detail.invoices ?? []).length ? (
{detail.invoices.map((item) => (
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
原始文件:{item.originalName || item.id}
绑定范围:{resolveLinkedItemLabel(item.linkedItemId)}
{item.status} {canUnbindInvoices ? ( ) : null}
))}
) : (

暂无关联票据

)}
{budgetHint ? (

预算提示

预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount} ({budgetHint.nextUsagePercent.toFixed(2)}%)
控制模式:{budgetHint.controlMode}
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (

当前金额将超出预算上限,提交时会被阻断(block 模式)。

) : budgetHint.exceedsThreshold ? (

当前金额已达到预算预警阈值,提交后会记录预算预警审计。

) : (

当前预算充足,可继续提交。

)}
) : null} {canManageIntegration ? (
} > {!isPaidReimbursement ? (

未满足导出条件,仅已付款报销可导出到 ERP。

) : null} {erpPreviewQuery.isLoading ?

正在加载 ERP 凭证预览...

: null} {erpPreviewQuery.isError ? (

{isApiError(erpPreviewQuery.error) ? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage( erpPreviewQuery.error.code, erpPreviewQuery.error.details, erpPreviewQuery.error.message, )}` : "加载 ERP 凭证预览失败"}

) : null}
同步状态
{erpSyncStatus}
最后同步
{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}
Outbox
{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}
外部 ID
{erpPreviewQuery.data?.exportState.externalId ?? "-"}
{erpPreviewQuery.data?.exportState.lastError ? (
最近错误:{erpPreviewQuery.data.exportState.lastError}
) : null}
) : null}
报销编号:{detail.formNumber ?? detail.id}
报销人:{detail.owner?.name ?? "-"}
提交人:{detail.submitter?.name ?? "-"}
当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}
明细数:{detail.items.length}
票据数:{detail.invoices.length}
关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
{detail.status === "reviewing" && detail.workflowProgress ? (
{detail.workflowProgress.steps.map((step, index) => (
第 {index + 1} 步 · {step.name} {step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
))}
) : null} 0} submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""} submitNotice={submitInvoiceNotice} isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} onPay={(payload) => mutation.mutate({ action: "pay", payload })} />
添加发票 选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。
setInvoiceSearchInput(event.target.value)} />
{invoiceActionError ? (

{invoiceActionError}

) : null}
{bindableInvoicesQuery.isLoading ? (

正在加载可绑定发票...

) : bindableInvoicesQuery.isError ? (

{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可绑定发票失败")}

) : (bindableInvoicesQuery.data?.items ?? []).length ? ( bindableInvoicesQuery.data?.items.map((item) => { const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id); return ( ); }) ) : (
当前没有可绑定发票,可直接在下方上传新发票。
)}
已选 {selectedInvoiceEntries.length} 张发票
或者上传新发票

上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。

setSelectedInvoiceFiles(Array.from(event.target.files ?? []))} />
{selectedInvoiceFiles.length ? (
{selectedInvoiceFiles.slice(0, 5).map((file) => (
{file.name} · {Math.ceil(file.size / 1024)} KB
))} {selectedInvoiceFiles.length > 5 ?
另有 {selectedInvoiceFiles.length - 5} 个文件
: null}
) : null} {pendingOcrInvoiceIds.length ? (

{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别中,系统会在可绑定后自动刷新列表。

) : null}
ERP 凭证预览 展示当前报销单将发送到 ERP 的凭证切片与最近一次导出状态。 {erpPreviewQuery.data ? (

凭证切片

类型:{erpPreviewQuery.data.journal.journalType}
单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}
金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}
法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}
成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}
付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}
付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}

导出状态

同步状态:{erpSyncStatus}
外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}
Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}
队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}
最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}
最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}
) : (

暂无可预览的 ERP 凭证数据。

)}
{ if (!open) { setSubmitConfirmPrecheck(null); } }} > 确认提交报销单? 以下校验项不会阻止提交,但会作为预警或复核信息进入审批流程。 {submitConfirmPrecheck ? (
{buildPrecheckDialogMessage(submitConfirmPrecheck)}
{submitConfirmPrecheck.standardChecks.some((item) => item.exceeded && item.controlMode === "warn") ? (

超出费用标准的明细

{submitConfirmPrecheck.standardChecks .filter((item) => item.exceeded && item.controlMode === "warn") .map((item: ExpenseStandardCheck) => (
{item.expenseTypeName}
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit} / 超出 ¥{item.exceededAmount}
{item.standardName}
))}
) : null}
) : null}
{ if (!open) setConfirmDeleteItemId(""); }} title="确认删除该明细?" variant="destructive" confirmLabel="删除" onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }} />
); }