From 23dfa32ee0926e99bd6a88b3c09d1ac622dc074a Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 8 May 2026 01:04:43 +0800 Subject: [PATCH] =?UTF-8?q?R109/A2,A3,A4=20=E6=94=B9=E8=BF=9B=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E6=8A=A5=E9=94=80=E5=92=8C=E5=8F=91=E7=A5=A8=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/invoicePool.ts | 8 +- client/src/lib/uploadTimeout.ts | 18 + client/src/pages/MobileCapturePage.tsx | 5 +- .../pages/MobileReimbursementDetailPage.tsx | 1062 +++++++++++++++++ client/src/pages/MobileReimbursementsPage.tsx | 4 +- client/src/router/index.tsx | 10 + .../lazy/MobileReimbursementDetailPage.ts | 1 + client/src/router/lazy/index.ts | 1 + 8 files changed, 1104 insertions(+), 5 deletions(-) create mode 100644 client/src/lib/uploadTimeout.ts create mode 100644 client/src/pages/MobileReimbursementDetailPage.tsx create mode 100644 client/src/router/lazy/MobileReimbursementDetailPage.ts diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index 96412913..71d5aeb8 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -170,7 +170,12 @@ export async function fetchBindableInvoices(query: { ); } -export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeId?: string) { +export async function uploadInvoice( + file: File, + ocrType?: string, + ownerEmployeeId?: string, + options: { signal?: AbortSignal } = {}, +) { const formData = new FormData(); formData.append("file", file); if (ocrType) { @@ -185,6 +190,7 @@ export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeI { method: "POST", body: formData, + signal: options.signal, }, ); } diff --git a/client/src/lib/uploadTimeout.ts b/client/src/lib/uploadTimeout.ts new file mode 100644 index 00000000..2709c5af --- /dev/null +++ b/client/src/lib/uploadTimeout.ts @@ -0,0 +1,18 @@ +export async function withUploadTimeout( + operation: (signal: AbortSignal) => Promise, + timeoutMs = 45_000, +) { + const controller = new AbortController(); + const timerId = window.setTimeout(() => controller.abort(), timeoutMs); + + try { + return await operation(controller.signal); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("上传超时,请检查网络后重试"); + } + throw error; + } finally { + window.clearTimeout(timerId); + } +} diff --git a/client/src/pages/MobileCapturePage.tsx b/client/src/pages/MobileCapturePage.tsx index 9c5bd0a7..fc31e2e3 100644 --- a/client/src/pages/MobileCapturePage.tsx +++ b/client/src/pages/MobileCapturePage.tsx @@ -8,6 +8,7 @@ import { PageHeader } from "../components/common/PageHeader"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; import { Spinner } from "../components/ui/spinner"; +import { withUploadTimeout } from "../lib/uploadTimeout"; import { enqueueInvoiceUpload, listQueuedInvoiceUploads, @@ -58,7 +59,7 @@ export function MobileCapturePage() { for (const [index, file] of files.entries()) { try { - await uploadInvoice(file, "vat"); + await withUploadTimeout((signal) => uploadInvoice(file, "vat", undefined, { signal })); result.succeeded += 1; } catch (err) { if (isLikelyNetworkError(err)) { @@ -118,7 +119,7 @@ export function MobileCapturePage() { for (const item of items) { try { const file = queuedItemToFile(item); - await uploadInvoice(file, "vat"); + await withUploadTimeout((signal) => uploadInvoice(file, "vat", undefined, { signal })); await removeQueuedInvoiceUpload(item.id); succeeded += 1; } catch { diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx new file mode 100644 index 00000000..7b7e9652 --- /dev/null +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -0,0 +1,1062 @@ +import { useEffect, useRef, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { fetchExpenseTypes } from "../api/expenseTypes"; +import { + bindInvoice, + fetchBindableInvoices, + unbindInvoice, + uploadInvoice, + type BindableInvoiceSummary, +} from "../api/invoicePool"; +import { + createReimbursementItem, + deleteReimbursement, + deleteReimbursementItem, + fetchReimbursementDetail, + precheckReimbursement, + submitReimbursement, + updateReimbursementItem, + type ReimbursementItem, + type ReimbursementStatus, +} from "../api/reimbursements"; +import type { PrecheckResponse } from "../api/feeControl"; +import { useAuth } from "../auth/AuthContext"; +import { hasPermission } from "../auth/permissions"; +import { ConfirmDialog } from "../components/common/ConfirmDialog"; +import { PageScaffold } from "../components/patterns/PageScaffold"; +import { Button } from "../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; +import { Input } from "../components/ui/input"; +import { Textarea } from "../components/ui/textarea"; +import { buildPrecheckDialogMessage } from "../lib/precheck"; +import { resolveErrorMessage } from "../lib/resolveErrorMessage"; +import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; +import { TEST_IDS } from "../lib/testIds"; +import { withUploadTimeout } from "../lib/uploadTimeout"; +import { cn } from "../lib/utils"; + +type ItemFormState = { + mode: "create" | "edit"; + itemId?: string; + expenseTypeId: string; + expenseDate: string; + amount: string; + description: string; +}; + +type SelectedInvoiceMap = Record; +type InvoicePanel = "upload" | "library"; + +const STATUS_LABEL: Record = { + draft: "草稿", + reviewing: "审批中", + approved: "已通过", + paid: "已支付", + rejected: "已驳回", + cancelled: "已取消", +}; + +const STATUS_TONE: Record = { + draft: "bg-zinc-100 text-zinc-700", + reviewing: "bg-amber-50 text-amber-700", + approved: "bg-emerald-50 text-emerald-700", + paid: "bg-blue-50 text-blue-700", + rejected: "bg-red-50 text-red-700", + cancelled: "bg-zinc-100 text-zinc-500", +}; + +const INVOICE_STATUS_LABEL: Record = { + pending_ocr: "识别中", + ocr_failed: "识别失败", + available: "可用", + bound: "已绑定", + archived: "已归档", +}; + +function todayDate() { + return new Date().toISOString().slice(0, 10); +} + +function formatCurrency(value: string | null | undefined) { + const numberValue = Number(value ?? 0); + if (!Number.isFinite(numberValue)) { + return "¥0.00"; + } + return new Intl.NumberFormat("zh-CN", { + style: "currency", + currency: "CNY", + }).format(numberValue); +} + +function formatDate(value: string | null | undefined) { + return value || "未填写"; +} + +function formatInvoiceTitle(invoice: { + invoiceCode?: string | null; + invoiceNumber?: string | null; + sellerName?: string | null; + originalName?: string | null; +}) { + if (invoice.invoiceCode || invoice.invoiceNumber) { + return `${invoice.invoiceCode || "-"}-${invoice.invoiceNumber || "-"}`; + } + return invoice.sellerName || invoice.originalName || "未识别票据"; +} + +function isEditableStatus(status: ReimbursementStatus | undefined) { + return status === "draft" || status === "rejected"; +} + +function itemLabel(item: ReimbursementItem) { + const typeName = item.expenseType?.name ?? "费用"; + const desc = item.description?.trim(); + return desc ? `${typeName} · ${desc}` : typeName; +} + +export function MobileReimbursementDetailPage() { + const { reimbursementId = "" } = useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { me, permissions } = useAuth(); + const fileInputRef = useRef(null); + + const [itemForm, setItemForm] = useState(null); + const [itemError, setItemError] = useState(""); + const [actionError, setActionError] = useState(""); + const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); + const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); + const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); + const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); + const [invoicePanel, setInvoicePanel] = useState("upload"); + const [invoiceSearchInput, setInvoiceSearchInput] = useState(""); + const [invoiceSearch, setInvoiceSearch] = useState(""); + const [selectedInvoices, setSelectedInvoices] = useState({}); + const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); + const [invoiceActionError, setInvoiceActionError] = useState(""); + const [invoiceActionMessage, setInvoiceActionMessage] = useState(""); + const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); + const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); + + const detailQuery = useQuery({ + enabled: Boolean(reimbursementId), + queryKey: ["reimbursements", reimbursementId], + queryFn: () => fetchReimbursementDetail(reimbursementId), + }); + + const expenseTypesQuery = useQuery({ + queryKey: ["expenseTypes", "enabled"], + queryFn: () => fetchExpenseTypes({ enabled: true }), + }); + + const detail = detailQuery.data; + const canUploadInvoice = hasPermission(permissions, "invoice.upload"); + const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); + const canViewAllExpense = hasPermission(permissions, "expense.view_all"); + const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); + const canSubmitExpense = hasPermission(permissions, "expense.submit"); + const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id)); + const canEdit = isEditableStatus(detail?.status); + const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice); + const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense)); + const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense)); + const selectedInvoiceEntries = Object.entries(selectedInvoices); + + const bindableInvoicesQuery = useQuery({ + enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog), + 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 invalidateDetail = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }), + ]); + }; + + const resolveDefaultLinkedItemId = (invoice?: BindableInvoiceSummary | null) => { + if (!detail?.items.length) { + return ""; + } + if (detail.items.length === 1) { + return detail.items[0].id; + } + + const invoiceAmount = Number(invoice?.totalAmount ?? NaN); + if (!Number.isFinite(invoiceAmount)) { + return ""; + } + + const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount); + return matchedItems.length === 1 ? matchedItems[0].id : ""; + }; + + const startCreateItem = () => { + setItemForm({ + mode: "create", + expenseTypeId: expenseTypesQuery.data?.items?.[0]?.id ?? "", + expenseDate: detail?.reimbursementDate ?? todayDate(), + amount: "", + description: "", + }); + setItemError(""); + }; + + const startEditItem = (item: ReimbursementItem) => { + setItemForm({ + mode: "edit", + itemId: item.id, + expenseTypeId: item.expenseTypeId, + expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(), + amount: item.amount, + description: item.description ?? "", + }); + setItemError(""); + }; + + const itemSaveMutation = useMutation({ + mutationFn: (form: ItemFormState) => { + if (!reimbursementId) { + throw new Error("缺少报销单 ID"); + } + + const payload = { + expenseTypeId: form.expenseTypeId, + expenseDate: form.expenseDate, + amount: form.amount, + description: form.description.trim() || undefined, + }; + + if (form.mode === "edit" && form.itemId) { + return updateReimbursementItem(reimbursementId, form.itemId, payload); + } + + return createReimbursementItem(reimbursementId, payload); + }, + onSuccess: async () => { + await invalidateDetail(); + setItemForm(null); + setItemError(""); + toast.success("明细已保存"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setItemError(message); + toast.error(message); + }, + }); + + const itemDeleteMutation = useMutation({ + mutationFn: (itemId: string) => deleteReimbursementItem(reimbursementId, itemId), + onSuccess: async () => { + await invalidateDetail(); + setConfirmDeleteItemId(""); + toast.success("明细已删除"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setItemError(message); + toast.error(message); + }, + }); + + const deleteReimbursementMutation = useMutation({ + mutationFn: () => deleteReimbursement(reimbursementId), + onSuccess: async () => { + await invalidateDetail(); + toast.success("草稿已删除"); + navigate("/mobile/reimbursements"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setActionError(message); + toast.error(message); + }, + }); + + const submitMutation = useMutation({ + mutationFn: () => submitReimbursement(reimbursementId), + onSuccess: async () => { + await invalidateDetail(); + setSubmitConfirmPrecheck(null); + setActionError(""); + toast.success("已提交审批"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setActionError(message); + toast.error(message); + }, + }); + + const bindInvoicesMutation = useMutation({ + mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => { + 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: resolveErrorMessage(error) }); + } + } + return results; + }, + onSuccess: async (results) => { + await invalidateDetail(); + await bindableInvoicesQuery.refetch(); + const failed = 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 (!failed.length) { + setInvoiceActionError(""); + setInvoiceActionMessage(`已绑定 ${succeededIds.length} 张发票`); + toast.success(`已绑定 ${succeededIds.length} 张发票`); + return; + } + + const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "绑定失败"}`).join(";"); + setInvoiceActionError(message); + toast.error("部分发票绑定失败"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setInvoiceActionError(message); + toast.error(message); + }, + }); + + const unbindInvoiceMutation = useMutation({ + mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), + onSuccess: async () => { + await invalidateDetail(); + setInvoiceActionError(""); + toast.success("发票已解绑"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setInvoiceActionError(message); + toast.error(message); + }, + }); + + const uploadInvoiceMutation = useMutation({ + mutationFn: async (files: File[]) => { + if (!detail?.ownerId) { + throw new Error("缺少报销人信息"); + } + if (!files.length) { + throw new Error("请选择发票文件"); + } + + const failures: Array<{ file: File; message: string }> = []; + const pendingIds: string[] = []; + let uploadedCount = 0; + let boundCount = 0; + + setInvoiceUploadProgress({ completed: 0, total: files.length }); + + for (const [index, file] of files.entries()) { + try { + const uploadResult = await withUploadTimeout((signal) => + uploadInvoice(file, "vat", detail.ownerId, { signal }), + ); + uploadedCount += 1; + + if (uploadResult.status === "available") { + const linkedItemId = resolveDefaultLinkedItemId(null); + try { + await bindInvoice(uploadResult.invoicePoolId, { + reimbursementId, + ...(linkedItemId ? { linkedItemId } : {}), + }); + boundCount += 1; + } catch (error) { + failures.push({ file, message: `上传成功,绑定失败:${resolveErrorMessage(error)}` }); + } + } else if (uploadResult.status === "pending_ocr") { + pendingIds.push(uploadResult.invoicePoolId); + } else if (uploadResult.status === "ocr_failed") { + failures.push({ file, message: "OCR 识别失败,请到发票池补录后再绑定" }); + } + } catch (error) { + failures.push({ file, message: resolveErrorMessage(error) }); + } + + setInvoiceUploadProgress({ completed: index + 1, total: files.length }); + } + + return { uploadedCount, boundCount, pendingIds, failures }; + }, + onSuccess: async (result) => { + await invalidateDetail(); + await bindableInvoicesQuery.refetch(); + + setPendingOcrInvoiceIds(result.pendingIds); + setSelectedInvoiceFiles(result.failures.map((item) => item.file)); + if (!result.failures.length && fileInputRef.current) { + fileInputRef.current.value = ""; + } + + const messages: string[] = []; + if (result.boundCount) { + messages.push(`${result.boundCount} 张已绑定`); + } + if (result.pendingIds.length) { + messages.push(`${result.pendingIds.length} 张识别中`); + } + if (result.failures.length) { + setInvoiceActionError(result.failures.map((item) => `${item.file.name}:${item.message}`).join(";")); + } else { + setInvoiceActionError(""); + } + setInvoiceActionMessage(messages.length ? messages.join(",") : `已上传 ${result.uploadedCount} 张`); + + if (result.failures.length) { + toast.error(`上传完成,${result.failures.length} 项需处理`); + return; + } + toast.success(messages.length ? messages.join(",") : "上传完成"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setInvoiceActionError(message); + toast.error(message); + }, + onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), + }); + + const handleSubmit = async () => { + if (!detail) { + return; + } + if (!detail.items.length) { + const message = "请先添加至少 1 条报销明细"; + setActionError(message); + toast.error(message); + return; + } + if (!detail.invoices.length) { + const message = "请先添加至少 1 张发票"; + setActionError(message); + toast.error(message); + return; + } + + try { + setActionError(""); + 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; + } + submitMutation.mutate(); + } catch (error) { + const message = resolveErrorMessage(error); + setActionError(message); + toast.error(message); + } + }; + + const closeInvoiceDialog = (open: boolean) => { + setIsInvoiceDialogOpen(open); + if (open) { + setInvoicePanel("upload"); + return; + } + setInvoiceSearchInput(""); + setInvoiceSearch(""); + setSelectedInvoices({}); + setSelectedInvoiceFiles([]); + setInvoiceActionError(""); + setInvoiceActionMessage(""); + setPendingOcrInvoiceIds([]); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => { + setSelectedInvoices((previous) => { + if (!checked) { + const next = { ...previous }; + delete next[invoice.id]; + return next; + } + return { + ...previous, + [invoice.id]: previous[invoice.id] ?? resolveDefaultLinkedItemId(invoice), + }; + }); + }; + + const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => { + setSelectedInvoices((previous) => ({ + ...previous, + [invoiceId]: linkedItemId, + })); + }; + + const resolveLinkedItemLabel = (linkedItemId: string | null) => { + if (!linkedItemId) { + return "整单"; + } + const item = detail?.items.find((candidate) => candidate.id === linkedItemId); + return item ? itemLabel(item) : "明细"; + }; + + const expenseTypes = expenseTypesQuery.data?.items ?? []; + + if (!reimbursementId) { + return ( + +
缺少报销单 ID。
+
+ ); + } + + if (detailQuery.isLoading) { + return ( + +
+
+
+ + ); + } + + if (detailQuery.isError || !detail) { + return ( + +
+ {resolveErrorMessage(detailQuery.error)} +
+ +
+ ); + } + + const displayCode = resolveReimbursementDisplayCode(detail); + + return ( + +
+ +
+
{displayCode}
+
报销详情
+
+ + {STATUS_LABEL[detail.status]} + +
+ +
+
合计金额
+
{formatCurrency(detail.totalAmount)}
+
+
+
明细
+
{detail.items.length}
+
+
+
发票
+
{detail.invoices.length}
+
+
+
日期
+
{formatDate(detail.reimbursementDate)}
+
+
+ {detail.description ?

{detail.description}

: null} +
+ +
+
+

报销明细

+ {canEdit ? ( + + ) : null} +
+ + {itemError ? ( +
{itemError}
+ ) : null} + + {itemForm ? ( +
+
+ + + +