import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchMyDelegations } from "../api/delegations"; import { exportInvoicePool } from "../api/exports"; import { archiveInvoice, bindInvoice, fetchInvoiceVerifications, fetchInvoicePoolDetail, fetchInvoicePoolList, fetchInvoiceSignedUrl, overrideInvoiceDuplicate, patchInvoice, retryInvoiceVerification, retryInvoiceOcr, triggerInvoiceVerification, unbindInvoice, uploadInvoice, type InvoicePoolStatus, type UploadInvoiceResponse, } 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"; import { downloadBlob } from "../lib/download"; import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; 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, type InvoiceBindMode } from "./invoice-pool/InvoiceBindCard"; import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel"; const PAGE_SIZE = 20; type UploadBatchProgress = { completed: number; total: number; currentFileName: string; }; type UploadBatchResult = { successes: Array<{ file: File; data: UploadInvoiceResponse }>; failures: Array<{ file: File; message: string }>; }; export function InvoicePoolPage() { const queryClient = useQueryClient(); const { permissions, me } = useAuth(); const canManageInvoice = hasPermission(permissions, "invoice.manage"); const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate"); 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(""); const [ownerScope, setOwnerScope] = useState<"own" | "all">("own"); const [page, setPage] = useState(1); const [selectedFiles, setSelectedFiles] = useState([]); const [uploadProgress, setUploadProgress] = 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); const [editRemarks, setEditRemarks] = useState(""); const [editInvoiceCode, setEditInvoiceCode] = useState(""); const [editInvoiceNumber, setEditInvoiceNumber] = useState(""); const [editInvoiceDate, setEditInvoiceDate] = useState(""); const [editSellerName, setEditSellerName] = useState(""); const [editBuyerName, setEditBuyerName] = useState(""); const [editAmount, setEditAmount] = useState(""); const [editTaxAmount, setEditTaxAmount] = useState(""); const [editTotalAmount, setEditTotalAmount] = useState(""); const [error, setError] = useState(""); const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null); const previousDetailStatusRef = useRef(null); // --- Queries --- const listQuery = useQuery({ queryKey: ["invoice-pool", "list", status, ownerScope, page], queryFn: () => fetchInvoicePoolList({ status: status || undefined, ownerScope, page, pageSize: PAGE_SIZE, }), refetchInterval: (query) => { const items = query.state.data?.items ?? []; return items.some((item) => item.status === "pending_ocr") ? 3_000 : false; }, }); const reimbursementQuery = useQuery({ queryKey: ["reimbursements", "for-bind"], 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"], queryFn: () => fetchMyDelegations("active"), }); const detailQuery = useQuery({ enabled: Boolean(detailInvoiceId), queryKey: ["invoice-pool", "detail", detailInvoiceId], queryFn: () => fetchInvoicePoolDetail(detailInvoiceId), refetchInterval: (query) => { const statusValue = query.state.data?.status; if (statusValue === "pending_ocr") { return 3_000; } return false; }, }); const verificationQuery = useQuery({ enabled: Boolean(detailInvoiceId) && canVerifyInvoice, queryKey: ["invoice-pool", "verification", detailInvoiceId], queryFn: () => fetchInvoiceVerifications(detailInvoiceId, 20), refetchInterval: (query) => { const items = query.state.data?.items ?? []; const hasPending = items.some((item) => item.status === "pending"); return hasPending ? 3_000 : false; }, }); // --- Side effects --- useEffect(() => { if (!detailQuery.data || editOpen) { return; } setEditSetAvailable(false); setEditRemarks(detailQuery.data.remarks ?? ""); setEditInvoiceCode(detailQuery.data.invoiceCode ?? ""); setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? ""); setEditInvoiceDate(detailQuery.data.invoiceDate ?? ""); setEditSellerName(detailQuery.data.sellerName ?? ""); setEditBuyerName(detailQuery.data.buyerName ?? ""); setEditAmount(detailQuery.data.amount ?? ""); setEditTaxAmount(detailQuery.data.taxAmount ?? ""); setEditTotalAmount(detailQuery.data.totalAmount ?? ""); }, [detailQuery.data, editOpen]); useEffect(() => { const currentStatus = detailQuery.data?.status ?? null; const previousStatus = previousDetailStatusRef.current; if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") { void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); if (currentStatus === "available") { toast.success("票据识别已完成,可继续添加到业务单据或财务复核"); } else if (currentStatus === "ocr_failed") { toast.error(`票据识别未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`); } } previousDetailStatusRef.current = currentStatus; }, [detailQuery.data?.ocrLastError, detailQuery.data?.status, queryClient]); useEffect(() => { if (!canManageInvoice && ownerScope === "all") { setOwnerScope("own"); } }, [canManageInvoice, ownerScope]); useEffect(() => { if (canManageTravelPayments) { return; } setBindMode("reimbursement"); setSelectedTravelRequestId(""); setSelectedTravelOrderId(""); }, [canManageTravelPayments]); useEffect(() => { if (!detailInvoiceId && listQuery.data?.items?.length) { setDetailInvoiceId(listQuery.data.items[0].id); } }, [detailInvoiceId, listQuery.data?.items]); // --- Helper for error handling --- const toUserError = (err: unknown, fallback: string) => { return isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : fallback; }; const handleError = (err: unknown, fallback: string) => { const msg = toUserError(err, fallback); setError(msg); toast.error(msg); }; // --- Mutations --- const uploadMutation = useMutation({ mutationFn: async () => { if (!selectedFiles.length) { throw new Error("请选择文件"); } const files = [...selectedFiles]; const result: UploadBatchResult = { successes: [], failures: [] }; setUploadProgress({ completed: 0, total: files.length, currentFileName: files[0]?.name ?? "" }); for (const [index, file] of files.entries()) { setUploadProgress({ completed: index, total: files.length, currentFileName: file.name }); try { const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined); if (data.uploadAction === "blocked" || !data.invoicePoolId) { result.failures.push({ file, message: data.unavailableMessage ?? "该发票不可添加" }); } else { result.successes.push({ file, data }); } } catch (err) { result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") }); } setUploadProgress({ completed: index + 1, total: files.length, currentFileName: file.name }); } return result; }, onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); const lastSuccess = result.successes.at(-1); if (lastSuccess?.data.invoicePoolId) { setDetailInvoiceId(lastSuccess.data.invoicePoolId); } setSelectedFiles(result.failures.map((item) => item.file)); setUploadProgress(null); if (result.failures.length) { const message = result.failures.map((item) => `${item.file.name}: ${item.message}`).join(";"); setError(message); if (result.successes.length) { toast.error(`已上传 ${result.successes.length} 个,${result.failures.length} 个失败`); } else { toast.error("上传失败,请检查文件或稍后重试"); } return; } setError(""); const hasPending = result.successes.some((item) => item.data.status === "pending_ocr"); toast.success(hasPending ? `已上传 ${result.successes.length} 个,识别中` : `已上传 ${result.successes.length} 个,可直接处理`); }, onSettled: () => setUploadProgress(null), onError: (err) => handleError(err, "上传失败,请稍后重试"), }); const bindMutation = useMutation({ mutationFn: async () => { if (!selectedInvoiceId) { throw new Error("请选择票据"); } 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, "绑定失败,请稍后重试"), }); const signedUrlMutation = useMutation({ mutationFn: async (invoiceId: string) => fetchInvoiceSignedUrl(invoiceId), onSuccess: (data) => { window.open(data.signedUrl, "_blank", "noopener,noreferrer"); }, onError: (err) => { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); } else { setError("预览链接获取失败"); } }, }); const patchMutation = useMutation({ mutationFn: async () => { if (!detailInvoiceId) { throw new Error("请选择一条票据"); } return patchInvoice(detailInvoiceId, { ...(editSetAvailable ? { status: "available" } : {}), remarks: editRemarks.trim() ? editRemarks.trim() : null, invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null, invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null, invoiceDate: editInvoiceDate ? editInvoiceDate : null, sellerName: editSellerName.trim() ? editSellerName.trim() : null, buyerName: editBuyerName.trim() ? editBuyerName.trim() : null, amount: editAmount.trim() ? editAmount.trim() : null, taxAmount: editTaxAmount.trim() ? editTaxAmount.trim() : null, totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null, }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setEditOpen(false); setError(""); toast.success("修正已保存"); }, onError: (err) => handleError(err, "保存失败,请稍后重试"), }); const archiveMutation = useMutation({ mutationFn: async (invoiceId: string) => archiveInvoice(invoiceId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); toast.success("已归档"); }, onError: (err) => handleError(err, "归档失败,请稍后重试"), }); const unbindMutation = useMutation({ mutationFn: async (invoiceId: string) => unbindInvoice(invoiceId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); toast.success("已解绑"); }, onError: (err) => handleError(err, "解绑失败,请稍后重试"), }); const retryOcrMutation = useMutation({ mutationFn: async (invoiceId: string) => retryInvoiceOcr(invoiceId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); toast.success("已开始重新识别"); }, onError: (err) => handleError(err, "重新识别失败,请稍后再试"), }); const triggerVerificationMutation = useMutation({ mutationFn: async (invoiceId: string) => triggerInvoiceVerification(invoiceId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), ]); setError(""); toast.success("发票核验已提交"); }, onError: (err) => handleError(err, "提交发票核验失败,请稍后重试"), }); const retryVerificationMutation = useMutation({ mutationFn: async (invoiceId: string) => retryInvoiceVerification(invoiceId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), ]); setError(""); toast.success("发票核验已重新提交"); }, onError: (err) => handleError(err, "重新提交发票核验失败,请稍后重试"), }); const overrideDuplicateMutation = useMutation({ mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) => overrideInvoiceDuplicate(params.invoiceId, { refFormNumber: params.refFormNumber, reason: params.reason, }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); toast.success("判重例外已生效"); }, onError: (err) => handleError(err, "判重例外失败,请稍后重试"), }); const exportMutation = useMutation({ mutationFn: (format: "csv" | "xlsx") => exportInvoicePool({ format, status: status || undefined, ownerScope, }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`); setError(""); toast.success("导出完成"); }, onError: (err) => handleError(err, "导出失败,请稍后重试"), }); // --- Derived state --- const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const detail = detailQuery.data; const invoiceStats = useMemo(() => { const items = listQuery.data?.items ?? []; return { total: listQuery.data?.total ?? 0, pending: items.filter((item) => item.status === "pending_ocr").length, failed: items.filter((item) => item.status === "ocr_failed").length, available: items.filter((item) => item.status === "available").length, bound: items.filter((item) => item.status === "bound").length, }; }, [listQuery.data?.items, listQuery.data?.total]); const canEditDetail = Boolean( detail && (canManageInvoice || detail.ownerEmployeeId === me?.id || detail.uploadedByEmployeeId === me?.id), ); // --- Render --- return ( exportMutation.mutate("csv")}> 导出 CSV exportMutation.mutate("xlsx")}> 导出 Excel } /> {error ? (

{error}

) : null}
票据总数
{invoiceStats.total}
识别中
{invoiceStats.pending}
识别失败
{invoiceStats.failed}
可绑定
{invoiceStats.available}
已绑定
{invoiceStats.bound}
uploadMutation.mutate()} showProxyOwnerSelector={showProxyOwnerSelector} proxyDelegationsQuery={proxyDelegationsQuery} uploadOwnerEmployeeId={uploadOwnerEmployeeId} setUploadOwnerEmployeeId={setUploadOwnerEmployeeId} />
); }