From 03f384fdfe066a1092bbecfce739ea3a2b634b92 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 28 Apr 2026 21:14:00 +0800 Subject: [PATCH] =?UTF-8?q?R73-A3=20=E4=BC=98=E5=8C=96=E8=BD=BB=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=20OCR=20=E5=89=8D=E7=AB=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/errorMessages.ts | 10 +++ client/src/components/ui/dialog.tsx | 3 +- client/src/lib/invoiceOcr.ts | 48 +++++++++++ client/src/lib/testIds.ts | 7 ++ client/src/pages/InvoicePoolPage.tsx | 42 +++++++++- client/src/pages/ReimbursementDetailPage.tsx | 45 +++++++++-- .../pages/invoice-pool/InvoiceDetailPanel.tsx | 81 ++++++++++++++++++- .../pages/invoice-pool/InvoiceListCard.tsx | 3 +- 8 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 client/src/lib/invoiceOcr.ts diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 9bf59074..dbe4ca6b 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -1,3 +1,5 @@ +import { formatInvoiceOcrFieldLabels } from "../lib/invoiceOcr"; + function readStringList(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -189,6 +191,14 @@ export function mapApiErrorMessage( return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。"; } + if (details?.ocr === "missing_required_fields") { + const labels = formatInvoiceOcrFieldLabels(readStringList(details?.missingFields)); + if (labels.length) { + return `票据仍缺少关键字段:${labels.join("、")},补齐后才能标记为可用。`; + } + return "票据仍缺少关键字段,补齐后才能标记为可用。"; + } + if (details?.verification === "disabled") { return "当前环境未开启发票验真。"; diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx index 8b1408f1..da258dee 100644 --- a/client/src/components/ui/dialog.tsx +++ b/client/src/components/ui/dialog.tsx @@ -30,7 +30,7 @@ export const DialogContent = React.forwardRef< = { + invoiceCode: "发票代码", + invoiceNumber: "发票号码", + invoiceDate: "开票日期", + totalAmount: "总金额", + sellerName: "销售方", +}; + +export function parseMissingInvoiceOcrFields(rawError: string | null | undefined) { + if (typeof rawError !== "string") { + return []; + } + + const matched = rawError.match(/^missing_required_fields:(.+)$/); + if (!matched?.[1]) { + return []; + } + + return matched[1] + .split(",") + .map((field) => field.trim()) + .filter(Boolean); +} + +export function formatInvoiceOcrFieldLabels(fields: string[]) { + return fields.map((field) => OCR_FIELD_LABELS[field] ?? field); +} + +export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) { + const missingFields = parseMissingInvoiceOcrFields(rawError); + if (missingFields.length) { + return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`; + } + + if (rawError === "duplicate_invoice_code_number") { + return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。"; + } + + if (rawError === "mock_ocr_failed") { + return "OCR 识别失败,请重试或人工补齐关键信息。"; + } + + if (!rawError) { + return "OCR 识别失败,请稍后重试。"; + } + + return rawError; +} diff --git a/client/src/lib/testIds.ts b/client/src/lib/testIds.ts index 9c169faa..2b23de22 100644 --- a/client/src/lib/testIds.ts +++ b/client/src/lib/testIds.ts @@ -41,6 +41,13 @@ export const TEST_IDS = { erpExport: "reimbursement-erp-export", overview: "reimbursement-overview", workflowProgress: "reimbursement-workflow-progress", + invoices: "reimbursement-invoices", + invoiceAdd: "reimbursement-invoice-add", + invoiceDialog: "reimbursement-invoice-dialog", + invoiceUploadFile: "reimbursement-invoice-upload-file", + invoiceUploadSubmit: "reimbursement-invoice-upload-submit", + invoicePendingHint: "reimbursement-invoice-pending-hint", + invoiceActionError: "reimbursement-invoice-action-error", }, weeklyReport: { save: "weekly-report-save", diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index 016b0a73..a34424ed 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; @@ -26,6 +26,7 @@ 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 { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; @@ -64,9 +65,14 @@ export function InvoicePoolPage() { 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 --- @@ -140,9 +146,29 @@ export function InvoicePoolPage() { 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("票据 OCR 已完成,可继续绑定或验真"); + } else if (currentStatus === "ocr_failed") { + toast.error(`票据 OCR 未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`); + } + } + + previousDetailStatusRef.current = currentStatus; + }, [detailQuery.data?.ocrLastError, detailQuery.data?.status, queryClient]); + useEffect(() => { if (!canManageInvoice && ownerScope === "all") { setOwnerScope("own"); @@ -188,7 +214,7 @@ export function InvoicePoolPage() { setDetailInvoiceId(data.invoicePoolId); setSelectedFile(null); setError(""); - toast.success("上传成功"); + toast.success(data.status === "pending_ocr" ? "上传成功,OCR 识别中" : "上传成功,可直接处理"); }, onError: (err) => handleError(err, "上传失败,请稍后重试"), }); @@ -246,6 +272,10 @@ export function InvoicePoolPage() { 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, }); }, @@ -461,6 +491,14 @@ export function InvoicePoolPage() { setEditInvoiceNumber={setEditInvoiceNumber} editInvoiceDate={editInvoiceDate} setEditInvoiceDate={setEditInvoiceDate} + editSellerName={editSellerName} + setEditSellerName={setEditSellerName} + editBuyerName={editBuyerName} + setEditBuyerName={setEditBuyerName} + editAmount={editAmount} + setEditAmount={setEditAmount} + editTaxAmount={editTaxAmount} + setEditTaxAmount={setEditTaxAmount} editTotalAmount={editTotalAmount} setEditTotalAmount={setEditTotalAmount} confirmAction={confirmAction} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 7416bafc..846241da 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -48,6 +48,7 @@ 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 { @@ -437,6 +438,12 @@ export function ReimbursementDetailPage() { if (!failedResults.length) { setInvoiceActionError(""); + setIsInvoiceDialogOpen(false); + setInvoiceSearchInput(""); + setInvoiceSearch(""); + setSelectedInvoices({}); + setSelectedInvoiceFile(null); + setPendingOcrInvoiceId(null); toast.success(`已绑定 ${succeededIds.length} 张发票`); return; } @@ -482,13 +489,27 @@ export function ReimbursementDetailPage() { if (!ownerEmployeeId) { throw new Error("missing ownerEmployeeId"); } - return uploadInvoice(file, undefined, ownerEmployeeId); + return uploadInvoice(file, "vat", ownerEmployeeId); }, onSuccess: async (result) => { setInvoiceActionError(""); setSelectedInvoiceFile(null); setInvoiceSearchInput(""); setInvoiceSearch(""); + + if (result.status === "available") { + const refreshed = await bindableInvoicesQuery.refetch(); + if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) { + setSelectedInvoices((previous) => ({ + ...previous, + [result.invoicePoolId]: previous[result.invoicePoolId] ?? "", + })); + } + setPendingOcrInvoiceId(null); + toast.success("发票上传成功,可直接绑定"); + return; + } + setPendingOcrInvoiceId(result.invoicePoolId); toast.success("发票上传成功,OCR 识别中"); @@ -512,7 +533,9 @@ export function ReimbursementDetailPage() { if (invoice.status === "ocr_failed") { setPendingOcrInvoiceId(null); - toast.error("发票 OCR 识别失败,请前往票据中心处理"); + const message = `发票 OCR 未完成:${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}可前往票据中心继续修正或重试。`; + setInvoiceActionError(message); + toast.error("发票 OCR 识别失败,请在当前提示或票据中心继续处理"); return; } } catch { @@ -522,6 +545,7 @@ export function ReimbursementDetailPage() { setPendingOcrInvoiceId(null); await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }); + setInvoiceActionError("发票仍在 OCR 处理中,请稍后刷新列表;若长时间未完成,可前往票据中心查看详情。"); toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表"); }, onError: (error) => { @@ -1002,7 +1026,7 @@ export function ReimbursementDetailPage() { ) : null} -
+

关联票据

@@ -1010,6 +1034,7 @@ export function ReimbursementDetailPage() {
{canOpenInvoiceBindDialog ? (
{invoiceActionError ? ( -

+

{invoiceActionError}

) : null} @@ -1231,7 +1259,10 @@ export function ReimbursementDetailPage() {
- + 添加发票 选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。 @@ -1350,11 +1381,13 @@ export function ReimbursementDetailPage() { setSelectedInvoiceFile(event.target.files?.[0] ?? null)} />
{pendingOcrInvoiceId ? ( -

+

发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。

) : null} diff --git a/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx b/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx index 8d25b453..8c295997 100644 --- a/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx +++ b/client/src/pages/invoice-pool/InvoiceDetailPanel.tsx @@ -2,6 +2,7 @@ import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; import type { InvoicePoolDetail, InvoiceVerificationRecord } from "../../api/invoicePool"; import { ConfirmDialog } from "../../components/common/ConfirmDialog"; import { Button } from "../../components/ui/button"; +import { formatInvoiceOcrErrorMessage } from "../../lib/invoiceOcr"; export type InvoiceDetailPanelProps = { detail: InvoicePoolDetail | undefined; @@ -30,6 +31,14 @@ export type InvoiceDetailPanelProps = { setEditInvoiceNumber: (value: string) => void; editInvoiceDate: string; setEditInvoiceDate: (value: string) => void; + editSellerName: string; + setEditSellerName: (value: string) => void; + editBuyerName: string; + setEditBuyerName: (value: string) => void; + editAmount: string; + setEditAmount: (value: string) => void; + editTaxAmount: string; + setEditTaxAmount: (value: string) => void; editTotalAmount: string; setEditTotalAmount: (value: string) => void; confirmAction: { type: string; invoiceId: string } | null; @@ -62,6 +71,14 @@ export function InvoiceDetailPanel({ setEditInvoiceNumber, editInvoiceDate, setEditInvoiceDate, + editSellerName, + setEditSellerName, + editBuyerName, + setEditBuyerName, + editAmount, + setEditAmount, + editTaxAmount, + setEditTaxAmount, editTotalAmount, setEditTotalAmount, confirmAction, @@ -80,6 +97,8 @@ export function InvoiceDetailPanel({ 发票号: {detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"} +
销售方:{detail.sellerName || "-"}
+
购买方:{detail.buyerName || "-"}
总金额: {detail.totalAmount ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(detail.totalAmount)) : "-"} @@ -101,7 +120,7 @@ export function InvoiceDetailPanel({
) : null} - {detail.ocrLastError ?
OCR错误:{detail.ocrLastError}
: null} + {detail.ocrLastError ?
OCR错误:{formatInvoiceOcrErrorMessage(detail.ocrLastError)}
: null} {detail.remarks ?
备注:{detail.remarks}
: null}
判重例外: @@ -109,6 +128,18 @@ export function InvoiceDetailPanel({
+ {detail.status === "pending_ocr" ? ( +
+ OCR 正在处理中,当前页面会自动轮询;完成后可直接继续绑定、验真或查看详情。 +
+ ) : null} + + {detail.status === "ocr_failed" ? ( +
+ {formatInvoiceOcrErrorMessage(detail.ocrLastError)} 可在下方直接重试 OCR,或补齐关键字段后标记为可用。 +
+ ) : null} +
{canVerifyInvoice ? (
+
+ + setEditSellerName(event.target.value)} + value={editSellerName} + /> +
+
+ + setEditBuyerName(event.target.value)} + value={editBuyerName} + /> +
+
+ + setEditAmount(event.target.value)} + value={editAmount} + /> +
+
+ + setEditTaxAmount(event.target.value)} + value={editTaxAmount} + /> +