H1-H10 优化OCR实时体验闭环

这个提交包含在:
2026-05-09 11:48:57 +08:00
父节点 549bd81e30
当前提交 c26cf9b954
共修改 34 个文件,包含 1217 行新增和 184 行删除
+3 -3
查看文件
@@ -105,11 +105,11 @@ export function mapApiErrorMessage(
} }
if (code === "VALID_006") { if (code === "VALID_006") {
return "OCR 重试次数已达上限,请稍后人工处理。"; return "票据识别重试次数已达上限,请稍后人工处理。";
} }
if (code === "VALID_007") { if (code === "VALID_007") {
return "OCR 重试过于频繁,请稍后再试。"; return "票据识别重试过于频繁,请稍后再试。";
} }
if (code === "VALID_001") { if (code === "VALID_001") {
@@ -188,7 +188,7 @@ export function mapApiErrorMessage(
} }
if (details?.ocr === "disabled") { if (details?.ocr === "disabled") {
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。"; return "当前环境未开启票据识别,请联系管理员处理。";
} }
if (details?.ocr === "missing_required_fields") { if (details?.ocr === "missing_required_fields") {
+81
查看文件
@@ -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();
}
+8
查看文件
@@ -21,6 +21,8 @@ export type InvoiceVerificationRecord = {
export type InvoicePoolListItem = { export type InvoicePoolListItem = {
id: string; id: string;
status: InvoicePoolStatus; status: InvoicePoolStatus;
availabilityReason: string | null;
availabilityMessage: string | null;
ocrType: string; ocrType: string;
ocrProvider: string; ocrProvider: string;
ocrAttempts: number; ocrAttempts: number;
@@ -68,11 +70,15 @@ export type UploadInvoiceResponse = {
uploadAction?: "create" | "reuse_existing" | "blocked"; uploadAction?: "create" | "reuse_existing" | "blocked";
unavailableReason?: string | null; unavailableReason?: string | null;
unavailableMessage?: string | null; unavailableMessage?: string | null;
availabilityReason?: string | null;
availabilityMessage?: string | null;
}; };
export type InvoicePoolDetail = { export type InvoicePoolDetail = {
id: string; id: string;
status: InvoicePoolStatus; status: InvoicePoolStatus;
availabilityReason: string | null;
availabilityMessage: string | null;
archivedReason: string | null; archivedReason: string | null;
archivedAt: string | null; archivedAt: string | null;
ownerEmployeeId: string; ownerEmployeeId: string;
@@ -144,6 +150,8 @@ export type InvoiceStatusItem = {
status: InvoicePoolStatus; status: InvoicePoolStatus;
reason: string; reason: string;
message: string; message: string;
availabilityReason: string | null;
availabilityMessage: string | null;
invoiceCode: string | null; invoiceCode: string | null;
invoiceNumber: string | null; invoiceNumber: string | null;
invoiceDate: string | null; invoiceDate: string | null;
+125
查看文件
@@ -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<number | null>(null);
const handledIdsRef = useRef(new Set<string>());
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,
};
}
+4 -4
查看文件
@@ -29,19 +29,19 @@ export function formatInvoiceOcrFieldLabels(fields: string[]) {
export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) { export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) {
const missingFields = parseMissingInvoiceOcrFields(rawError); const missingFields = parseMissingInvoiceOcrFields(rawError);
if (missingFields.length) { if (missingFields.length) {
return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`; return `票据识别缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
} }
if (rawError === "duplicate_invoice_code_number") { if (rawError === "duplicate_invoice_code_number") {
return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。"; return "已识别出相同发票,不能重复添加。";
} }
if (rawError === "mock_ocr_failed") { if (rawError === "mock_ocr_failed") {
return "OCR 识别失败,请重试或人工补齐关键信息。"; return "票据识别失败,请重试或人工补齐关键信息。";
} }
if (!rawError) { if (!rawError) {
return "OCR 识别失败,请稍后重试。"; return "票据识别失败,请稍后重试。";
} }
return rawError; return rawError;
@@ -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 { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -8,7 +8,6 @@ import {
bindInvoice, bindInvoice,
fetchBindableInvoices, fetchBindableInvoices,
fetchInvoicePoolList, fetchInvoicePoolList,
fetchInvoiceStatuses,
unbindInvoice, unbindInvoice,
uploadInvoice, uploadInvoice,
type BindableInvoiceSummary, type BindableInvoiceSummary,
@@ -48,6 +47,7 @@ import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds"; import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout"; import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
type ItemFormState = { type ItemFormState = {
@@ -162,8 +162,9 @@ export function MobileReimbursementDetailPage() {
const { reimbursementId = "" } = useParams(); const { reimbursementId = "" } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { me, permissions } = useAuth(); const { me, permissions, accessToken } = useAuth();
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const fileDocumentInputRef = useRef<HTMLInputElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null); const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const [itemForm, setItemForm] = useState<ItemFormState | null>(null); const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
@@ -234,16 +235,6 @@ export function MobileReimbursementDetailPage() {
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, 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 invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success";
const invoiceSearchLower = invoiceSearch.toLowerCase(); const invoiceSearchLower = invoiceSearch.toLowerCase();
const bindableLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>( const bindableLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
@@ -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({ const unbindInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
onSuccess: async () => { onSuccess: async () => {
@@ -537,10 +557,15 @@ export function MobileReimbursementDetailPage() {
await invalidateDetail(); await invalidateDetail();
await bindableInvoicesQuery.refetch(); await bindableInvoicesQuery.refetch();
setPendingOcrInvoiceIds(result.pendingIds); setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
setSelectedInvoiceFiles(result.failures.map((item) => item.file)); setSelectedInvoiceFiles(result.failures.map((item) => item.file));
if (!result.failures.length && fileInputRef.current) { if (!result.failures.length) {
fileInputRef.current.value = ""; if (fileInputRef.current) {
fileInputRef.current.value = "";
}
if (fileDocumentInputRef.current) {
fileDocumentInputRef.current.value = "";
}
} }
const messages: string[] = []; 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 () => { const handleSubmit = async () => {
if (!detail) { if (!detail) {
return; return;
@@ -704,6 +697,9 @@ export function MobileReimbursementDetailPage() {
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
if (fileDocumentInputRef.current) {
fileDocumentInputRef.current.value = "";
}
}; };
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => { const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
@@ -1145,23 +1141,42 @@ export function MobileReimbursementDetailPage() {
{invoicePanel === "upload" ? ( {invoicePanel === "upload" ? (
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<label <div className="grid grid-cols-2 gap-2">
htmlFor="mobile-reimbursement-invoice-upload" <label
className="flex min-h-32 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-4 py-5 text-center" htmlFor="mobile-reimbursement-invoice-upload-image"
> className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
<span className="material-symbols-outlined text-[30px] text-zinc-700">upload_file</span> >
<span className="mt-2 text-sm font-semibold text-zinc-900"> <span className="material-symbols-outlined text-[28px] text-zinc-700">photo_camera</span>
{selectedInvoiceFiles.length ? `已选择 ${selectedInvoiceFiles.length} 个文件` : "选择或拍摄发票"} <span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选图片</span>
</span> <span className="mt-1 text-xs text-zinc-500">适合手机拍发票</span>
<span className="mt-1 text-xs text-zinc-500">支持图片和 PDF,可多选</span> </label>
</label> <label
htmlFor="mobile-reimbursement-invoice-upload-document"
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
>
<span className="material-symbols-outlined text-[28px] text-zinc-700">picture_as_pdf</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF/文件</span>
<span className="mt-1 text-xs text-zinc-500">适合电子发票</span>
</label>
</div>
<input <input
ref={fileInputRef} ref={fileInputRef}
id="mobile-reimbursement-invoice-upload" id="mobile-reimbursement-invoice-upload-image"
className="sr-only" className="sr-only"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile} data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file" type="file"
accept="image/*,application/pdf" accept="image/*"
capture="environment"
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
<input
ref={fileDocumentInputRef}
id="mobile-reimbursement-invoice-upload-document"
className="sr-only"
data-testid="reimbursement-invoice-upload-document"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))} onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/> />
+46 -45
查看文件
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
@@ -27,7 +27,6 @@ import {
import { import {
bindInvoice, bindInvoice,
fetchBindableInvoices, fetchBindableInvoices,
fetchInvoiceStatuses,
unbindInvoice, unbindInvoice,
uploadInvoice, uploadInvoice,
} from "../api/invoicePool"; } from "../api/invoicePool";
@@ -54,6 +53,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr"; import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds"; import { TEST_IDS } from "../lib/testIds";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions"; import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
@@ -89,10 +89,6 @@ function formatDateTime(value: string | null) {
return value.slice(0, 19).replace("T", " "); 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) { function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
if (isApiError(error)) { if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
@@ -142,7 +138,7 @@ function resolveErpSyncStatus(
export function ReimbursementDetailPage() { export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams(); const { reimbursementId = "" } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { permissions, me } = useAuth(); const { permissions, me, accessToken } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [actionError, setActionError] = useState(""); const [actionError, setActionError] = useState("");
@@ -501,7 +497,9 @@ export function ReimbursementDetailPage() {
setInvoiceSearch(""); setInvoiceSearch("");
setSelectedInvoices({}); setSelectedInvoices({});
setSelectedInvoiceFiles([]); setSelectedInvoiceFiles([]);
setPendingOcrInvoiceIds([]); setPendingOcrInvoiceIds((previous) =>
previous.filter((invoiceId) => !succeededIds.includes(invoiceId)),
);
toast.success(`已添加 ${succeededIds.length} 张发票`); toast.success(`已添加 ${succeededIds.length} 张发票`);
return; 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({ const unbindInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
onSuccess: async () => { onSuccess: async () => {
@@ -584,34 +609,9 @@ export function ReimbursementDetailPage() {
setInvoiceUploadProgress({ completed: index + 1, total: files.length }); setInvoiceUploadProgress({ completed: index + 1, total: files.length });
} }
setPendingOcrInvoiceIds(Array.from(pendingById.keys())); setPendingOcrInvoiceIds((previous) =>
Array.from(new Set([...previous, ...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()));
}
for (const invoiceId of availableIds) { for (const invoiceId of availableIds) {
try { try {
@@ -635,30 +635,31 @@ export function ReimbursementDetailPage() {
setInvoiceSearchInput(""); setInvoiceSearchInput("");
setInvoiceSearch(""); setInvoiceSearch("");
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file)); 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: ["invoice-pool"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
await bindableInvoicesQuery.refetch(); await bindableInvoicesQuery.refetch();
const messages = [ const blockingMessages = [
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`), ...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
...result.ocrFailures, ...result.ocrFailures,
...result.bindFailures, ...result.bindFailures,
]; ];
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`);
}
if (messages.length) { if (blockingMessages.length) {
const message = messages.join(";"); const message = blockingMessages.join(";");
setInvoiceActionError(message); setInvoiceActionError(message);
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`); toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${blockingMessages.length} 项需处理`);
return; return;
} }
setInvoiceActionError(""); setInvoiceActionError("");
if (result.pendingIds.length) {
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后会自动添加`);
return;
}
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`); toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
}, },
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
@@ -1644,7 +1645,7 @@ export function ReimbursementDetailPage() {
{pendingOcrInvoiceIds.length ? ( {pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}> <p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。 {invoiceOcrTracker.statusMessage}
</p> </p>
) : null} ) : null}
</div> </div>
+1 -1
查看文件
@@ -69,7 +69,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
await page.getByTestId("invoice-upload-submit").click(); await page.getByTestId("invoice-upload-submit").click();
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 }); 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-open").click();
await page.getByTestId("invoice-edit-set-available").check(); await page.getByTestId("invoice-edit-set-available").check();
@@ -354,6 +354,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
uploadAction: "create", uploadAction: "create",
unavailableReason: null, unavailableReason: null,
unavailableMessage: null, unavailableMessage: null,
availabilityReason: "pending_ocr",
availabilityMessage: "发票正在识别,完成后会自动刷新",
}); });
return; return;
} }
@@ -368,6 +370,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
status: stillPending ? "pending_ocr" : "available", status: stillPending ? "pending_ocr" : "available",
reason: stillPending ? "pending_ocr" : "available", reason: stillPending ? "pending_ocr" : "available",
message: stillPending ? "发票正在识别" : "发票可添加", message: stillPending ? "发票正在识别" : "发票可添加",
availabilityReason: stillPending ? "pending_ocr" : "available",
availabilityMessage: stillPending ? "发票正在识别" : "发票可添加",
invoiceCode: stillPending ? null : "044002600111", invoiceCode: stillPending ? null : "044002600111",
invoiceNumber: stillPending ? null : "12345678", invoiceNumber: stillPending ? null : "12345678",
invoiceDate: stillPending ? null : "2026-05-08", invoiceDate: stillPending ? null : "2026-05-08",
@@ -515,7 +519,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
buffer: Buffer.from("%PDF r111 invoice"), buffer: Buffer.from("%PDF r111 invoice"),
}); });
await page.getByTestId("reimbursement-invoice-upload-submit").click(); 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); expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
@@ -588,8 +592,20 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible(); await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
await invoiceDialog.getByRole("button", { name: "上传" }).click(); 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", name: "北京悦海堂发票.pdf",
mimeType: "application/pdf", mimeType: "application/pdf",
buffer: Buffer.from("%PDF r111 mobile invoice"), buffer: Buffer.from("%PDF r111 mobile invoice"),
+15 -6
查看文件
@@ -104,12 +104,21 @@ LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readine
1. 上传接口是否成功返回 invoice id。 1. 上传接口是否成功返回 invoice id。
2. 发票是否进入 `pending_ocr`。 2. 发票是否进入 `pending_ocr`。
3. OCR job 是否运行。 3. 单票识别任务是否立即入队,周期任务是否只作为兜底。
4. OCR provider 是否为腾讯而不是 mock。 4. OCR job 是否运行。
5. OCR 是否返回结构化字段。 5. OCR provider 是否为腾讯而不是 mock。
6. 查重是否把发票转为不可添加。 6. OCR 是否返回结构化字段。
7. 前端状态查询是否轮询并刷新。 7. 查重是否把发票转为不可添加。
8. 报销详情是否自动添加到明细。 8. 前端事件流或轮询兜底是否触发刷新。
9. 报销详情是否自动添加到明细。
发布前可执行 pending OCR 扫描,定位卡住的票据:
```bash
node scripts/qa/check-pending-ocr.mjs --thresholds=2,5,10
```
脚本只输出票据 ID、上传时间、尝试次数和最后错误摘要,不读取票据文件内容。缺少 `DATABASE_URL` 时应输出 `SKIP`,不视为真实数据库健康检查通过。
## 公网与部署 ## 公网与部署
+11
查看文件
@@ -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 扫描"
1 ID Flow Trigger CurrentFeedback AutoRefresh TerminalStates FailureReason RetryEntry IndustryBenchmark Risk SuggestedIssue Notes
2 AUX-01 报销详情发票识别 员工上传 PDF/图片发票 上传成功后显示单一识别状态,识别完成后自动添加;事件流失败时轮询兜底 fetch-stream/SSE + 3s 轮询兜底 已添加、识别失败、重复、已用于报销 通过 availabilityMessage 展示业务原因 失败发票可重新识别或人工修正 合思/分贝通/每刻均强调票据识别、验真、查重和电子档案 真实腾讯 OCR 耗时和公网链路仍需 live smoke 证据 保留 H10 live OCR gate 本轮已作为主链路优化
3 AUX-02 票据中心发票验真 财务或有权限用户触发验真 详情页显示验真记录,pending 时轮询记录列表 React Query 轮询 pending 验真记录 通过、失败、异常 展示 provider 返回的 resultMessage 或 errorMessage 失败记录可重试验真 国内费控系统通常把识别、验真、查重作为连续可追踪流程 普通员工不应看到过多 provider 细节;财务侧可保留 新增验真 UX 断言 Issue 不在本轮改业务代码
4 AUX-03 支付批次执行 财务创建、复核并执行付款批次 批次页显示状态和操作结果 toast 依赖页面 refetch 和 focused regression 待复核、已复核、执行中、已执行、失败 失败路径由批次路由和回归脚本覆盖 失败后按批次状态重新处理 分贝通/合思强调企业支付、对账、归档闭环 执行中长耗时缺少实时进度和失败明细聚合 新增支付批次执行进度 Issue 需防 maker-checker 权限回归
5 AUX-04 ERP 凭证/主数据同步 付款、报销或主数据变更后进入 outbox 管理页和预览可查看 pending outbox 当前以后台 job 和页面刷新为主 pending、sent、failed、dead-letter outbox 错误摘要可查 管理端可 retry outbox 同类系统强调 OA/HRM/ERP 连接和三流合一 普通业务页对同步中/同步失败提示较弱 新增 ERP 同步状态外显 Issue 外部 ERP live 回执仍是专项
6 AUX-05 用户/主数据导入 管理员上传导入文件 导入接口返回成功/失败摘要 通常一次 HTTP 完成;批量大时缺少任务化反馈 完成、部分失败、失败 行级错误摘要 修正文件后重新导入 国内 OA 常见批量导入需行级错误与可下载失败清单 大批量导入可能阻塞且反馈不连续 新增导入任务化与错误清单 Issue 需先盘点现有导入入口
7 AUX-06 审批处理与移动离线队列 审批人在 PC/移动端通过、驳回、转交 操作后 toast 和列表刷新;移动离线队列有回放机制 页面 refetch;离线队列恢复后重放 已处理、待处理、失败、冲突 权限/状态冲突显示错误 失败后重新提交操作 国内 OA 强调移动审批效率和全程留痕 多人并发和代理审批 live 证据不足 新增审批并发与代理 live smoke Issue 不在本轮改 workflow 状态机
8 AUX-07 附件材料上传 报销草稿或驳回状态上传附件 显示上传结果和附件列表 上传完成后 refetch 详情 已上传、失败、已删除 上传失败显示错误 重新选择文件上传 行业实践区分发票凭证和附件材料 移动弱网下缺分片/重试进度;PDF 与图片入口需清楚 新增附件弱网与视觉断言 Issue 需保持附件不参与发票识别和查重
9 AUX-08 通知与 IM 投递 审批、报销状态变化或系统消息触发 站内通知列表显示未读;外部 IM 依赖配置 前端查询通知;外部投递后台处理 未读、已读、投递失败 后台日志或 outbox 错误 管理端/脚本重试 国内 OA 强调消息可达和待办闭环 真实企业微信消息投递证据不足 新增 IM 投递 live smoke Issue 真实企业微信登录由人工验收
10 AUX-09 预算/费用标准预检 提交报销或采购前触发预检 页面展示预算、标准和风险提示 多为提交前即时计算 通过、提示、阻断 以业务文案展示不合规原因 修改明细后重新预检 费控最佳实践强调事前管控 普通用户对阻断原因和修改建议仍可增强 新增提交前预检解释 Issue 不顺手改当前费用规则
11 AUX-10 公网 live smoke 与发布门禁 部署后执行公网 ready、资源扫描、OCR 样本和网页 smoke 脚本输出 pass 或明确 SKIP 缺项 命令式检查,不依赖页面自动刷新 通过、失败、跳过 缺账号/缺样本/公网异常 补齐环境变量后重跑 上线前必须保留真实链路证据 缺账号或样本时容易误以为已覆盖真实链路 保留 H10 发布门禁 本轮脚本已加入 pending OCR 扫描
@@ -43,9 +43,9 @@
## 票据上传 ## 票据上传
- 拍照上传入口打开摄像头。 - 报销详情“添加发票”上传页应同时提供“拍照或选图片”和“选择 PDF/文件”两个入口。
- 选择图片入口打开相册或文件选择,支持 JPG、PNG、WEBP。 - “拍照或选图片”入口可打开摄像头或相册,支持 JPG、PNG、WEBP。
- 选择 PDF 入口不应直接打开摄像头,应允许选择 PDF 文件。 - “选择 PDF/文件”入口不应直接打开摄像头,应允许选择 PDF 文件;在企业微信里重点记录是否能进入系统文件选择器。
- 上传后显示当前文件名、上传进度或失败原因。 - 上传后显示当前文件名、上传进度或失败原因。
- 网络中断或失败时有明确提示,可重试。 - 网络中断或失败时有明确提示,可重试。
@@ -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""]","面向用户可测改动必须部署后再通知;真实企业微信登录由人工验收。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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保留历史快照。
3 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;不新增数据库迁移。
4 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。
5 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。
6 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"] 解决顶部/中部/底部重复识别提示。
7 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迁移,避免状态机变更风险。
8 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"] 真机文件选择无法完全自动化,保留人工验收项。
9 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"] 只读检查,不修改数据。
10 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"] 审计输出用于后续决策。
11 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"] 面向用户可测改动必须部署后再通知;真实企业微信登录由人工验收。
+78
查看文件
@@ -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);
});
+1 -1
查看文件
@@ -132,7 +132,7 @@ function scanNativeFileInputs(file, source) {
function scanMobileCapturePdfInputs(file, source) { function scanMobileCapturePdfInputs(file, source) {
const normalized = toPosix(path.relative(rootDir, file)); const normalized = toPosix(path.relative(rootDir, file));
if (normalized !== "client/src/pages/MobileCapturePage.tsx") { if (!/client\/src\/pages\/Mobile.*\.tsx$/.test(normalized)) {
return; return;
} }
+3
查看文件
@@ -46,6 +46,9 @@ curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null
echo ">>> [live-readiness] 公网资源文案扫描" echo ">>> [live-readiness] 公网资源文案扫描"
node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt" 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 if has_live_smoke_credentials; then
echo ">>> [live-readiness] 执行公网 live smoke" 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" LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt"
+4
查看文件
@@ -73,8 +73,10 @@ const travelAuthorizationsRoutes = require("./routes/travelAuthorizations");
const travelTransactionsRoutes = require("./routes/travelTransactions"); const travelTransactionsRoutes = require("./routes/travelTransactions");
const travelReconciliationRoutes = require("./routes/travelReconciliation"); const travelReconciliationRoutes = require("./routes/travelReconciliation");
const aiRoutes = require("./routes/ai"); const aiRoutes = require("./routes/ai");
const eventsRoutes = require("./routes/events");
const { startJobs } = require("./jobs/jobSystem"); const { startJobs } = require("./jobs/jobSystem");
const { initMetrics } = require("./observability"); const { initMetrics } = require("./observability");
const { createAppEventBus } = require("./services/events");
const { AppError } = require("./utils/AppError"); const { AppError } = require("./utils/AppError");
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi"); const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
@@ -122,6 +124,7 @@ async function buildApp() {
}); });
app.decorate("config", config); app.decorate("config", config);
app.decorate("appEvents", createAppEventBus());
await fs.mkdir(config.storageDir, { recursive: true }); await fs.mkdir(config.storageDir, { recursive: true });
await app.register(cookie); await app.register(cookie);
@@ -332,6 +335,7 @@ async function buildApp() {
await app.register(authRoutes, { prefix: "/api/auth" }); await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(wecomAuthRoutes, { 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(meRoutes, { prefix: "/api" });
await app.register(reimbursementRoutes, { prefix: "/api" }); await app.register(reimbursementRoutes, { prefix: "/api" });
await app.register(invoicePoolRoutes, { prefix: "/api" }); await app.register(invoicePoolRoutes, { prefix: "/api" });
+66 -10
查看文件
@@ -1,6 +1,7 @@
const cron = require("node-cron"); const cron = require("node-cron");
const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/ocr"); const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/ocr");
const { publishInvoiceUpdated } = require("../services/events");
const { resolveInvoiceDedupBlocker } = require("../services/invoiceGovernance"); const { resolveInvoiceDedupBlocker } = require("../services/invoiceGovernance");
const { readFileBuffer } = require("../services/storage"); const { readFileBuffer } = require("../services/storage");
@@ -70,7 +71,30 @@ async function claimPendingInvoices(prisma, config, workerId) {
ocr_lock_owner = ${workerId} ocr_lock_owner = ${workerId}
FROM candidates FROM candidates
WHERE i.id = candidates.id 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 now = new Date();
const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1; const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1;
const isTerminal = nextAttempts >= config.ocrMaxAttempts; const isTerminal = nextAttempts >= config.ocrMaxAttempts;
const nextStatus = isTerminal ? "ocr_failed" : "pending_ocr";
await prisma.invoicePool.update({ await prisma.invoicePool.update({
where: { id: claimedInvoice.id }, where: { id: claimedInvoice.id },
data: { data: {
status: isTerminal ? "ocr_failed" : "pending_ocr", status: nextStatus,
ocr_provider: getEffectiveOcrProvider(config), ocr_provider: getEffectiveOcrProvider(config),
ocr_attempts: nextAttempts, ocr_attempts: nextAttempts,
ocr_last_error: errorMessage, ocr_last_error: errorMessage,
@@ -239,6 +264,8 @@ async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config)
ocr_lock_owner: null, ocr_lock_owner: null,
}, },
}); });
return nextStatus;
} }
async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, logger) { 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", "OCR invoice marked as failed for missing required fields",
); );
return { outcome: "failed" }; return { outcome: "failed", status: "ocr_failed" };
} }
const duplicate = await resolveInvoiceDedupBlocker( const duplicate = await resolveInvoiceDedupBlocker(
@@ -302,14 +329,14 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
}, },
"OCR invoice marked as duplicate", "OCR invoice marked as duplicate",
); );
return { outcome: "duplicate" }; return { outcome: "duplicate", status: "ocr_failed" };
} }
await markInvoiceAsAvailable(prisma, config, claimedInvoice.id, normalized, ocrResultId); await markInvoiceAsAvailable(prisma, config, claimedInvoice.id, normalized, ocrResultId);
return { outcome: "available" }; return { outcome: "available", status: "available" };
} catch (error) { } catch (error) {
const message = trimErrorMessage(error); const message = trimErrorMessage(error);
await markInvoiceAsFailed(prisma, claimedInvoice, message, config); const status = await markInvoiceAsFailed(prisma, claimedInvoice, message, config);
logger.error( logger.error(
{ {
err: error, err: error,
@@ -320,12 +347,11 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
}, },
"OCR processing failed", "OCR processing failed",
); );
return { outcome: "failed" }; return { outcome: "failed", status };
} }
} }
async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`) { async function processClaimedInvoices(prisma, config, logger, workerId, claimed, options = {}) {
const claimed = await claimPendingInvoices(prisma, config, workerId);
if (!claimed.length) { if (!claimed.length) {
return { claimed: 0, available: 0, duplicate: 0, failed: 0 }; 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") { } else if (result?.outcome === "failed") {
stats.failed += 1; 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; 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) { function startOcrJob(fastify) {
if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) { if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) {
fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled"); fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled");
@@ -359,6 +405,12 @@ function startOcrJob(fastify) {
let running = false; let running = false;
const workerId = `ocr-worker:${process.pid}`; 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 () => { const task = cron.schedule(fastify.config.ocrPollCron, async () => {
if (running) { if (running) {
@@ -367,7 +419,9 @@ function startOcrJob(fastify) {
running = true; running = true;
try { 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?.({ fastify.metrics?.jobRunsTotal?.inc?.({
job: "ocr.tick", job: "ocr.tick",
driver: "cron", driver: "cron",
@@ -423,9 +477,11 @@ function startOcrJob(fastify) {
module.exports = { module.exports = {
OCR_DUPLICATE_ERROR, OCR_DUPLICATE_ERROR,
claimPendingInvoiceById,
claimPendingInvoices, claimPendingInvoices,
getEffectiveOcrProvider, getEffectiveOcrProvider,
markInvoiceAsQualityFailed, markInvoiceAsQualityFailed,
runOcrForInvoiceId,
runOcrOnce, runOcrOnce,
startOcrJob, startOcrJob,
}; };
+91 -3
查看文件
@@ -2,16 +2,18 @@ const PgBoss = require("pg-boss");
const { revokeExpiredDelegations } = require("./delegationRevokeJob"); const { revokeExpiredDelegations } = require("./delegationRevokeJob");
const { archiveUnusedInvoices } = require("./invoiceArchiveJob"); const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
const { runOcrOnce } = require("./ocrJob"); const { runOcrForInvoiceId, runOcrOnce } = require("./ocrJob");
const { runVerificationForRecord } = require("./verificationJob"); const { runVerificationForRecord } = require("./verificationJob");
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob"); const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
const { runErpMasterDataSyncOnce } = require("./erpMasterDataSyncJob"); const { runErpMasterDataSyncOnce } = require("./erpMasterDataSyncJob");
const { publishInvoiceUpdated } = require("../services/events");
const { const {
FEATURE_FLAG_KEYS, FEATURE_FLAG_KEYS,
evaluateFeatureFlag, evaluateFeatureFlag,
} = require("../services/featureFlags"); } = require("../services/featureFlags");
const JOB_OCR_TICK = "ocr.tick"; const JOB_OCR_TICK = "ocr.tick";
const JOB_OCR_INVOICE = "ocr.invoice";
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired"; const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
const JOB_INVOICE_ARCHIVE = "invoice.archive-unused"; const JOB_INVOICE_ARCHIVE = "invoice.archive-unused";
const JOB_INVOICE_VERIFY = "invoice.verify"; 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_TICK);
await boss.createQueue(JOB_OCR_INVOICE);
await boss.createQueue(JOB_DELEGATION_REVOKE); await boss.createQueue(JOB_DELEGATION_REVOKE);
await boss.createQueue(JOB_INVOICE_ARCHIVE); await boss.createQueue(JOB_INVOICE_ARCHIVE);
await boss.createQueue(JOB_INVOICE_VERIFY); await boss.createQueue(JOB_INVOICE_VERIFY);
@@ -51,6 +54,13 @@ async function startPgBossJobs(fastify) {
await boss.createQueue(JOB_ERP_MASTER_DATA_SYNC); 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) => { await boss.work(JOB_DELEGATION_REVOKE, { batchSize: 1 }, async (jobs) => {
const job = jobs?.[0]; const job = jobs?.[0];
try { try {
@@ -122,7 +132,9 @@ async function startPgBossJobs(fastify) {
const job = jobs?.[0]; const job = jobs?.[0];
const workerId = `pgboss:ocr:${process.pid}`; const workerId = `pgboss:ocr:${process.pid}`;
try { 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?.({ fastify.metrics?.jobRunsTotal?.inc?.({
job: JOB_OCR_TICK, job: JOB_OCR_TICK,
driver: "pgboss", 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) => { await boss.work(JOB_INVOICE_VERIFY, { batchSize: 1 }, async (jobs) => {
const job = jobs?.[0]; const job = jobs?.[0];
const recordId = job?.data?.recordId; const recordId = job?.data?.recordId;
@@ -301,7 +374,7 @@ async function startPgBossJobs(fastify) {
await boss.schedule(JOB_OCR_TICK, fastify.config.ocrPollCron, null, { await boss.schedule(JOB_OCR_TICK, fastify.config.ocrPollCron, null, {
tz: fastify.config.appTimezone, tz: fastify.config.appTimezone,
singletonKey: JOB_OCR_TICK, singletonKey: JOB_OCR_TICK,
singletonSeconds: 55, singletonSeconds: 1,
}); });
} else { } else {
await boss.unschedule(JOB_OCR_TICK).catch(() => {}); await boss.unschedule(JOB_OCR_TICK).catch(() => {});
@@ -359,12 +432,27 @@ async function startPgBossJobs(fastify) {
return boss; 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 = { module.exports = {
JOB_OCR_INVOICE,
JOB_OCR_TICK, JOB_OCR_TICK,
JOB_DELEGATION_REVOKE, JOB_DELEGATION_REVOKE,
JOB_INVOICE_ARCHIVE, JOB_INVOICE_ARCHIVE,
JOB_INVOICE_VERIFY, JOB_INVOICE_VERIFY,
JOB_INTEGRATION_OUTBOX, JOB_INTEGRATION_OUTBOX,
JOB_ERP_MASTER_DATA_SYNC, JOB_ERP_MASTER_DATA_SYNC,
enqueueOcrInvoice,
startPgBossJobs, startPgBossJobs,
}; };
+52
查看文件
@@ -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;
+18
查看文件
@@ -10,6 +10,7 @@ const { sha256Hex } = require("../utils/crypto");
const { toMoneyString } = require("../utils/workflow"); const { toMoneyString } = require("../utils/workflow");
const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { findActiveDelegationForDelegate } = require("../utils/delegation");
const { getMissingRequiredOcrFields, isOcrEnabledForType } = require("../services/ocr"); const { getMissingRequiredOcrFields, isOcrEnabledForType } = require("../services/ocr");
const { enqueueOcrInvoice } = require("../jobs/pgBossJobs");
const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification"); const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification");
const { registerInvoicePoolReadRoutes } = require("./invoicePool/read"); const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers"); const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers");
@@ -17,6 +18,7 @@ const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects");
const { writeBuffer } = require("../services/storage"); const { writeBuffer } = require("../services/storage");
const { const {
mapInvoiceUnavailableReason, mapInvoiceUnavailableReason,
resolveInvoiceAvailability,
resolveInvoiceDedupBlocker, resolveInvoiceDedupBlocker,
resolveUploadDedupDecision, resolveUploadDedupDecision,
} = require("../services/invoiceGovernance"); } = require("../services/invoiceGovernance");
@@ -509,6 +511,8 @@ async function invoicePoolRoutes(fastify) {
uploadAction: uploadDedupDecision.action, uploadAction: uploadDedupDecision.action,
unavailableReason: uploadDedupDecision.reason, unavailableReason: uploadDedupDecision.reason,
unavailableMessage: message, unavailableMessage: message,
availabilityReason: uploadDedupDecision.reason,
availabilityMessage: message,
}); });
} }
@@ -570,10 +574,20 @@ async function invoicePoolRoutes(fastify) {
return created; 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, { return sendData(reply, request, {
invoicePoolId: invoice.id, invoicePoolId: invoice.id,
status: invoice.status, status: invoice.status,
fileSha256: invoice.file_sha256, fileSha256: invoice.file_sha256,
uploadAction: "create",
availabilityReason: availability.reason,
availabilityMessage: availability.message,
}); });
}, },
); );
@@ -1231,6 +1245,10 @@ async function invoicePoolRoutes(fastify) {
return updated; 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)); return sendData(reply, request, normalizeInvoiceResponse(invoice));
}, },
); );
+4 -43
查看文件
@@ -6,7 +6,7 @@ const { buildAuditLogData } = require("../../utils/audit");
const { signPayload, verifyToken } = require("../../utils/signedUrl"); const { signPayload, verifyToken } = require("../../utils/signedUrl");
const { openReadStream, StorageNotFoundError } = require("../../services/storage"); const { openReadStream, StorageNotFoundError } = require("../../services/storage");
const { toMoneyString } = require("../../utils/workflow"); const { toMoneyString } = require("../../utils/workflow");
const { mapInvoiceUnavailableReason } = require("../../services/invoiceGovernance"); const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
const { const {
mapInvoiceSummary, mapInvoiceSummary,
normalizeInvoiceResponse, normalizeInvoiceResponse,
@@ -106,48 +106,7 @@ function parseInvoiceStatusQuery(query) {
} }
function resolveInvoiceStatusReason(invoice) { function resolveInvoiceStatusReason(invoice) {
if (invoice.status === "pending_ocr") { return resolveInvoiceAvailability(invoice);
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: "发票可添加",
};
} }
function ensureInvoiceReadableByUser(request, invoice) { function ensureInvoiceReadableByUser(request, invoice) {
@@ -254,6 +213,8 @@ function registerInvoicePoolReadRoutes(fastify) {
status: invoice.status, status: invoice.status,
reason: statusReason.reason, reason: statusReason.reason,
message: statusReason.message, message: statusReason.message,
availabilityReason: statusReason.reason,
availabilityMessage: statusReason.message,
invoiceCode: invoice.invoice_code ?? null, invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null, invoiceNumber: invoice.invoice_number ?? null,
invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
@@ -1,4 +1,5 @@
const { toMoneyString } = require("../../../utils/workflow"); const { toMoneyString } = require("../../../utils/workflow");
const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance");
function mapVerificationRecord(item) { function mapVerificationRecord(item) {
if (!item) { if (!item) {
@@ -26,10 +27,13 @@ function mapInvoiceSummary(item) {
const latestVerification = Array.isArray(item.verification_records) const latestVerification = Array.isArray(item.verification_records)
? item.verification_records[0] ?? null ? item.verification_records[0] ?? null
: null; : null;
const availability = resolveInvoiceAvailability(item);
return { return {
id: item.id, id: item.id,
status: item.status, status: item.status,
availabilityReason: availability.reason,
availabilityMessage: availability.message,
ocrType: item.ocr_type, ocrType: item.ocr_type,
ocrProvider: item.ocr_provider, ocrProvider: item.ocr_provider,
ocrAttempts: item.ocr_attempts, ocrAttempts: item.ocr_attempts,
@@ -71,10 +75,13 @@ function normalizeInvoiceResponse(invoice) {
const latestVerification = Array.isArray(invoice.verification_records) const latestVerification = Array.isArray(invoice.verification_records)
? invoice.verification_records[0] ?? null ? invoice.verification_records[0] ?? null
: null; : null;
const availability = resolveInvoiceAvailability(invoice);
return { return {
id: invoice.id, id: invoice.id,
status: invoice.status, status: invoice.status,
availabilityReason: availability.reason,
availabilityMessage: availability.message,
archivedReason: invoice.archived_reason ?? null, archivedReason: invoice.archived_reason ?? null,
archivedAt: invoice.archived_at?.toISOString?.() ?? null, archivedAt: invoice.archived_at?.toISOString?.() ?? null,
ownerEmployeeId: invoice.owner_employee_id, ownerEmployeeId: invoice.owner_employee_id,
@@ -28,6 +28,7 @@ function buildInvoiceSummarySelect() {
return { return {
id: true, id: true,
status: true, status: true,
archived_reason: true,
ocr_type: true, ocr_type: true,
ocr_provider: true, ocr_provider: true,
ocr_attempts: true, ocr_attempts: true,
+104
查看文件
@@ -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,
};
+95
查看文件
@@ -8,6 +8,14 @@ const INVOICE_UNAVAILABLE_REASONS = {
usedByCompany: "used_by_company", usedByCompany: "used_by_company",
}; };
const OCR_FIELD_LABELS = {
invoiceCode: "发票代码",
invoiceNumber: "发票号码",
invoiceDate: "开票日期",
totalAmount: "总金额",
sellerName: "销售方",
};
function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) { function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) {
const filters = []; const filters = [];
if (fileSha256) { 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 = { module.exports = {
INVOICE_UNAVAILABLE_REASONS, INVOICE_UNAVAILABLE_REASONS,
buildInvoiceDedupFilters, buildInvoiceDedupFilters,
classifyInvoiceConflict, classifyInvoiceConflict,
findInvoiceDedupConflicts, findInvoiceDedupConflicts,
formatInvoiceOcrFailureMessage,
isUsedInvoice, isUsedInvoice,
mapInvoiceUnavailableReason, mapInvoiceUnavailableReason,
parseMissingInvoiceOcrFields,
resolveInvoiceAvailability,
resolveInvoiceDedupBlocker, resolveInvoiceDedupBlocker,
resolveUploadDedupDecision, resolveUploadDedupDecision,
}; };
@@ -1,6 +1,8 @@
const { const {
INVOICE_UNAVAILABLE_REASONS, INVOICE_UNAVAILABLE_REASONS,
classifyInvoiceConflict, classifyInvoiceConflict,
formatInvoiceOcrFailureMessage,
resolveInvoiceAvailability,
resolveUploadDedupDecision, resolveUploadDedupDecision,
} = require("../../src/services/invoiceGovernance"); } = require("../../src/services/invoiceGovernance");
@@ -17,6 +19,7 @@ function buildInvoice(overrides = {}) {
file_sha256: overrides.file_sha256 ?? "sha256-a", file_sha256: overrides.file_sha256 ?? "sha256-a",
invoice_code: overrides.invoice_code ?? null, invoice_code: overrides.invoice_code ?? null,
invoice_number: overrides.invoice_number ?? 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.action).toBe("blocked");
expect(decision.reason).toBe(INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner); 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(
"票据识别失败,请重试或人工补齐关键信息。",
);
});
}); });
+42 -1
查看文件
@@ -2,12 +2,14 @@ const fs = require("node:fs/promises");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const { runOcrOnce } = require("../../../src/jobs/ocrJob"); const { runOcrForInvoiceId, runOcrOnce } = require("../../../src/jobs/ocrJob");
function buildClaimedInvoice(overrides = {}) { function buildClaimedInvoice(overrides = {}) {
return { return {
id: "11111111-1111-4111-8111-111111111111", id: "11111111-1111-4111-8111-111111111111",
file_sha256: "sha256-1", file_sha256: "sha256-1",
owner_employee_id: "employee-owner-1",
uploaded_by_employee_id: "employee-upload-1",
ocr_provider: "mock", ocr_provider: "mock",
ocr_type: "vat", ocr_type: "vat",
storage_path: "20260428/invoice.txt", 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 () => { it("marks invoice ocr_failed when required VAT fields are missing", async () => {
await fs.writeFile( await fs.writeFile(
path.join(tempDir, "20260428/invoice.txt"), path.join(tempDir, "20260428/invoice.txt"),
+56
查看文件
@@ -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();
});
});
+52
查看文件
@@ -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();
}
});
});
@@ -38,6 +38,8 @@ function buildMultipartRequest({ filename, contentType, content, fields = [] })
async function buildApp({ async function buildApp({
ocrProvider = "off", ocrProvider = "off",
jobDriver = "cron",
bossSend = null,
invoiceConflicts = [], invoiceConflicts = [],
statusRows = [], statusRows = [],
permissionCodes = ["invoice.upload"], permissionCodes = ["invoice.upload"],
@@ -75,7 +77,11 @@ async function buildApp({
maxUploadSizeBytes: 5 * 1024 * 1024, maxUploadSizeBytes: 5 * 1024 * 1024,
invoiceArchiveUnusedDays: 30, invoiceArchiveUnusedDays: 30,
ocrProvider, ocrProvider,
jobDriver,
}); });
if (bossSend) {
app.decorate("boss", { send: bossSend });
}
app.decorate("authenticate", async (request) => { app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) { if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); 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 () => { it("puts VAT uploads into pending_ocr and enqueues a directed OCR job when OCR provider is enabled", async () => {
const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock" }); const bossSend = vi.fn(async () => ({ id: "ocr-job-1" }));
const { app, prisma, tempDir } = await buildApp({
ocrProvider: "mock",
jobDriver: "pgboss",
bossSend,
});
try { try {
const requestPayload = buildMultipartRequest({ const requestPayload = buildMultipartRequest({
@@ -214,6 +225,9 @@ describe("invoicePool upload route", () => {
}), }),
}), }),
); );
expect(bossSend).toHaveBeenCalledWith("ocr.invoice", {
invoiceId: "11111111-1111-4111-8111-111111111111",
});
} finally { } finally {
await app.close(); await app.close();
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
@@ -133,6 +133,7 @@ async function buildApp(options = {}) {
], ],
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)), travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
auditLogs: [], auditLogs: [],
sequenceNumber: 0,
}; };
function buildReimbursementSelect(record, select) { function buildReimbursementSelect(record, select) {
@@ -236,6 +237,28 @@ async function buildApp(options = {}) {
return found ? cloneTravelRequest(found) : null; 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: { reimbursementForm: {
create: vi.fn(async ({ data, select }) => { create: vi.fn(async ({ data, select }) => {
const record = { const record = {
@@ -317,6 +340,7 @@ async function buildApp(options = {}) {
expenseTypes: state.expenseTypes.map((item) => ({ ...item })), expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)), travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)),
auditLogs: state.auditLogs.map((item) => ({ ...item })), auditLogs: state.auditLogs.map((item) => ({ ...item })),
sequenceNumber: state.sequenceNumber,
}; };
const tx = buildPrismaLike(txState); const tx = buildPrismaLike(txState);
const result = await callback(tx); const result = await callback(tx);
@@ -327,6 +351,7 @@ async function buildApp(options = {}) {
state.expenseTypes = txState.expenseTypes; state.expenseTypes = txState.expenseTypes;
state.travelRequests = txState.travelRequests; state.travelRequests = txState.travelRequests;
state.auditLogs = txState.auditLogs; state.auditLogs = txState.auditLogs;
state.sequenceNumber = txState.sequenceNumber;
return result; return result;
}); });
@@ -44,6 +44,7 @@ function buildReimbursementRecord(overrides = {}) {
}, },
}, },
], ],
attachments: [],
invoice_pool_items: [ invoice_pool_items: [
{ {
id: "invoice-1", id: "invoice-1",
+67
查看文件
@@ -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"],
}),
);
});
});