From c26cf9b9546df466df02701d7935f15745748194 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 9 May 2026 11:48:57 +0800 Subject: [PATCH] =?UTF-8?q?H1-H10=20=E4=BC=98=E5=8C=96OCR=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E4=BD=93=E9=AA=8C=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/errorMessages.ts | 6 +- client/src/api/events.ts | 81 +++++++++++ client/src/api/invoicePool.ts | 8 ++ client/src/hooks/useInvoiceOcrTracker.ts | 125 ++++++++++++++++ client/src/lib/invoiceOcr.ts | 8 +- .../pages/MobileReimbursementDetailPage.tsx | 135 ++++++++++-------- client/src/pages/ReimbursementDetailPage.tsx | 91 ++++++------ client/tests/e2e/invoice-ocr-flow.spec.ts | 2 +- .../r111-invoice-reimbursement-guard.spec.ts | 20 ++- docs/trialrun-runbook.md | 21 ++- docs/trialrun/async-ux-audit.csv | 11 ++ docs/trialrun/real-device-public-checklist.md | 6 +- ...2026-05-09_10-47-33-ocr-realtime-ux-v2.csv | 11 ++ scripts/qa/check-pending-ocr.mjs | 78 ++++++++++ scripts/qa/check-ui-design-rules.mjs | 2 +- scripts/qa/run-live-smoke-readiness.sh | 3 + server/src/app.js | 4 + server/src/jobs/ocrJob.js | 76 ++++++++-- server/src/jobs/pgBossJobs.js | 94 +++++++++++- server/src/routes/events.js | 52 +++++++ server/src/routes/invoicePool.js | 18 +++ server/src/routes/invoicePool/read.js | 47 +----- .../src/routes/invoicePool/shared/mappers.js | 7 + .../src/routes/invoicePool/shared/selects.js | 1 + server/src/services/events.js | 104 ++++++++++++++ server/src/services/invoiceGovernance.js | 95 ++++++++++++ .../unit/invoiceGovernance.dedup.test.js | 33 +++++ server/tests/unit/jobs/ocrJob.test.js | 43 +++++- server/tests/unit/jobs/pgBossJobs.test.js | 56 ++++++++ server/tests/unit/routes/events.test.js | 52 +++++++ .../unit/routes/invoicePool.upload.test.js | 18 ++- .../unit/routes/reimbursements.create.test.js | 25 ++++ .../unit/routes/reimbursements.detail.test.js | 1 + server/tests/unit/services/events.test.js | 67 +++++++++ 34 files changed, 1217 insertions(+), 184 deletions(-) create mode 100644 client/src/api/events.ts create mode 100644 client/src/hooks/useInvoiceOcrTracker.ts create mode 100644 docs/trialrun/async-ux-audit.csv create mode 100644 issues/2026-05-09_10-47-33-ocr-realtime-ux-v2.csv create mode 100644 scripts/qa/check-pending-ocr.mjs create mode 100644 server/src/routes/events.js create mode 100644 server/src/services/events.js create mode 100644 server/tests/unit/jobs/pgBossJobs.test.js create mode 100644 server/tests/unit/routes/events.test.js create mode 100644 server/tests/unit/services/events.test.js diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index dbe4ca6b..56f6cb02 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -105,11 +105,11 @@ export function mapApiErrorMessage( } if (code === "VALID_006") { - return "OCR 重试次数已达上限,请稍后人工处理。"; + return "票据识别重试次数已达上限,请稍后人工处理。"; } if (code === "VALID_007") { - return "OCR 重试过于频繁,请稍后再试。"; + return "票据识别重试过于频繁,请稍后再试。"; } if (code === "VALID_001") { @@ -188,7 +188,7 @@ export function mapApiErrorMessage( } if (details?.ocr === "disabled") { - return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。"; + return "当前环境未开启票据识别,请联系管理员处理。"; } if (details?.ocr === "missing_required_fields") { diff --git a/client/src/api/events.ts b/client/src/api/events.ts new file mode 100644 index 00000000..4c5dd438 --- /dev/null +++ b/client/src/api/events.ts @@ -0,0 +1,81 @@ +export type AppEvent = { + id?: string; + type: string; + invoiceId?: string; + status?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +}; + +type SubscribeAppEventsOptions = { + accessToken: string; + onEvent: (event: AppEvent) => void; + onError?: (error: unknown) => void; +}; + +function parseSseMessage(message: string): AppEvent | null { + const dataLines = message + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()); + + if (!dataLines.length) { + return null; + } + + try { + return JSON.parse(dataLines.join("\n")) as AppEvent; + } catch { + return null; + } +} + +export function subscribeAppEvents(options: SubscribeAppEventsOptions) { + const controller = new AbortController(); + + void (async () => { + try { + const response = await fetch("/api/events", { + method: "GET", + credentials: "include", + headers: { + authorization: `Bearer ${options.accessToken}`, + accept: "text/event-stream", + }, + signal: controller.signal, + }); + + if (!response.ok || !response.body) { + throw new Error(`events_stream_unavailable:${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (!controller.signal.aborted) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split(/\r?\n\r?\n/); + buffer = chunks.pop() ?? ""; + + for (const chunk of chunks) { + const event = parseSseMessage(chunk); + if (event) { + options.onEvent(event); + } + } + } + } catch (error) { + if (!controller.signal.aborted) { + options.onError?.(error); + } + } + })(); + + return () => controller.abort(); +} diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index 0d038b3d..f9a601f4 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -21,6 +21,8 @@ export type InvoiceVerificationRecord = { export type InvoicePoolListItem = { id: string; status: InvoicePoolStatus; + availabilityReason: string | null; + availabilityMessage: string | null; ocrType: string; ocrProvider: string; ocrAttempts: number; @@ -68,11 +70,15 @@ export type UploadInvoiceResponse = { uploadAction?: "create" | "reuse_existing" | "blocked"; unavailableReason?: string | null; unavailableMessage?: string | null; + availabilityReason?: string | null; + availabilityMessage?: string | null; }; export type InvoicePoolDetail = { id: string; status: InvoicePoolStatus; + availabilityReason: string | null; + availabilityMessage: string | null; archivedReason: string | null; archivedAt: string | null; ownerEmployeeId: string; @@ -144,6 +150,8 @@ export type InvoiceStatusItem = { status: InvoicePoolStatus; reason: string; message: string; + availabilityReason: string | null; + availabilityMessage: string | null; invoiceCode: string | null; invoiceNumber: string | null; invoiceDate: string | null; diff --git a/client/src/hooks/useInvoiceOcrTracker.ts b/client/src/hooks/useInvoiceOcrTracker.ts new file mode 100644 index 00000000..73ef06ab --- /dev/null +++ b/client/src/hooks/useInvoiceOcrTracker.ts @@ -0,0 +1,125 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { subscribeAppEvents } from "../api/events"; +import { + fetchInvoiceStatuses, + type InvoiceStatusItem, +} from "../api/invoicePool"; + +type UseInvoiceOcrTrackerOptions = { + accessToken: string | null; + enabled: boolean; + pendingInvoiceIds: string[]; + setPendingInvoiceIds: (updater: string[] | ((previous: string[]) => string[])) => void; + onAvailable: (invoiceIds: string[]) => void; + onUnavailable: (items: InvoiceStatusItem[]) => void; +}; + +export function useInvoiceOcrTracker({ + accessToken, + enabled, + pendingInvoiceIds, + setPendingInvoiceIds, + onAvailable, + onUnavailable, +}: UseInvoiceOcrTrackerOptions) { + const [streamFailed, setStreamFailed] = useState(false); + const firstPendingAtRef = useRef(null); + const handledIdsRef = useRef(new Set()); + const pendingKey = pendingInvoiceIds.join(","); + + useEffect(() => { + if (pendingInvoiceIds.length && firstPendingAtRef.current == null) { + firstPendingAtRef.current = Date.now(); + } + if (!pendingInvoiceIds.length) { + firstPendingAtRef.current = null; + handledIdsRef.current.clear(); + } + }, [pendingInvoiceIds.length]); + + const statusQuery = useQuery({ + enabled: enabled && pendingInvoiceIds.length > 0, + queryKey: ["invoice-pool", "status", pendingInvoiceIds], + queryFn: () => fetchInvoiceStatuses(pendingInvoiceIds), + refetchInterval: pendingInvoiceIds.length ? 3_000 : false, + }); + + useEffect(() => { + if (!enabled || !accessToken || !pendingInvoiceIds.length) { + return; + } + + setStreamFailed(false); + return subscribeAppEvents({ + accessToken, + onEvent: (event) => { + if (event.type !== "invoice.updated" || !event.invoiceId) { + return; + } + if (pendingInvoiceIds.includes(event.invoiceId)) { + void statusQuery.refetch(); + } + }, + onError: () => setStreamFailed(true), + }); + }, [accessToken, enabled, pendingKey]); + + useEffect(() => { + const statuses = statusQuery.data?.items ?? []; + if (!statuses.length) { + return; + } + + const availableIds = statuses + .filter((item) => item.status === "available") + .map((item) => item.id) + .filter((invoiceId) => !handledIdsRef.current.has(invoiceId)); + const unavailableItems = statuses + .filter((item) => ["ocr_failed", "archived", "bound"].includes(item.status)) + .filter((item) => !handledIdsRef.current.has(item.id)); + const finishedIds = new Set([...availableIds, ...unavailableItems.map((item) => item.id)]); + + if (!finishedIds.size) { + return; + } + + for (const invoiceId of finishedIds) { + handledIdsRef.current.add(invoiceId); + } + + if (availableIds.length) { + onAvailable(availableIds); + } + if (unavailableItems.length) { + onUnavailable(unavailableItems); + } + + setPendingInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId))); + }, [statusQuery.data?.items, onAvailable, onUnavailable, setPendingInvoiceIds]); + + const hasLongWait = Boolean( + pendingInvoiceIds.length && + firstPendingAtRef.current && + Date.now() - firstPendingAtRef.current > 60_000, + ); + + const statusMessage = useMemo(() => { + if (!pendingInvoiceIds.length) { + return ""; + } + + if (hasLongWait) { + return `${pendingInvoiceIds.length} 张发票仍在识别,系统会继续自动刷新,完成后会自动添加。`; + } + + return `${pendingInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`; + }, [hasLongWait, pendingInvoiceIds.length]); + + return { + statusMessage, + isTracking: pendingInvoiceIds.length > 0, + streamFailed, + refetch: statusQuery.refetch, + }; +} diff --git a/client/src/lib/invoiceOcr.ts b/client/src/lib/invoiceOcr.ts index 0c917169..05a17cc5 100644 --- a/client/src/lib/invoiceOcr.ts +++ b/client/src/lib/invoiceOcr.ts @@ -29,19 +29,19 @@ export function formatInvoiceOcrFieldLabels(fields: string[]) { export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) { const missingFields = parseMissingInvoiceOcrFields(rawError); if (missingFields.length) { - return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`; + return `票据识别缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`; } if (rawError === "duplicate_invoice_code_number") { - return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。"; + return "已识别出相同发票,不能重复添加。"; } if (rawError === "mock_ocr_failed") { - return "OCR 识别失败,请重试或人工补齐关键信息。"; + return "票据识别失败,请重试或人工补齐关键信息。"; } if (!rawError) { - return "OCR 识别失败,请稍后重试。"; + return "票据识别失败,请稍后重试。"; } return rawError; diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index bddee06a..24aeeb13 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -8,7 +8,6 @@ import { bindInvoice, fetchBindableInvoices, fetchInvoicePoolList, - fetchInvoiceStatuses, unbindInvoice, uploadInvoice, type BindableInvoiceSummary, @@ -48,6 +47,7 @@ import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { TEST_IDS } from "../lib/testIds"; import { withUploadTimeout } from "../lib/uploadTimeout"; +import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { cn } from "../lib/utils"; type ItemFormState = { @@ -162,8 +162,9 @@ export function MobileReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { me, permissions } = useAuth(); + const { me, permissions, accessToken } = useAuth(); const fileInputRef = useRef(null); + const fileDocumentInputRef = useRef(null); const attachmentInputRef = useRef(null); const [itemForm, setItemForm] = useState(null); @@ -234,16 +235,6 @@ export function MobileReimbursementDetailPage() { refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, }); - const pendingInvoiceStatusesQuery = useQuery({ - enabled: pendingOcrInvoiceIds.length > 0, - queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds], - queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds), - refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, - }); - - const invoiceStatusMessage = pendingOcrInvoiceIds.length - ? `${pendingOcrInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。` - : invoiceActionMessage; const invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success"; const invoiceSearchLower = invoiceSearch.toLowerCase(); const bindableLibraryInvoices = useMemo( @@ -446,6 +437,35 @@ export function MobileReimbursementDetailPage() { }, }); + const handleTrackedInvoicesAvailable = useCallback( + (invoiceIds: string[]) => { + if (!invoiceIds.length) { + return; + } + bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId }))); + }, + [bindInvoicesMutation], + ); + + const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => { + const message = items + .map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.availabilityMessage ?? item.message}`) + .join(";"); + setInvoiceActionError(message); + setInvoiceActionMessage(""); + toast.error("部分发票需要处理"); + }, []); + + const invoiceOcrTracker = useInvoiceOcrTracker({ + accessToken, + enabled: Boolean(reimbursementId), + pendingInvoiceIds: pendingOcrInvoiceIds, + setPendingInvoiceIds: setPendingOcrInvoiceIds, + onAvailable: handleTrackedInvoicesAvailable, + onUnavailable: handleTrackedInvoicesUnavailable, + }); + const invoiceStatusMessage = invoiceOcrTracker.statusMessage || invoiceActionMessage; + const unbindInvoiceMutation = useMutation({ mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), onSuccess: async () => { @@ -537,10 +557,15 @@ export function MobileReimbursementDetailPage() { await invalidateDetail(); await bindableInvoicesQuery.refetch(); - setPendingOcrInvoiceIds(result.pendingIds); + setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds]))); setSelectedInvoiceFiles(result.failures.map((item) => item.file)); - if (!result.failures.length && fileInputRef.current) { - fileInputRef.current.value = ""; + if (!result.failures.length) { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + if (fileDocumentInputRef.current) { + fileDocumentInputRef.current.value = ""; + } } const messages: string[] = []; @@ -617,38 +642,6 @@ export function MobileReimbursementDetailPage() { }, }); - useEffect(() => { - const statuses = pendingInvoiceStatusesQuery.data?.items ?? []; - if (!statuses.length || bindInvoicesMutation.isPending) { - return; - } - - const availableIds = statuses - .filter((item) => item.status === "available") - .map((item) => item.id); - const finishedIds = new Set( - statuses - .filter((item) => item.status !== "pending_ocr") - .map((item) => item.id), - ); - const failedMessages = statuses - .filter((item) => item.status === "ocr_failed" || item.status === "archived") - .map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`); - - if (failedMessages.length) { - setInvoiceActionError(failedMessages.join(";")); - setInvoiceActionMessage(""); - } - - if (availableIds.length) { - bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId }))); - } - - if (finishedIds.size) { - setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId))); - } - }, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]); - const handleSubmit = async () => { if (!detail) { return; @@ -704,6 +697,9 @@ export function MobileReimbursementDetailPage() { if (fileInputRef.current) { fileInputRef.current.value = ""; } + if (fileDocumentInputRef.current) { + fileDocumentInputRef.current.value = ""; + } }; const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => { @@ -1145,23 +1141,42 @@ export function MobileReimbursementDetailPage() { {invoicePanel === "upload" ? (
- +
+ + +
setSelectedInvoiceFiles(Array.from(event.target.files ?? []))} + /> + setSelectedInvoiceFiles(Array.from(event.target.files ?? []))} /> diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index a281c730..39acdad4 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; @@ -27,7 +27,6 @@ import { import { bindInvoice, fetchBindableInvoices, - fetchInvoiceStatuses, unbindInvoice, uploadInvoice, } from "../api/invoicePool"; @@ -54,6 +53,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr"; +import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { TEST_IDS } from "../lib/testIds"; import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions"; @@ -89,10 +89,6 @@ function formatDateTime(value: string | null) { 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)}`; @@ -142,7 +138,7 @@ function resolveErpSyncStatus( export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const navigate = useNavigate(); - const { permissions, me } = useAuth(); + const { permissions, me, accessToken } = useAuth(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(""); @@ -501,7 +497,9 @@ export function ReimbursementDetailPage() { setInvoiceSearch(""); setSelectedInvoices({}); setSelectedInvoiceFiles([]); - setPendingOcrInvoiceIds([]); + setPendingOcrInvoiceIds((previous) => + previous.filter((invoiceId) => !succeededIds.includes(invoiceId)), + ); toast.success(`已添加 ${succeededIds.length} 张发票`); return; } @@ -523,6 +521,33 @@ export function ReimbursementDetailPage() { }, }); + const handleTrackedInvoicesAvailable = useCallback( + (invoiceIds: string[]) => { + if (!invoiceIds.length) { + return; + } + bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId }))); + }, + [bindInvoicesMutation], + ); + + const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => { + const message = items + .map((item) => `${item.originalName ?? item.id.slice(0, 8)}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`) + .join(";"); + setInvoiceActionError(message); + toast.error("部分发票需要处理"); + }, []); + + const invoiceOcrTracker = useInvoiceOcrTracker({ + accessToken, + enabled: Boolean(reimbursementId), + pendingInvoiceIds: pendingOcrInvoiceIds, + setPendingInvoiceIds: setPendingOcrInvoiceIds, + onAvailable: handleTrackedInvoicesAvailable, + onUnavailable: handleTrackedInvoicesUnavailable, + }); + const unbindInvoiceMutation = useMutation({ mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), onSuccess: async () => { @@ -584,34 +609,9 @@ export function ReimbursementDetailPage() { 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 fetchInvoiceStatuses(Array.from(pendingById.keys())); - - for (const invoice of invoiceResults.items) { - 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}: ${invoice.message || formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`); - pendingById.delete(invoice.id); - } else if (invoice.status === "archived" || invoice.status === "bound") { - const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8); - ocrFailures.push(`${fileName}: ${invoice.message}`); - pendingById.delete(invoice.id); - } - } - - setPendingOcrInvoiceIds(Array.from(pendingById.keys())); - } + setPendingOcrInvoiceIds((previous) => + Array.from(new Set([...previous, ...Array.from(pendingById.keys())])), + ); for (const invoiceId of availableIds) { try { @@ -635,30 +635,31 @@ export function ReimbursementDetailPage() { setInvoiceSearchInput(""); setInvoiceSearch(""); setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file)); - setPendingOcrInvoiceIds(result.pendingIds); + setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds]))); await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); await bindableInvoicesQuery.refetch(); - const messages = [ + const blockingMessages = [ ...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`), ...result.ocrFailures, ...result.bindFailures, ]; - if (result.pendingIds.length) { - messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`); - } - if (messages.length) { - const message = messages.join(";"); + if (blockingMessages.length) { + const message = blockingMessages.join(";"); setInvoiceActionError(message); - toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`); + toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${blockingMessages.length} 项需处理`); return; } setInvoiceActionError(""); + if (result.pendingIds.length) { + toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后会自动添加`); + return; + } toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`); }, onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), @@ -1644,7 +1645,7 @@ export function ReimbursementDetailPage() { {pendingOcrInvoiceIds.length ? (

- {pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。 + {invoiceOcrTracker.statusMessage}

) : null}
diff --git a/client/tests/e2e/invoice-ocr-flow.spec.ts b/client/tests/e2e/invoice-ocr-flow.spec.ts index a36780b0..9a4ce8f2 100644 --- a/client/tests/e2e/invoice-ocr-flow.spec.ts +++ b/client/tests/e2e/invoice-ocr-flow.spec.ts @@ -69,7 +69,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并 await page.getByTestId("invoice-upload-submit").click(); await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 }); - await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("OCR 缺少关键字段:发票代码、销售方。"); + await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("票据识别缺少关键字段:发票代码、销售方。"); await page.getByTestId("invoice-edit-open").click(); await page.getByTestId("invoice-edit-set-available").check(); diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts index 3980da9d..135f880e 100644 --- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts +++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts @@ -354,6 +354,8 @@ async function mockApis(page: Page, state: ReturnType { buffer: Buffer.from("%PDF r111 invoice"), }); await page.getByTestId("reimbursement-invoice-upload-submit").click(); - await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 }); expect(state.statusPollCount).toBeGreaterThanOrEqual(1); await page.keyboard.press("Escape"); @@ -588,8 +592,20 @@ test.describe("R111 国内报销发票与附件双端体验", () => { await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible(); await invoiceDialog.getByRole("button", { name: "上传" }).click(); + await expect(invoiceDialog.getByText("拍照或选图片")).toBeVisible(); + await expect(invoiceDialog.getByText("选择 PDF/文件")).toBeVisible(); + await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "image/*"); + await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("capture", "environment"); + await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).toHaveAttribute( + "accept", + /application\/pdf/, + ); + await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).not.toHaveAttribute( + "capture", + /.+/, + ); - await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ + await mobile.page.getByTestId("reimbursement-invoice-upload-document").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r111 mobile invoice"), diff --git a/docs/trialrun-runbook.md b/docs/trialrun-runbook.md index b91b4d03..65cb8b9a 100644 --- a/docs/trialrun-runbook.md +++ b/docs/trialrun-runbook.md @@ -104,12 +104,21 @@ LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readine 1. 上传接口是否成功返回 invoice id。 2. 发票是否进入 `pending_ocr`。 -3. OCR job 是否运行。 -4. OCR provider 是否为腾讯而不是 mock。 -5. OCR 是否返回结构化字段。 -6. 查重是否把发票转为不可添加。 -7. 前端状态查询是否轮询并刷新。 -8. 报销详情是否自动添加到明细。 +3. 单票识别任务是否立即入队,周期任务是否只作为兜底。 +4. OCR job 是否运行。 +5. OCR provider 是否为腾讯而不是 mock。 +6. OCR 是否返回结构化字段。 +7. 查重是否把发票转为不可添加。 +8. 前端事件流或轮询兜底是否触发刷新。 +9. 报销详情是否自动添加到明细。 + +发布前可执行 pending OCR 扫描,定位卡住的票据: + +```bash +node scripts/qa/check-pending-ocr.mjs --thresholds=2,5,10 +``` + +脚本只输出票据 ID、上传时间、尝试次数和最后错误摘要,不读取票据文件内容。缺少 `DATABASE_URL` 时应输出 `SKIP`,不视为真实数据库健康检查通过。 ## 公网与部署 diff --git a/docs/trialrun/async-ux-audit.csv b/docs/trialrun/async-ux-audit.csv new file mode 100644 index 00000000..fb18d981 --- /dev/null +++ b/docs/trialrun/async-ux-audit.csv @@ -0,0 +1,11 @@ +"ID","Flow","Trigger","CurrentFeedback","AutoRefresh","TerminalStates","FailureReason","RetryEntry","IndustryBenchmark","Risk","SuggestedIssue","Notes" +"AUX-01","报销详情发票识别","员工上传 PDF/图片发票","上传成功后显示单一识别状态,识别完成后自动添加;事件流失败时轮询兜底","fetch-stream/SSE + 3s 轮询兜底","已添加、识别失败、重复、已用于报销","通过 availabilityMessage 展示业务原因","失败发票可重新识别或人工修正","合思/分贝通/每刻均强调票据识别、验真、查重和电子档案","真实腾讯 OCR 耗时和公网链路仍需 live smoke 证据","保留 H10 live OCR gate","本轮已作为主链路优化" +"AUX-02","票据中心发票验真","财务或有权限用户触发验真","详情页显示验真记录,pending 时轮询记录列表","React Query 轮询 pending 验真记录","通过、失败、异常","展示 provider 返回的 resultMessage 或 errorMessage","失败记录可重试验真","国内费控系统通常把识别、验真、查重作为连续可追踪流程","普通员工不应看到过多 provider 细节;财务侧可保留","新增验真 UX 断言 Issue","不在本轮改业务代码" +"AUX-03","支付批次执行","财务创建、复核并执行付款批次","批次页显示状态和操作结果 toast","依赖页面 refetch 和 focused regression","待复核、已复核、执行中、已执行、失败","失败路径由批次路由和回归脚本覆盖","失败后按批次状态重新处理","分贝通/合思强调企业支付、对账、归档闭环","执行中长耗时缺少实时进度和失败明细聚合","新增支付批次执行进度 Issue","需防 maker-checker 权限回归" +"AUX-04","ERP 凭证/主数据同步","付款、报销或主数据变更后进入 outbox","管理页和预览可查看 pending outbox","当前以后台 job 和页面刷新为主","pending、sent、failed、dead-letter","outbox 错误摘要可查","管理端可 retry outbox","同类系统强调 OA/HRM/ERP 连接和三流合一","普通业务页对同步中/同步失败提示较弱","新增 ERP 同步状态外显 Issue","外部 ERP live 回执仍是专项" +"AUX-05","用户/主数据导入","管理员上传导入文件","导入接口返回成功/失败摘要","通常一次 HTTP 完成;批量大时缺少任务化反馈","完成、部分失败、失败","行级错误摘要","修正文件后重新导入","国内 OA 常见批量导入需行级错误与可下载失败清单","大批量导入可能阻塞且反馈不连续","新增导入任务化与错误清单 Issue","需先盘点现有导入入口" +"AUX-06","审批处理与移动离线队列","审批人在 PC/移动端通过、驳回、转交","操作后 toast 和列表刷新;移动离线队列有回放机制","页面 refetch;离线队列恢复后重放","已处理、待处理、失败、冲突","权限/状态冲突显示错误","失败后重新提交操作","国内 OA 强调移动审批效率和全程留痕","多人并发和代理审批 live 证据不足","新增审批并发与代理 live smoke Issue","不在本轮改 workflow 状态机" +"AUX-07","附件材料上传","报销草稿或驳回状态上传附件","显示上传结果和附件列表","上传完成后 refetch 详情","已上传、失败、已删除","上传失败显示错误","重新选择文件上传","行业实践区分发票凭证和附件材料","移动弱网下缺分片/重试进度;PDF 与图片入口需清楚","新增附件弱网与视觉断言 Issue","需保持附件不参与发票识别和查重" +"AUX-08","通知与 IM 投递","审批、报销状态变化或系统消息触发","站内通知列表显示未读;外部 IM 依赖配置","前端查询通知;外部投递后台处理","未读、已读、投递失败","后台日志或 outbox 错误","管理端/脚本重试","国内 OA 强调消息可达和待办闭环","真实企业微信消息投递证据不足","新增 IM 投递 live smoke Issue","真实企业微信登录由人工验收" +"AUX-09","预算/费用标准预检","提交报销或采购前触发预检","页面展示预算、标准和风险提示","多为提交前即时计算","通过、提示、阻断","以业务文案展示不合规原因","修改明细后重新预检","费控最佳实践强调事前管控","普通用户对阻断原因和修改建议仍可增强","新增提交前预检解释 Issue","不顺手改当前费用规则" +"AUX-10","公网 live smoke 与发布门禁","部署后执行公网 ready、资源扫描、OCR 样本和网页 smoke","脚本输出 pass 或明确 SKIP 缺项","命令式检查,不依赖页面自动刷新","通过、失败、跳过","缺账号/缺样本/公网异常","补齐环境变量后重跑","上线前必须保留真实链路证据","缺账号或样本时容易误以为已覆盖真实链路","保留 H10 发布门禁","本轮脚本已加入 pending OCR 扫描" diff --git a/docs/trialrun/real-device-public-checklist.md b/docs/trialrun/real-device-public-checklist.md index db4b2f1a..95fb5ff9 100644 --- a/docs/trialrun/real-device-public-checklist.md +++ b/docs/trialrun/real-device-public-checklist.md @@ -43,9 +43,9 @@ ## 票据上传 -- 拍照上传入口打开摄像头。 -- 选择图片入口打开相册或文件选择,支持 JPG、PNG、WEBP。 -- 选择 PDF 入口不应直接打开摄像头,应允许选择 PDF 文件。 +- 报销详情“添加发票”上传页应同时提供“拍照或选图片”和“选择 PDF/文件”两个入口。 +- “拍照或选图片”入口可打开摄像头或相册,支持 JPG、PNG、WEBP。 +- “选择 PDF/文件”入口不应直接打开摄像头,应允许选择 PDF 文件;在企业微信里重点记录是否能进入系统文件选择器。 - 上传后显示当前文件名、上传进度或失败原因。 - 网络中断或失败时有明确提示,可重试。 diff --git a/issues/2026-05-09_10-47-33-ocr-realtime-ux-v2.csv b/issues/2026-05-09_10-47-33-ocr-realtime-ux-v2.csv new file mode 100644 index 00000000..a37754de --- /dev/null +++ b/issues/2026-05-09_10-47-33-ocr-realtime-ux-v2.csv @@ -0,0 +1,11 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +H1,OCR v2合同与基线复核,"新建OCR实时体验v2执行合同,替代旧F1-F8计划;记录当前PC、移动、票据中心、真实OCR样本和live smoke准备状态。","1) 新CSV存在且字段完整;2) Notes明确supersedes旧计划;3) 不回改旧CSV;4) 开发范围以H2-H10为准。","业务流程:计划替换->开发实施;步骤:validate_issues_csv.py校验本CSV。","python|manual",DONE,DONE,DONE,"issues/2026-05-09_10-47-33-ocr-realtime-ux-v2.csv","[]","supersedes issues/2026-05-09_09-33-40-ocr-realtime-and-async-ux.csv;旧CSV保留历史快照。" +H2,后端定向OCR任务,"新增ocr.invoice定向任务和enqueueOcrInvoice(invoiceId)能力,上传与重新识别后立即投递;周期ocr.tick只做兜底。","1) 上传pending_ocr后尝试投递单票任务;2) retry OCR后尝试投递单票任务;3) pg-boss不可用时不阻塞HTTP;4) 定向任务只处理目标票据;5) 周期tick仍可处理pending票据。","业务流程:上传发票->立即入队->后台OCR;步骤:server unit tests覆盖入队、retry、pg-boss不可用、定向处理和tick兜底;node --check相关文件。","node|pg-boss|vitest",DONE,DONE,DONE,"server/src/jobs/ocrJob.js|server/src/jobs/pgBossJobs.js|server/src/routes/invoicePool.js|server/tests/unit","[""H1""]","不把腾讯OCR同步塞进上传HTTP;不新增数据库迁移。" +H3,OCR完成事件流,"新增认证fetch-stream/SSE事件接口/api/events,发布最小化invoice.updated事件;前端只用事件触发refetch,失败回退轮询。","1) 事件接口需Bearer token认证;2) payload只含type、invoiceId、status、updatedAt等最小字段;3) OCR完成后发布事件;4) 连接断开不影响原轮询兜底。","业务流程:OCR状态变化->事件通知->前端刷新;步骤:后端单测或轻量路由测试覆盖认证和事件格式;前端Playwright覆盖事件成功与断开兜底。","node|react|sse|playwright",DONE,DONE,DONE,"server/src/routes/events.js|server/src/services/events.js|server/src/app.js|client/src/api/events.ts|client/src/hooks","[""H2""]","不使用EventSource URL传token;fetch stream使用Authorization header。" +H4,前端OCR跟踪hook,"抽出useInvoiceOcrTracker,统一pending IDs、事件订阅、轮询兜底、状态归类、自动绑定和超长等待文案。","1) PC和移动报销详情共用跟踪逻辑;2) pending持续自动刷新,不再60秒停止;3) available自动绑定;4) failed/duplicate/used显示明确业务原因;5) 事件失败自动轮询。","业务流程:报销详情上传发票->识别完成->自动添加;步骤:Playwright mock慢OCR与事件/轮询路径断言。","react|typescript|playwright",DONE,DONE,DONE,"client/src/hooks/useInvoiceOcrTracker.ts|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/tests/e2e","[""H3""]","保留现有React Query缓存,事件只触发refetch。" +H5,添加发票单一提示与自动添加体验,"重构PC/移动添加发票弹窗的识别提示区,避免多处重复“识别中”;识别完成自动添加,失败或重复展示字段、原因和下一步。","1) 同一弹窗同一时刻只有一个主识别状态提示;2) PC上传后慢OCR 65秒仍自动添加;3) 移动端上传后自动添加;4) 失败/重复显示字段和下一步;5) 普通用户不见内部码值。","业务流程:添加发票弹窗->上传->识别->自动添加或处理异常;步骤:Playwright断言提示数量、自动添加、异常文案和禁用内部术语。","react|playwright",DONE,DONE,DONE,"client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/lib/invoiceOcr.ts|client/tests/e2e","[""H4""]","解决顶部/中部/底部重复识别提示。" +H6,重复票据语义清理,"不新增数据库状态枚举,扩展API返回availabilityReason/availabilityMessage,区分重复、已使用、识别失败、已添加和归档。","1) UploadInvoiceResponse、InvoicePoolDetail、InvoicePoolListItem、InvoiceStatusItem含availabilityReason/availabilityMessage;2) 重复不再泛化为OCR失败;3) 旧字段兼容;4) 普通用户页面不显示raw error。","业务流程:重复发票上传或OCR后判重->展示业务原因;步骤:后端单测覆盖状态映射;前端单测或E2E断言中文文案。","node|react|typescript|vitest",DONE,DONE,DONE,"server/src/routes/invoicePool/read.js|server/src/routes/invoicePool/shared/mappers.js|server/src/routes/invoicePool.js|client/src/api/invoicePool.ts|client/src/lib/invoiceOcr.ts","[""H2""]","不新增Prisma迁移,避免状态机变更风险。" +H7,移动端拍照与PDF文件入口,"企业微信/手机添加发票区分拍照/选择图片与选择PDF/文件,两入口共用上传逻辑;真机PDF选择写入清单。","1) 移动端有两个明确入口;2) 图片入口accept=image/*且capture可用;3) PDF/文件入口accept包含application/pdf;4) 选中文件名回显不溢出;5) real-device checklist记录真机PDF检查。","业务流程:企业微信添加发票->拍照或选择PDF->上传识别;步骤:Playwright mobile/wecom UA断言入口、accept属性和截图;人工清单更新。","react|playwright|manual",DONE,DONE,DONE,"client/src/pages/MobileReimbursementDetailPage.tsx|docs/trialrun/real-device-public-checklist.md|client/tests/e2e","[""H5""]","真机文件选择无法完全自动化,保留人工验收项。" +H8,pending OCR卡住扫描,"新增pending OCR扫描脚本,输出超过2/5/10分钟的票据数量、ID、上传时间、尝试次数、最后错误,不泄露图片和密钥。","1) 脚本可连接当前DATABASE_URL只读查询;2) 输出JSON或表格摘要;3) 不读取票据文件内容;4) release gate可调用并在无数据库时清晰SKIP。","业务流程:发布前OCR健康检查;步骤:node scripts/qa/check-pending-ocr.mjs --thresholds=2,5,10;无DB环境输出SKIP。","node|prisma|manual",DONE,DONE,DONE,"scripts/qa/check-pending-ocr.mjs|scripts/qa/run-live-smoke-readiness.sh|docs/trialrun-runbook.md","[""H2""]","只读检查,不修改数据。" +H9,项目级异步UX审计,"OCR主链路完成后扫描验真、支付批次、ERP同步、导入、审批、附件上传等异步流程,输出异步UX审计CSV,不顺手改业务代码。","1) docs/trialrun/async-ux-audit.csv存在;2) 每项包含流程、触发、反馈、自动更新、终态、失败原因、重试入口、风险和后续Issue建议;3) 高风险不在本轮顺手改。","业务流程:全项目异步体验审计;步骤:静态搜索路由、jobs、页面和E2E后人工审阅CSV。","manual|rg|docs",DONE,DONE,DONE,"docs/trialrun/async-ux-audit.csv|docs/trialrun/domestic-oa-best-practices.md","[""H5"",""H8""]","审计输出用于后续决策。" +H10,回归部署与公网复核,"完成H2-H9后执行完整验证、提交、部署和公网复核;只有公网检查通过后才通知用户可测试。","1) server prisma validate/node --check/相关unit通过;2) client typecheck/build通过;3) Playwright PC+mobile/wecom相关用例通过;4) 真实PDF OCR smoke有结果或明确SKIP缺项;5) 部署后/ready正常并检查公网文案。","业务流程:实现->回归->提交->部署->公网验证;步骤:server/client回归、run-web-gates或等效脚本、deploy/deploy.sh、curl /ready、live OCR。","bash|npm|playwright|curl|git",DOING,TODO,TODO,"server|client|scripts|docs|issues|deploy","[""H2"",""H3"",""H4"",""H5"",""H6"",""H7"",""H8"",""H9""]","面向用户可测改动必须部署后再通知;真实企业微信登录由人工验收。" diff --git a/scripts/qa/check-pending-ocr.mjs b/scripts/qa/check-pending-ocr.mjs new file mode 100644 index 00000000..c372ee66 --- /dev/null +++ b/scripts/qa/check-pending-ocr.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import process from "node:process"; +import { PrismaClient } from "../../server/node_modules/@prisma/client/index.js"; + +function parseThresholds() { + const arg = process.argv.find((item) => item.startsWith("--thresholds=")); + const raw = arg ? arg.slice("--thresholds=".length) : "2,5,10"; + return raw + .split(",") + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item) && item > 0) + .sort((a, b) => a - b); +} + +function minutesAgo(minutes) { + return new Date(Date.now() - minutes * 60_000); +} + +async function main() { + if (!process.env.DATABASE_URL) { + console.log("[pending-ocr][SKIP] missing DATABASE_URL"); + return; + } + + const thresholds = parseThresholds(); + const prisma = new PrismaClient(); + + try { + const summary = []; + for (const minutes of thresholds) { + const where = { + status: "pending_ocr", + created_at: { lte: minutesAgo(minutes) }, + }; + const count = await prisma.invoicePool.count({ where }); + const rows = await prisma.invoicePool.findMany({ + where, + orderBy: { created_at: "asc" }, + take: 50, + select: { + id: true, + created_at: true, + updated_at: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_locked_at: true, + ocr_lock_owner: true, + }, + }); + + summary.push({ + thresholdMinutes: minutes, + count, + sampleLimit: 50, + items: rows.map((item) => ({ + id: item.id, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + attempts: item.ocr_attempts ?? 0, + lastRequestedAt: item.ocr_last_requested_at?.toISOString?.() ?? null, + lastError: item.ocr_last_error ?? null, + lockedAt: item.ocr_locked_at?.toISOString?.() ?? null, + lockOwner: item.ocr_lock_owner ?? null, + })), + }); + } + + console.log(JSON.stringify({ ok: true, thresholds: summary }, null, 2)); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error) => { + console.error(`[pending-ocr][FAIL] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs index dd690213..7d2231f1 100644 --- a/scripts/qa/check-ui-design-rules.mjs +++ b/scripts/qa/check-ui-design-rules.mjs @@ -132,7 +132,7 @@ function scanNativeFileInputs(file, source) { function scanMobileCapturePdfInputs(file, source) { const normalized = toPosix(path.relative(rootDir, file)); - if (normalized !== "client/src/pages/MobileCapturePage.tsx") { + if (!/client\/src\/pages\/Mobile.*\.tsx$/.test(normalized)) { return; } diff --git a/scripts/qa/run-live-smoke-readiness.sh b/scripts/qa/run-live-smoke-readiness.sh index 9aefb57e..e04e4920 100755 --- a/scripts/qa/run-live-smoke-readiness.sh +++ b/scripts/qa/run-live-smoke-readiness.sh @@ -46,6 +46,9 @@ curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null echo ">>> [live-readiness] 公网资源文案扫描" node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt" +echo ">>> [live-readiness] pending OCR 扫描" +node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 | tee "$REPORT_DIR/pending-ocr.txt" + if has_live_smoke_credentials; then echo ">>> [live-readiness] 执行公网 live smoke" LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt" diff --git a/server/src/app.js b/server/src/app.js index 318ad84b..b210bb90 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -73,8 +73,10 @@ const travelAuthorizationsRoutes = require("./routes/travelAuthorizations"); const travelTransactionsRoutes = require("./routes/travelTransactions"); const travelReconciliationRoutes = require("./routes/travelReconciliation"); const aiRoutes = require("./routes/ai"); +const eventsRoutes = require("./routes/events"); const { startJobs } = require("./jobs/jobSystem"); const { initMetrics } = require("./observability"); +const { createAppEventBus } = require("./services/events"); const { AppError } = require("./utils/AppError"); const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi"); @@ -122,6 +124,7 @@ async function buildApp() { }); app.decorate("config", config); + app.decorate("appEvents", createAppEventBus()); await fs.mkdir(config.storageDir, { recursive: true }); await app.register(cookie); @@ -332,6 +335,7 @@ async function buildApp() { await app.register(authRoutes, { prefix: "/api/auth" }); await app.register(wecomAuthRoutes, { prefix: "/api/auth" }); + await app.register(eventsRoutes, { prefix: "/api" }); await app.register(meRoutes, { prefix: "/api" }); await app.register(reimbursementRoutes, { prefix: "/api" }); await app.register(invoicePoolRoutes, { prefix: "/api" }); diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js index be2825f5..c254c748 100644 --- a/server/src/jobs/ocrJob.js +++ b/server/src/jobs/ocrJob.js @@ -1,6 +1,7 @@ const cron = require("node-cron"); const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/ocr"); +const { publishInvoiceUpdated } = require("../services/events"); const { resolveInvoiceDedupBlocker } = require("../services/invoiceGovernance"); const { readFileBuffer } = require("../services/storage"); @@ -70,7 +71,30 @@ async function claimPendingInvoices(prisma, config, workerId) { ocr_lock_owner = ${workerId} FROM candidates WHERE i.id = candidates.id - RETURNING i.id, i.owner_employee_id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts + RETURNING i.id, i.owner_employee_id, i.uploaded_by_employee_id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts + `; +} + +async function claimPendingInvoiceById(prisma, config, workerId, invoiceId) { + const lockTtlSeconds = Math.max(60, Number(config.ocrLockTtlSeconds || 300)); + + return prisma.$queryRaw` + WITH candidates AS ( + SELECT id + FROM "invoice_pool" + WHERE id = ${invoiceId}::uuid + AND status = 'pending_ocr' + AND (ocr_locked_at IS NULL OR ocr_locked_at < NOW() - make_interval(secs => ${lockTtlSeconds})) + ORDER BY created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + UPDATE "invoice_pool" AS i + SET ocr_locked_at = NOW(), + ocr_lock_owner = ${workerId} + FROM candidates + WHERE i.id = candidates.id + RETURNING i.id, i.owner_employee_id, i.uploaded_by_employee_id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts `; } @@ -226,11 +250,12 @@ async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config) const now = new Date(); const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1; const isTerminal = nextAttempts >= config.ocrMaxAttempts; + const nextStatus = isTerminal ? "ocr_failed" : "pending_ocr"; await prisma.invoicePool.update({ where: { id: claimedInvoice.id }, data: { - status: isTerminal ? "ocr_failed" : "pending_ocr", + status: nextStatus, ocr_provider: getEffectiveOcrProvider(config), ocr_attempts: nextAttempts, ocr_last_error: errorMessage, @@ -239,6 +264,8 @@ async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config) ocr_lock_owner: null, }, }); + + return nextStatus; } async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, logger) { @@ -276,7 +303,7 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l }, "OCR invoice marked as failed for missing required fields", ); - return { outcome: "failed" }; + return { outcome: "failed", status: "ocr_failed" }; } const duplicate = await resolveInvoiceDedupBlocker( @@ -302,14 +329,14 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l }, "OCR invoice marked as duplicate", ); - return { outcome: "duplicate" }; + return { outcome: "duplicate", status: "ocr_failed" }; } await markInvoiceAsAvailable(prisma, config, claimedInvoice.id, normalized, ocrResultId); - return { outcome: "available" }; + return { outcome: "available", status: "available" }; } catch (error) { const message = trimErrorMessage(error); - await markInvoiceAsFailed(prisma, claimedInvoice, message, config); + const status = await markInvoiceAsFailed(prisma, claimedInvoice, message, config); logger.error( { err: error, @@ -320,12 +347,11 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l }, "OCR processing failed", ); - return { outcome: "failed" }; + return { outcome: "failed", status }; } } -async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`) { - const claimed = await claimPendingInvoices(prisma, config, workerId); +async function processClaimedInvoices(prisma, config, logger, workerId, claimed, options = {}) { if (!claimed.length) { return { claimed: 0, available: 0, duplicate: 0, failed: 0 }; } @@ -341,11 +367,31 @@ async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid} } else if (result?.outcome === "failed") { stats.failed += 1; } + + if (typeof options.onInvoiceProcessed === "function") { + await options.onInvoiceProcessed({ + invoiceId: item.id, + status: result?.status ?? item.status ?? null, + outcome: result?.outcome ?? null, + ownerEmployeeId: item.owner_employee_id ?? null, + uploadedByEmployeeId: item.uploaded_by_employee_id ?? null, + }); + } } return stats; } +async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`, options = {}) { + const claimed = await claimPendingInvoices(prisma, config, workerId); + return processClaimedInvoices(prisma, config, logger, workerId, claimed, options); +} + +async function runOcrForInvoiceId(prisma, config, logger, invoiceId, workerId = `ocr:${process.pid}`, options = {}) { + const claimed = await claimPendingInvoiceById(prisma, config, workerId, invoiceId); + return processClaimedInvoices(prisma, config, logger, workerId, claimed, options); +} + function startOcrJob(fastify) { if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) { fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled"); @@ -359,6 +405,12 @@ function startOcrJob(fastify) { let running = false; const workerId = `ocr-worker:${process.pid}`; + const onInvoiceProcessed = (event) => + publishInvoiceUpdated(fastify, { + invoiceId: event.invoiceId, + status: event.status, + audienceEmployeeIds: [event.ownerEmployeeId, event.uploadedByEmployeeId], + }); const task = cron.schedule(fastify.config.ocrPollCron, async () => { if (running) { @@ -367,7 +419,9 @@ function startOcrJob(fastify) { running = true; try { - const result = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); + const result = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId, { + onInvoiceProcessed, + }); fastify.metrics?.jobRunsTotal?.inc?.({ job: "ocr.tick", driver: "cron", @@ -423,9 +477,11 @@ function startOcrJob(fastify) { module.exports = { OCR_DUPLICATE_ERROR, + claimPendingInvoiceById, claimPendingInvoices, getEffectiveOcrProvider, markInvoiceAsQualityFailed, + runOcrForInvoiceId, runOcrOnce, startOcrJob, }; diff --git a/server/src/jobs/pgBossJobs.js b/server/src/jobs/pgBossJobs.js index 1cbd61df..adca759c 100644 --- a/server/src/jobs/pgBossJobs.js +++ b/server/src/jobs/pgBossJobs.js @@ -2,16 +2,18 @@ const PgBoss = require("pg-boss"); const { revokeExpiredDelegations } = require("./delegationRevokeJob"); const { archiveUnusedInvoices } = require("./invoiceArchiveJob"); -const { runOcrOnce } = require("./ocrJob"); +const { runOcrForInvoiceId, runOcrOnce } = require("./ocrJob"); const { runVerificationForRecord } = require("./verificationJob"); const { runIntegrationOutboxOnce } = require("./integrationOutboxJob"); const { runErpMasterDataSyncOnce } = require("./erpMasterDataSyncJob"); +const { publishInvoiceUpdated } = require("../services/events"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag, } = require("../services/featureFlags"); const JOB_OCR_TICK = "ocr.tick"; +const JOB_OCR_INVOICE = "ocr.invoice"; const JOB_DELEGATION_REVOKE = "delegation.revoke-expired"; const JOB_INVOICE_ARCHIVE = "invoice.archive-unused"; const JOB_INVOICE_VERIFY = "invoice.verify"; @@ -43,6 +45,7 @@ async function startPgBossJobs(fastify) { }); await boss.createQueue(JOB_OCR_TICK); + await boss.createQueue(JOB_OCR_INVOICE); await boss.createQueue(JOB_DELEGATION_REVOKE); await boss.createQueue(JOB_INVOICE_ARCHIVE); await boss.createQueue(JOB_INVOICE_VERIFY); @@ -51,6 +54,13 @@ async function startPgBossJobs(fastify) { await boss.createQueue(JOB_ERP_MASTER_DATA_SYNC); } + const onOcrInvoiceProcessed = (event) => + publishInvoiceUpdated(fastify, { + invoiceId: event.invoiceId, + status: event.status, + audienceEmployeeIds: [event.ownerEmployeeId, event.uploadedByEmployeeId], + }); + await boss.work(JOB_DELEGATION_REVOKE, { batchSize: 1 }, async (jobs) => { const job = jobs?.[0]; try { @@ -122,7 +132,9 @@ async function startPgBossJobs(fastify) { const job = jobs?.[0]; const workerId = `pgboss:ocr:${process.pid}`; try { - const stats = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); + const stats = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId, { + onInvoiceProcessed: onOcrInvoiceProcessed, + }); fastify.metrics?.jobRunsTotal?.inc?.({ job: JOB_OCR_TICK, driver: "pgboss", @@ -164,6 +176,67 @@ async function startPgBossJobs(fastify) { } }); + await boss.work(JOB_OCR_INVOICE, { batchSize: 1 }, async (jobs) => { + if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) { + return; + } + + const job = jobs?.[0]; + const invoiceId = job?.data?.invoiceId; + if (!invoiceId) { + return; + } + + const workerId = `pgboss:ocr-invoice:${process.pid}`; + try { + const stats = await runOcrForInvoiceId( + fastify.prisma, + fastify.config, + fastify.log, + invoiceId, + workerId, + { onInvoiceProcessed: onOcrInvoiceProcessed }, + ); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_OCR_INVOICE, + driver: "pgboss", + outcome: "success", + }); + if (stats.available > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_OCR_INVOICE, driver: "pgboss", kind: "available" }, + stats.available, + ); + } + if (stats.duplicate > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_OCR_INVOICE, driver: "pgboss", kind: "duplicate" }, + stats.duplicate, + ); + } + if (stats.failed > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_OCR_INVOICE, driver: "pgboss", kind: "failed" }, + stats.failed, + ); + } + if (stats.claimed > 0) { + fastify.log.info( + { jobId: job?.id, invoiceId, workerId, ...stats }, + "pg-boss OCR invoice job processed", + ); + } + } catch (error) { + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_OCR_INVOICE, + driver: "pgboss", + outcome: "failure", + }); + fastify.log.error({ err: error, jobId: job?.id, invoiceId }, "pg-boss OCR invoice job failed"); + throw error; + } + }); + await boss.work(JOB_INVOICE_VERIFY, { batchSize: 1 }, async (jobs) => { const job = jobs?.[0]; const recordId = job?.data?.recordId; @@ -301,7 +374,7 @@ async function startPgBossJobs(fastify) { await boss.schedule(JOB_OCR_TICK, fastify.config.ocrPollCron, null, { tz: fastify.config.appTimezone, singletonKey: JOB_OCR_TICK, - singletonSeconds: 55, + singletonSeconds: 1, }); } else { await boss.unschedule(JOB_OCR_TICK).catch(() => {}); @@ -359,12 +432,27 @@ async function startPgBossJobs(fastify) { return boss; } +async function enqueueOcrInvoice(fastify, invoiceId) { + if ( + fastify?.config?.jobDriver === "pgboss" && + fastify.boss && + ["tencent", "mock"].includes(fastify.config.ocrProvider) + ) { + await fastify.boss.send(JOB_OCR_INVOICE, { invoiceId }); + return { mode: "queued" }; + } + + return { mode: "fallback" }; +} + module.exports = { + JOB_OCR_INVOICE, JOB_OCR_TICK, JOB_DELEGATION_REVOKE, JOB_INVOICE_ARCHIVE, JOB_INVOICE_VERIFY, JOB_INTEGRATION_OUTBOX, JOB_ERP_MASTER_DATA_SYNC, + enqueueOcrInvoice, startPgBossJobs, }; diff --git a/server/src/routes/events.js b/server/src/routes/events.js new file mode 100644 index 00000000..93bba5df --- /dev/null +++ b/server/src/routes/events.js @@ -0,0 +1,52 @@ +const { canReceiveEvent, toClientEvent } = require("../services/events"); + +const HEARTBEAT_INTERVAL_MS = 25_000; + +function writeSseEvent(raw, event) { + const clientEvent = toClientEvent(event); + if (!clientEvent) { + return; + } + + raw.write(`id: ${clientEvent.id}\n`); + raw.write(`event: ${clientEvent.type}\n`); + raw.write(`data: ${JSON.stringify(clientEvent)}\n\n`); +} + +async function eventsRoutes(fastify) { + fastify.get( + "/events", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const canManageInvoice = fastify.hasPermission(request.employee, "invoice.manage"); + reply.hijack(); + reply.raw.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + "connection": "keep-alive", + "x-accel-buffering": "no", + }); + reply.raw.write(": connected\n\n"); + + const unsubscribe = fastify.appEvents.subscribe((event) => { + if (!canReceiveEvent(request.employee, event, { canManageInvoice })) { + return; + } + writeSseEvent(reply.raw, event); + }); + + const heartbeat = setInterval(() => { + reply.raw.write(": heartbeat\n\n"); + }, HEARTBEAT_INTERVAL_MS); + + request.raw.on("close", () => { + clearInterval(heartbeat); + unsubscribe(); + }); + }, + ); +} + +module.exports = eventsRoutes; diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index b04b460c..08534e73 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -10,6 +10,7 @@ const { sha256Hex } = require("../utils/crypto"); const { toMoneyString } = require("../utils/workflow"); const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { getMissingRequiredOcrFields, isOcrEnabledForType } = require("../services/ocr"); +const { enqueueOcrInvoice } = require("../jobs/pgBossJobs"); const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification"); const { registerInvoicePoolReadRoutes } = require("./invoicePool/read"); const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers"); @@ -17,6 +18,7 @@ const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects"); const { writeBuffer } = require("../services/storage"); const { mapInvoiceUnavailableReason, + resolveInvoiceAvailability, resolveInvoiceDedupBlocker, resolveUploadDedupDecision, } = require("../services/invoiceGovernance"); @@ -509,6 +511,8 @@ async function invoicePoolRoutes(fastify) { uploadAction: uploadDedupDecision.action, unavailableReason: uploadDedupDecision.reason, unavailableMessage: message, + availabilityReason: uploadDedupDecision.reason, + availabilityMessage: message, }); } @@ -570,10 +574,20 @@ async function invoicePoolRoutes(fastify) { return created; }); + if (invoice.status === "pending_ocr") { + enqueueOcrInvoice(fastify, invoice.id).catch((error) => { + request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue OCR invoice job"); + }); + } + + const availability = resolveInvoiceAvailability(invoice); return sendData(reply, request, { invoicePoolId: invoice.id, status: invoice.status, fileSha256: invoice.file_sha256, + uploadAction: "create", + availabilityReason: availability.reason, + availabilityMessage: availability.message, }); }, ); @@ -1231,6 +1245,10 @@ async function invoicePoolRoutes(fastify) { return updated; }); + enqueueOcrInvoice(fastify, invoice.id).catch((error) => { + request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue retry OCR invoice job"); + }); + return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); diff --git a/server/src/routes/invoicePool/read.js b/server/src/routes/invoicePool/read.js index 71bb7cea..54afc3ad 100644 --- a/server/src/routes/invoicePool/read.js +++ b/server/src/routes/invoicePool/read.js @@ -6,7 +6,7 @@ const { buildAuditLogData } = require("../../utils/audit"); const { signPayload, verifyToken } = require("../../utils/signedUrl"); const { openReadStream, StorageNotFoundError } = require("../../services/storage"); const { toMoneyString } = require("../../utils/workflow"); -const { mapInvoiceUnavailableReason } = require("../../services/invoiceGovernance"); +const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance"); const { mapInvoiceSummary, normalizeInvoiceResponse, @@ -106,48 +106,7 @@ function parseInvoiceStatusQuery(query) { } function resolveInvoiceStatusReason(invoice) { - if (invoice.status === "pending_ocr") { - return { - reason: "pending_ocr", - message: "发票正在识别,请稍后刷新", - }; - } - - if (invoice.status === "ocr_failed") { - const rawError = String(invoice.ocr_last_error ?? ""); - if (rawError === "duplicate_invoice_code_number" || rawError.includes("duplicate_invoice")) { - return { - reason: "duplicate_invoice_code_number", - message: mapInvoiceUnavailableReason("used_by_company") ?? "发票重复,不能添加", - }; - } - return { - reason: "ocr_failed", - message: rawError || "发票识别失败,请重新识别或手动处理", - }; - } - - if (invoice.status === "bound") { - return { - reason: "already_added", - message: "发票已添加到报销单", - }; - } - - if (invoice.status === "archived") { - return { - reason: invoice.archived_reason === "used_complete" ? "used_by_company" : "archived", - message: - invoice.archived_reason === "used_complete" - ? "该发票已用于报销,不能重复添加" - : "发票已归档,不可添加", - }; - } - - return { - reason: "available", - message: "发票可添加", - }; + return resolveInvoiceAvailability(invoice); } function ensureInvoiceReadableByUser(request, invoice) { @@ -254,6 +213,8 @@ function registerInvoicePoolReadRoutes(fastify) { status: invoice.status, reason: statusReason.reason, message: statusReason.message, + availabilityReason: statusReason.reason, + availabilityMessage: statusReason.message, invoiceCode: invoice.invoice_code ?? null, invoiceNumber: invoice.invoice_number ?? null, invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, diff --git a/server/src/routes/invoicePool/shared/mappers.js b/server/src/routes/invoicePool/shared/mappers.js index d668ce44..86e04548 100644 --- a/server/src/routes/invoicePool/shared/mappers.js +++ b/server/src/routes/invoicePool/shared/mappers.js @@ -1,4 +1,5 @@ const { toMoneyString } = require("../../../utils/workflow"); +const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance"); function mapVerificationRecord(item) { if (!item) { @@ -26,10 +27,13 @@ function mapInvoiceSummary(item) { const latestVerification = Array.isArray(item.verification_records) ? item.verification_records[0] ?? null : null; + const availability = resolveInvoiceAvailability(item); return { id: item.id, status: item.status, + availabilityReason: availability.reason, + availabilityMessage: availability.message, ocrType: item.ocr_type, ocrProvider: item.ocr_provider, ocrAttempts: item.ocr_attempts, @@ -71,10 +75,13 @@ function normalizeInvoiceResponse(invoice) { const latestVerification = Array.isArray(invoice.verification_records) ? invoice.verification_records[0] ?? null : null; + const availability = resolveInvoiceAvailability(invoice); return { id: invoice.id, status: invoice.status, + availabilityReason: availability.reason, + availabilityMessage: availability.message, archivedReason: invoice.archived_reason ?? null, archivedAt: invoice.archived_at?.toISOString?.() ?? null, ownerEmployeeId: invoice.owner_employee_id, diff --git a/server/src/routes/invoicePool/shared/selects.js b/server/src/routes/invoicePool/shared/selects.js index 8a7218f1..2100b4ff 100644 --- a/server/src/routes/invoicePool/shared/selects.js +++ b/server/src/routes/invoicePool/shared/selects.js @@ -28,6 +28,7 @@ function buildInvoiceSummarySelect() { return { id: true, status: true, + archived_reason: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, diff --git a/server/src/services/events.js b/server/src/services/events.js new file mode 100644 index 00000000..f24f0882 --- /dev/null +++ b/server/src/services/events.js @@ -0,0 +1,104 @@ +const { EventEmitter } = require("node:events"); + +function uniqueStrings(values) { + return Array.from( + new Set( + (Array.isArray(values) ? values : []) + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean), + ), + ); +} + +function createAppEventBus() { + const emitter = new EventEmitter(); + emitter.setMaxListeners(500); + let sequence = 0; + + return { + publish(event) { + const payload = { + id: `${Date.now()}-${++sequence}`, + createdAt: new Date().toISOString(), + ...event, + audienceEmployeeIds: uniqueStrings(event?.audienceEmployeeIds), + }; + emitter.emit("event", payload); + return payload; + }, + subscribe(handler) { + emitter.on("event", handler); + return () => emitter.off("event", handler); + }, + }; +} + +function buildInvoiceUpdatedEvent({ + invoiceId, + status, + updatedAt = null, + audienceEmployeeIds = [], +}) { + return { + type: "invoice.updated", + invoiceId, + status, + updatedAt: updatedAt ?? new Date().toISOString(), + audienceEmployeeIds: uniqueStrings(audienceEmployeeIds), + }; +} + +function publishInvoiceUpdated(fastify, event) { + if (!fastify?.appEvents || typeof fastify.appEvents.publish !== "function") { + return null; + } + return fastify.appEvents.publish(buildInvoiceUpdatedEvent(event)); +} + +function canReceiveEvent(employee, event, options = {}) { + if (!employee) { + return false; + } + + if (event?.type === "invoice.updated" && options.canManageInvoice) { + return true; + } + + const audience = uniqueStrings(event?.audienceEmployeeIds); + if (!audience.length) { + return false; + } + + return audience.includes(employee.id); +} + +function toClientEvent(event) { + if (!event) { + return null; + } + + if (event.type === "invoice.updated") { + return { + id: event.id, + type: event.type, + invoiceId: event.invoiceId, + status: event.status ?? null, + updatedAt: event.updatedAt ?? event.createdAt ?? null, + createdAt: event.createdAt ?? null, + }; + } + + return { + id: event.id, + type: event.type, + createdAt: event.createdAt ?? null, + }; +} + +module.exports = { + buildInvoiceUpdatedEvent, + canReceiveEvent, + createAppEventBus, + publishInvoiceUpdated, + toClientEvent, +}; diff --git a/server/src/services/invoiceGovernance.js b/server/src/services/invoiceGovernance.js index 4c6d3d3e..3ace0017 100644 --- a/server/src/services/invoiceGovernance.js +++ b/server/src/services/invoiceGovernance.js @@ -8,6 +8,14 @@ const INVOICE_UNAVAILABLE_REASONS = { usedByCompany: "used_by_company", }; +const OCR_FIELD_LABELS = { + invoiceCode: "发票代码", + invoiceNumber: "发票号码", + invoiceDate: "开票日期", + totalAmount: "总金额", + sellerName: "销售方", +}; + function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) { const filters = []; if (fileSha256) { @@ -182,13 +190,100 @@ function mapInvoiceUnavailableReason(reason) { } } +function parseMissingInvoiceOcrFields(rawError) { + 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); +} + +function formatInvoiceOcrFailureMessage(rawError) { + const missingFields = parseMissingInvoiceOcrFields(rawError); + if (missingFields.length) { + const labels = missingFields.map((field) => OCR_FIELD_LABELS[field] ?? field); + return `票据识别缺少关键字段:${labels.join("、")}。`; + } + + if (rawError === "mock_ocr_failed") { + return "票据识别失败,请重试或人工补齐关键信息。"; + } + + if (!rawError) { + return "票据识别失败,请稍后重试。"; + } + + return "票据识别失败,请重新识别或手动处理。"; +} + +function resolveInvoiceAvailability(invoice) { + if (!invoice) { + return { + reason: "not_found", + message: "未找到发票", + }; + } + + if (invoice.status === "pending_ocr") { + return { + reason: "pending_ocr", + message: "发票正在识别,完成后会自动刷新", + }; + } + + if (invoice.status === "ocr_failed") { + const rawError = String(invoice.ocr_last_error ?? ""); + if (rawError === "duplicate_invoice_code_number" || rawError.includes("duplicate_invoice")) { + return { + reason: "duplicate_invoice", + message: "已识别出相同发票,不能重复添加", + }; + } + + return { + reason: "ocr_failed", + message: formatInvoiceOcrFailureMessage(rawError), + }; + } + + if (isUsedInvoice(invoice)) { + return { + reason: INVOICE_UNAVAILABLE_REASONS.usedByCompany, + message: mapInvoiceUnavailableReason(INVOICE_UNAVAILABLE_REASONS.usedByCompany), + }; + } + + if (invoice.status === "archived") { + return { + reason: "archived", + message: "发票已归档,不可添加", + }; + } + + return { + reason: "available", + message: "发票可添加", + }; +} + module.exports = { INVOICE_UNAVAILABLE_REASONS, buildInvoiceDedupFilters, classifyInvoiceConflict, findInvoiceDedupConflicts, + formatInvoiceOcrFailureMessage, isUsedInvoice, mapInvoiceUnavailableReason, + parseMissingInvoiceOcrFields, + resolveInvoiceAvailability, resolveInvoiceDedupBlocker, resolveUploadDedupDecision, }; diff --git a/server/tests/unit/invoiceGovernance.dedup.test.js b/server/tests/unit/invoiceGovernance.dedup.test.js index 8828f935..1ec24f35 100644 --- a/server/tests/unit/invoiceGovernance.dedup.test.js +++ b/server/tests/unit/invoiceGovernance.dedup.test.js @@ -1,6 +1,8 @@ const { INVOICE_UNAVAILABLE_REASONS, classifyInvoiceConflict, + formatInvoiceOcrFailureMessage, + resolveInvoiceAvailability, resolveUploadDedupDecision, } = require("../../src/services/invoiceGovernance"); @@ -17,6 +19,7 @@ function buildInvoice(overrides = {}) { file_sha256: overrides.file_sha256 ?? "sha256-a", invoice_code: overrides.invoice_code ?? null, invoice_number: overrides.invoice_number ?? null, + ocr_last_error: overrides.ocr_last_error ?? null, }; } @@ -90,4 +93,34 @@ describe("invoiceGovernance dedup", () => { expect(decision.action).toBe("blocked"); expect(decision.reason).toBe(INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner); }); + + it("formats invoice availability without leaking raw OCR error codes", () => { + expect( + resolveInvoiceAvailability( + buildInvoice({ + status: "ocr_failed", + ocr_last_error: "missing_required_fields:invoiceCode,sellerName", + }), + ), + ).toEqual({ + reason: "ocr_failed", + message: "票据识别缺少关键字段:发票代码、销售方。", + }); + + expect( + resolveInvoiceAvailability( + buildInvoice({ + status: "ocr_failed", + ocr_last_error: "duplicate_invoice_code_number", + }), + ), + ).toEqual({ + reason: "duplicate_invoice", + message: "已识别出相同发票,不能重复添加", + }); + + expect(formatInvoiceOcrFailureMessage("mock_ocr_failed")).toBe( + "票据识别失败,请重试或人工补齐关键信息。", + ); + }); }); diff --git a/server/tests/unit/jobs/ocrJob.test.js b/server/tests/unit/jobs/ocrJob.test.js index 264ffb88..bdb6bf0e 100644 --- a/server/tests/unit/jobs/ocrJob.test.js +++ b/server/tests/unit/jobs/ocrJob.test.js @@ -2,12 +2,14 @@ const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); -const { runOcrOnce } = require("../../../src/jobs/ocrJob"); +const { runOcrForInvoiceId, runOcrOnce } = require("../../../src/jobs/ocrJob"); function buildClaimedInvoice(overrides = {}) { return { id: "11111111-1111-4111-8111-111111111111", file_sha256: "sha256-1", + owner_employee_id: "employee-owner-1", + uploaded_by_employee_id: "employee-upload-1", ocr_provider: "mock", ocr_type: "vat", storage_path: "20260428/invoice.txt", @@ -91,6 +93,45 @@ describe("ocrJob quality gate", () => { ); }); + it("processes a directed invoice and emits processed metadata", async () => { + await fs.writeFile( + path.join(tempDir, "20260428/invoice.txt"), + "MOCK_DIRECTED_SUCCESS_R73", + "utf8", + ); + + const prisma = buildPrisma([buildClaimedInvoice()]); + const logger = { + warn: vi.fn(), + error: vi.fn(), + }; + const onInvoiceProcessed = vi.fn(); + + const result = await runOcrForInvoiceId( + prisma, + { + storageDir: tempDir, + ocrBatchSize: 10, + ocrLockTtlSeconds: 300, + ocrMaxAttempts: 3, + ocrProvider: "mock", + }, + logger, + "11111111-1111-4111-8111-111111111111", + "ocr-directed-worker", + { onInvoiceProcessed }, + ); + + expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 }); + expect(onInvoiceProcessed).toHaveBeenCalledWith({ + invoiceId: "11111111-1111-4111-8111-111111111111", + status: "available", + outcome: "available", + ownerEmployeeId: "employee-owner-1", + uploadedByEmployeeId: "employee-upload-1", + }); + }); + it("marks invoice ocr_failed when required VAT fields are missing", async () => { await fs.writeFile( path.join(tempDir, "20260428/invoice.txt"), diff --git a/server/tests/unit/jobs/pgBossJobs.test.js b/server/tests/unit/jobs/pgBossJobs.test.js new file mode 100644 index 00000000..34a1a3bd --- /dev/null +++ b/server/tests/unit/jobs/pgBossJobs.test.js @@ -0,0 +1,56 @@ +const { enqueueOcrInvoice, JOB_OCR_INVOICE } = require("../../../src/jobs/pgBossJobs"); + +describe("pgBossJobs enqueueOcrInvoice", () => { + it("queues a directed OCR invoice job when pg-boss is available", async () => { + const send = vi.fn(async () => ({ id: "job-1" })); + + const result = await enqueueOcrInvoice( + { + config: { + jobDriver: "pgboss", + ocrProvider: "mock", + }, + boss: { send }, + }, + "11111111-1111-4111-8111-111111111111", + ); + + expect(result).toEqual({ mode: "queued" }); + expect(send).toHaveBeenCalledWith(JOB_OCR_INVOICE, { + invoiceId: "11111111-1111-4111-8111-111111111111", + }); + }); + + it("falls back cleanly when pg-boss is unavailable", async () => { + await expect( + enqueueOcrInvoice( + { + config: { + jobDriver: "pgboss", + ocrProvider: "mock", + }, + boss: null, + }, + "11111111-1111-4111-8111-111111111111", + ), + ).resolves.toEqual({ mode: "fallback" }); + }); + + it("does not queue OCR when the OCR provider is disabled", async () => { + const send = vi.fn(); + + const result = await enqueueOcrInvoice( + { + config: { + jobDriver: "pgboss", + ocrProvider: "off", + }, + boss: { send }, + }, + "11111111-1111-4111-8111-111111111111", + ); + + expect(result).toEqual({ mode: "fallback" }); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/routes/events.test.js b/server/tests/unit/routes/events.test.js new file mode 100644 index 00000000..1ceb8022 --- /dev/null +++ b/server/tests/unit/routes/events.test.js @@ -0,0 +1,52 @@ +const Fastify = require("fastify"); + +const eventsRoutes = require("../../../src/routes/events"); +const { createAppEventBus } = require("../../../src/services/events"); +const { AppError } = require("../../../src/utils/AppError"); + +async function buildApp() { + const app = Fastify(); + app.decorate("appEvents", createAppEventBus()); + app.decorate("authenticate", async (request) => { + const auth = request.headers.authorization ?? ""; + if (!auth.startsWith("Bearer ")) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { id: "employee-1", permissionCodes: [] }; + }); + app.decorate("hasPermission", (employee, permissionCode) => + (employee?.permissionCodes ?? []).includes(permissionCode), + ); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + return reply.status(500).send({ error: { code: "SYS_001", message: "服务异常" } }); + }); + await app.register(eventsRoutes, { prefix: "/api" }); + return app; +} + +describe("GET /api/events", () => { + it("requires bearer authentication before opening the event stream", async () => { + const app = await buildApp(); + + try { + const response = await app.inject({ + method: "GET", + url: "/api/events", + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error.code).toBe("AUTH_001"); + } finally { + await app.close(); + } + }); +}); diff --git a/server/tests/unit/routes/invoicePool.upload.test.js b/server/tests/unit/routes/invoicePool.upload.test.js index 67634e1d..ade9d6fc 100644 --- a/server/tests/unit/routes/invoicePool.upload.test.js +++ b/server/tests/unit/routes/invoicePool.upload.test.js @@ -38,6 +38,8 @@ function buildMultipartRequest({ filename, contentType, content, fields = [] }) async function buildApp({ ocrProvider = "off", + jobDriver = "cron", + bossSend = null, invoiceConflicts = [], statusRows = [], permissionCodes = ["invoice.upload"], @@ -75,7 +77,11 @@ async function buildApp({ maxUploadSizeBytes: 5 * 1024 * 1024, invoiceArchiveUnusedDays: 30, ocrProvider, + jobDriver, }); + if (bossSend) { + app.decorate("boss", { send: bossSend }); + } app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); @@ -177,8 +183,13 @@ describe("invoicePool upload route", () => { } }); - it("puts VAT uploads into pending_ocr when OCR provider is enabled", async () => { - const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock" }); + it("puts VAT uploads into pending_ocr and enqueues a directed OCR job when OCR provider is enabled", async () => { + const bossSend = vi.fn(async () => ({ id: "ocr-job-1" })); + const { app, prisma, tempDir } = await buildApp({ + ocrProvider: "mock", + jobDriver: "pgboss", + bossSend, + }); try { const requestPayload = buildMultipartRequest({ @@ -214,6 +225,9 @@ describe("invoicePool upload route", () => { }), }), ); + expect(bossSend).toHaveBeenCalledWith("ocr.invoice", { + invoiceId: "11111111-1111-4111-8111-111111111111", + }); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); diff --git a/server/tests/unit/routes/reimbursements.create.test.js b/server/tests/unit/routes/reimbursements.create.test.js index b8d44954..d38f3394 100644 --- a/server/tests/unit/routes/reimbursements.create.test.js +++ b/server/tests/unit/routes/reimbursements.create.test.js @@ -133,6 +133,7 @@ async function buildApp(options = {}) { ], travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)), auditLogs: [], + sequenceNumber: 0, }; function buildReimbursementSelect(record, select) { @@ -236,6 +237,28 @@ async function buildApp(options = {}) { return found ? cloneTravelRequest(found) : null; }), }, + documentSequence: { + findUnique: vi.fn(async () => null), + upsert: vi.fn(async ({ create, update }) => { + stateRef.sequenceNumber = (stateRef.sequenceNumber ?? 0) + 1; + return { + document_type: create.document_type, + prefix: update.prefix ?? create.prefix, + date_format: update.date_format ?? create.date_format, + separator: update.separator ?? create.separator, + number_length: update.number_length ?? create.number_length, + reset_period: update.reset_period ?? create.reset_period, + current_number: stateRef.sequenceNumber, + last_reset_date: null, + updated_at: new Date("2026-04-08T12:00:00.000Z"), + updated_by: update.updated_by ?? create.updated_by ?? null, + }; + }), + }, + documentCategory: { + findUnique: vi.fn(async () => null), + findFirst: vi.fn(async () => null), + }, reimbursementForm: { create: vi.fn(async ({ data, select }) => { const record = { @@ -317,6 +340,7 @@ async function buildApp(options = {}) { expenseTypes: state.expenseTypes.map((item) => ({ ...item })), travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)), auditLogs: state.auditLogs.map((item) => ({ ...item })), + sequenceNumber: state.sequenceNumber, }; const tx = buildPrismaLike(txState); const result = await callback(tx); @@ -327,6 +351,7 @@ async function buildApp(options = {}) { state.expenseTypes = txState.expenseTypes; state.travelRequests = txState.travelRequests; state.auditLogs = txState.auditLogs; + state.sequenceNumber = txState.sequenceNumber; return result; }); diff --git a/server/tests/unit/routes/reimbursements.detail.test.js b/server/tests/unit/routes/reimbursements.detail.test.js index 94615bfd..3f71b458 100644 --- a/server/tests/unit/routes/reimbursements.detail.test.js +++ b/server/tests/unit/routes/reimbursements.detail.test.js @@ -44,6 +44,7 @@ function buildReimbursementRecord(overrides = {}) { }, }, ], + attachments: [], invoice_pool_items: [ { id: "invoice-1", diff --git a/server/tests/unit/services/events.test.js b/server/tests/unit/services/events.test.js new file mode 100644 index 00000000..859980dd --- /dev/null +++ b/server/tests/unit/services/events.test.js @@ -0,0 +1,67 @@ +const { + buildInvoiceUpdatedEvent, + canReceiveEvent, + createAppEventBus, + toClientEvent, +} = require("../../../src/services/events"); + +describe("app events", () => { + it("publishes minimal invoice updated events", () => { + const event = buildInvoiceUpdatedEvent({ + invoiceId: "11111111-1111-4111-8111-111111111111", + status: "available", + audienceEmployeeIds: ["employee-1", "employee-1", ""], + updatedAt: "2026-05-09T10:00:00.000Z", + }); + + expect(event).toEqual({ + type: "invoice.updated", + invoiceId: "11111111-1111-4111-8111-111111111111", + status: "available", + updatedAt: "2026-05-09T10:00:00.000Z", + audienceEmployeeIds: ["employee-1"], + }); + + expect(toClientEvent({ id: "evt-1", createdAt: "2026-05-09T10:00:01.000Z", ...event })).toEqual({ + id: "evt-1", + type: "invoice.updated", + invoiceId: "11111111-1111-4111-8111-111111111111", + status: "available", + updatedAt: "2026-05-09T10:00:00.000Z", + createdAt: "2026-05-09T10:00:01.000Z", + }); + }); + + it("filters invoice events by employee audience unless invoice manager", () => { + const event = buildInvoiceUpdatedEvent({ + invoiceId: "11111111-1111-4111-8111-111111111111", + status: "available", + audienceEmployeeIds: ["employee-1"], + }); + + expect(canReceiveEvent({ id: "employee-1" }, event)).toBe(true); + expect(canReceiveEvent({ id: "employee-2" }, event)).toBe(false); + expect(canReceiveEvent({ id: "employee-2" }, event, { canManageInvoice: true })).toBe(true); + }); + + it("supports subscribe and unsubscribe on the in-process event bus", () => { + const bus = createAppEventBus(); + const handler = vi.fn(); + const unsubscribe = bus.subscribe(handler); + + bus.publish({ type: "invoice.updated", invoiceId: "invoice-1", audienceEmployeeIds: ["employee-1"] }); + unsubscribe(); + bus.publish({ type: "invoice.updated", invoiceId: "invoice-2", audienceEmployeeIds: ["employee-1"] }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + createdAt: expect.any(String), + type: "invoice.updated", + invoiceId: "invoice-1", + audienceEmployeeIds: ["employee-1"], + }), + ); + }); +});