H1-H10 优化OCR实时体验闭环
这个提交包含在:
@@ -105,11 +105,11 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (code === "VALID_006") {
|
||||
return "OCR 重试次数已达上限,请稍后人工处理。";
|
||||
return "票据识别重试次数已达上限,请稍后人工处理。";
|
||||
}
|
||||
|
||||
if (code === "VALID_007") {
|
||||
return "OCR 重试过于频繁,请稍后再试。";
|
||||
return "票据识别重试过于频繁,请稍后再试。";
|
||||
}
|
||||
|
||||
if (code === "VALID_001") {
|
||||
@@ -188,7 +188,7 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (details?.ocr === "disabled") {
|
||||
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
|
||||
return "当前环境未开启票据识别,请联系管理员处理。";
|
||||
}
|
||||
|
||||
if (details?.ocr === "missing_required_fields") {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export type InvoiceVerificationRecord = {
|
||||
export type InvoicePoolListItem = {
|
||||
id: string;
|
||||
status: InvoicePoolStatus;
|
||||
availabilityReason: string | null;
|
||||
availabilityMessage: string | null;
|
||||
ocrType: string;
|
||||
ocrProvider: string;
|
||||
ocrAttempts: number;
|
||||
@@ -68,11 +70,15 @@ export type UploadInvoiceResponse = {
|
||||
uploadAction?: "create" | "reuse_existing" | "blocked";
|
||||
unavailableReason?: string | null;
|
||||
unavailableMessage?: string | null;
|
||||
availabilityReason?: string | null;
|
||||
availabilityMessage?: string | null;
|
||||
};
|
||||
|
||||
export type InvoicePoolDetail = {
|
||||
id: string;
|
||||
status: InvoicePoolStatus;
|
||||
availabilityReason: string | null;
|
||||
availabilityMessage: string | null;
|
||||
archivedReason: string | null;
|
||||
archivedAt: string | null;
|
||||
ownerEmployeeId: string;
|
||||
@@ -144,6 +150,8 @@ export type InvoiceStatusItem = {
|
||||
status: InvoicePoolStatus;
|
||||
reason: string;
|
||||
message: string;
|
||||
availabilityReason: string | null;
|
||||
availabilityMessage: string | null;
|
||||
invoiceCode: string | null;
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -29,19 +29,19 @@ export function formatInvoiceOcrFieldLabels(fields: string[]) {
|
||||
export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) {
|
||||
const missingFields = parseMissingInvoiceOcrFields(rawError);
|
||||
if (missingFields.length) {
|
||||
return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
|
||||
return `票据识别缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
|
||||
}
|
||||
|
||||
if (rawError === "duplicate_invoice_code_number") {
|
||||
return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。";
|
||||
return "已识别出相同发票,不能重复添加。";
|
||||
}
|
||||
|
||||
if (rawError === "mock_ocr_failed") {
|
||||
return "OCR 识别失败,请重试或人工补齐关键信息。";
|
||||
return "票据识别失败,请重试或人工补齐关键信息。";
|
||||
}
|
||||
|
||||
if (!rawError) {
|
||||
return "OCR 识别失败,请稍后重试。";
|
||||
return "票据识别失败,请稍后重试。";
|
||||
}
|
||||
|
||||
return rawError;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoicePoolList,
|
||||
fetchInvoiceStatuses,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
type BindableInvoiceSummary,
|
||||
@@ -48,6 +47,7 @@ import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type ItemFormState = {
|
||||
@@ -162,8 +162,9 @@ export function MobileReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const { me, permissions, accessToken } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileDocumentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||||
@@ -234,16 +235,6 @@ export function MobileReimbursementDetailPage() {
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const pendingInvoiceStatusesQuery = useQuery({
|
||||
enabled: pendingOcrInvoiceIds.length > 0,
|
||||
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
|
||||
queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds),
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const invoiceStatusMessage = pendingOcrInvoiceIds.length
|
||||
? `${pendingOcrInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`
|
||||
: invoiceActionMessage;
|
||||
const invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success";
|
||||
const invoiceSearchLower = invoiceSearch.toLowerCase();
|
||||
const bindableLibraryInvoices = useMemo<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({
|
||||
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
|
||||
onSuccess: async () => {
|
||||
@@ -537,10 +557,15 @@ export function MobileReimbursementDetailPage() {
|
||||
await invalidateDetail();
|
||||
await bindableInvoicesQuery.refetch();
|
||||
|
||||
setPendingOcrInvoiceIds(result.pendingIds);
|
||||
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
|
||||
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
|
||||
if (!result.failures.length && fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
if (!result.failures.length) {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
if (fileDocumentInputRef.current) {
|
||||
fileDocumentInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
@@ -617,38 +642,6 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const statuses = pendingInvoiceStatusesQuery.data?.items ?? [];
|
||||
if (!statuses.length || bindInvoicesMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = statuses
|
||||
.filter((item) => item.status === "available")
|
||||
.map((item) => item.id);
|
||||
const finishedIds = new Set(
|
||||
statuses
|
||||
.filter((item) => item.status !== "pending_ocr")
|
||||
.map((item) => item.id),
|
||||
);
|
||||
const failedMessages = statuses
|
||||
.filter((item) => item.status === "ocr_failed" || item.status === "archived")
|
||||
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`);
|
||||
|
||||
if (failedMessages.length) {
|
||||
setInvoiceActionError(failedMessages.join(";"));
|
||||
setInvoiceActionMessage("");
|
||||
}
|
||||
|
||||
if (availableIds.length) {
|
||||
bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId })));
|
||||
}
|
||||
|
||||
if (finishedIds.size) {
|
||||
setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
|
||||
}
|
||||
}, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!detail) {
|
||||
return;
|
||||
@@ -704,6 +697,9 @@ export function MobileReimbursementDetailPage() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
if (fileDocumentInputRef.current) {
|
||||
fileDocumentInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
|
||||
@@ -1145,23 +1141,42 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
{invoicePanel === "upload" ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<label
|
||||
htmlFor="mobile-reimbursement-invoice-upload"
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[30px] text-zinc-700">upload_file</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">
|
||||
{selectedInvoiceFiles.length ? `已选择 ${selectedInvoiceFiles.length} 个文件` : "选择或拍摄发票"}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">支持图片和 PDF,可多选</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label
|
||||
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-[28px] text-zinc-700">photo_camera</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选图片</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">适合手机拍发票</span>
|
||||
</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
|
||||
ref={fileInputRef}
|
||||
id="mobile-reimbursement-invoice-upload"
|
||||
id="mobile-reimbursement-invoice-upload-image"
|
||||
className="sr-only"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
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
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoiceStatuses,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
} from "../api/invoicePool";
|
||||
@@ -54,6 +53,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
@@ -89,10 +89,6 @@ function formatDateTime(value: string | null) {
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
@@ -142,7 +138,7 @@ function resolveErpSyncStatus(
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { permissions, me } = useAuth();
|
||||
const { permissions, me, accessToken } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [actionError, setActionError] = useState("");
|
||||
@@ -501,7 +497,9 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoices({});
|
||||
setSelectedInvoiceFiles([]);
|
||||
setPendingOcrInvoiceIds([]);
|
||||
setPendingOcrInvoiceIds((previous) =>
|
||||
previous.filter((invoiceId) => !succeededIds.includes(invoiceId)),
|
||||
);
|
||||
toast.success(`已添加 ${succeededIds.length} 张发票`);
|
||||
return;
|
||||
}
|
||||
@@ -523,6 +521,33 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleTrackedInvoicesAvailable = useCallback(
|
||||
(invoiceIds: string[]) => {
|
||||
if (!invoiceIds.length) {
|
||||
return;
|
||||
}
|
||||
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
|
||||
},
|
||||
[bindInvoicesMutation],
|
||||
);
|
||||
|
||||
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
|
||||
const message = items
|
||||
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`)
|
||||
.join(";");
|
||||
setInvoiceActionError(message);
|
||||
toast.error("部分发票需要处理");
|
||||
}, []);
|
||||
|
||||
const invoiceOcrTracker = useInvoiceOcrTracker({
|
||||
accessToken,
|
||||
enabled: Boolean(reimbursementId),
|
||||
pendingInvoiceIds: pendingOcrInvoiceIds,
|
||||
setPendingInvoiceIds: setPendingOcrInvoiceIds,
|
||||
onAvailable: handleTrackedInvoicesAvailable,
|
||||
onUnavailable: handleTrackedInvoicesUnavailable,
|
||||
});
|
||||
|
||||
const unbindInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
|
||||
onSuccess: async () => {
|
||||
@@ -584,34 +609,9 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (pendingById.size && Date.now() < deadline) {
|
||||
await sleep(2_000);
|
||||
const invoiceResults = await fetchInvoiceStatuses(Array.from(pendingById.keys()));
|
||||
|
||||
for (const invoice of invoiceResults.items) {
|
||||
if (!invoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (invoice.status === "available") {
|
||||
availableIds.push(invoice.id);
|
||||
pendingById.delete(invoice.id);
|
||||
} else if (invoice.status === "ocr_failed") {
|
||||
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
|
||||
ocrFailures.push(`${fileName}: ${invoice.message || formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
|
||||
pendingById.delete(invoice.id);
|
||||
} else if (invoice.status === "archived" || invoice.status === "bound") {
|
||||
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
|
||||
ocrFailures.push(`${fileName}: ${invoice.message}`);
|
||||
pendingById.delete(invoice.id);
|
||||
}
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
|
||||
}
|
||||
setPendingOcrInvoiceIds((previous) =>
|
||||
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
|
||||
);
|
||||
|
||||
for (const invoiceId of availableIds) {
|
||||
try {
|
||||
@@ -635,30 +635,31 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file));
|
||||
setPendingOcrInvoiceIds(result.pendingIds);
|
||||
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||
await bindableInvoicesQuery.refetch();
|
||||
|
||||
const messages = [
|
||||
const blockingMessages = [
|
||||
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
|
||||
...result.ocrFailures,
|
||||
...result.bindFailures,
|
||||
];
|
||||
if (result.pendingIds.length) {
|
||||
messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`);
|
||||
}
|
||||
|
||||
if (messages.length) {
|
||||
const message = messages.join(";");
|
||||
if (blockingMessages.length) {
|
||||
const message = blockingMessages.join(";");
|
||||
setInvoiceActionError(message);
|
||||
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`);
|
||||
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${blockingMessages.length} 项需处理`);
|
||||
return;
|
||||
}
|
||||
|
||||
setInvoiceActionError("");
|
||||
if (result.pendingIds.length) {
|
||||
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后会自动添加`);
|
||||
return;
|
||||
}
|
||||
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
|
||||
},
|
||||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||||
@@ -1644,7 +1645,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
{pendingOcrInvoiceIds.length ? (
|
||||
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||
{pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。
|
||||
{invoiceOcrTracker.statusMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("OCR 缺少关键字段:发票代码、销售方。");
|
||||
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("票据识别缺少关键字段:发票代码、销售方。");
|
||||
|
||||
await page.getByTestId("invoice-edit-open").click();
|
||||
await page.getByTestId("invoice-edit-set-available").check();
|
||||
|
||||
@@ -354,6 +354,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
uploadAction: "create",
|
||||
unavailableReason: null,
|
||||
unavailableMessage: null,
|
||||
availabilityReason: "pending_ocr",
|
||||
availabilityMessage: "发票正在识别,完成后会自动刷新",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -368,6 +370,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
status: stillPending ? "pending_ocr" : "available",
|
||||
reason: stillPending ? "pending_ocr" : "available",
|
||||
message: stillPending ? "发票正在识别" : "发票可添加",
|
||||
availabilityReason: stillPending ? "pending_ocr" : "available",
|
||||
availabilityMessage: stillPending ? "发票正在识别" : "发票可添加",
|
||||
invoiceCode: stillPending ? null : "044002600111",
|
||||
invoiceNumber: stillPending ? null : "12345678",
|
||||
invoiceDate: stillPending ? null : "2026-05-08",
|
||||
@@ -515,7 +519,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
buffer: Buffer.from("%PDF r111 invoice"),
|
||||
});
|
||||
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
|
||||
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -588,8 +592,20 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
|
||||
|
||||
await invoiceDialog.getByRole("button", { name: "上传" }).click();
|
||||
await expect(invoiceDialog.getByText("拍照或选图片")).toBeVisible();
|
||||
await expect(invoiceDialog.getByText("选择 PDF/文件")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "image/*");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("capture", "environment");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).toHaveAttribute(
|
||||
"accept",
|
||||
/application\/pdf/,
|
||||
);
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).not.toHaveAttribute(
|
||||
"capture",
|
||||
/.+/,
|
||||
);
|
||||
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-document").setInputFiles({
|
||||
name: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF r111 mobile invoice"),
|
||||
|
||||
+15
-6
@@ -104,12 +104,21 @@ LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readine
|
||||
|
||||
1. 上传接口是否成功返回 invoice id。
|
||||
2. 发票是否进入 `pending_ocr`。
|
||||
3. OCR job 是否运行。
|
||||
4. OCR provider 是否为腾讯而不是 mock。
|
||||
5. OCR 是否返回结构化字段。
|
||||
6. 查重是否把发票转为不可添加。
|
||||
7. 前端状态查询是否轮询并刷新。
|
||||
8. 报销详情是否自动添加到明细。
|
||||
3. 单票识别任务是否立即入队,周期任务是否只作为兜底。
|
||||
4. OCR job 是否运行。
|
||||
5. OCR provider 是否为腾讯而不是 mock。
|
||||
6. OCR 是否返回结构化字段。
|
||||
7. 查重是否把发票转为不可添加。
|
||||
8. 前端事件流或轮询兜底是否触发刷新。
|
||||
9. 报销详情是否自动添加到明细。
|
||||
|
||||
发布前可执行 pending OCR 扫描,定位卡住的票据:
|
||||
|
||||
```bash
|
||||
node scripts/qa/check-pending-ocr.mjs --thresholds=2,5,10
|
||||
```
|
||||
|
||||
脚本只输出票据 ID、上传时间、尝试次数和最后错误摘要,不读取票据文件内容。缺少 `DATABASE_URL` 时应输出 `SKIP`,不视为真实数据库健康检查通过。
|
||||
|
||||
## 公网与部署
|
||||
|
||||
|
||||
@@ -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 扫描"
|
||||
|
@@ -43,9 +43,9 @@
|
||||
|
||||
## 票据上传
|
||||
|
||||
- 拍照上传入口打开摄像头。
|
||||
- 选择图片入口打开相册或文件选择,支持 JPG、PNG、WEBP。
|
||||
- 选择 PDF 入口不应直接打开摄像头,应允许选择 PDF 文件。
|
||||
- 报销详情“添加发票”上传页应同时提供“拍照或选图片”和“选择 PDF/文件”两个入口。
|
||||
- “拍照或选图片”入口可打开摄像头或相册,支持 JPG、PNG、WEBP。
|
||||
- “选择 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""]","面向用户可测改动必须部署后再通知;真实企业微信登录由人工验收。"
|
||||
|
@@ -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);
|
||||
});
|
||||
@@ -132,7 +132,7 @@ function scanNativeFileInputs(file, source) {
|
||||
|
||||
function scanMobileCapturePdfInputs(file, source) {
|
||||
const normalized = toPosix(path.relative(rootDir, file));
|
||||
if (normalized !== "client/src/pages/MobileCapturePage.tsx") {
|
||||
if (!/client\/src\/pages\/Mobile.*\.tsx$/.test(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null
|
||||
echo ">>> [live-readiness] 公网资源文案扫描"
|
||||
node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt"
|
||||
|
||||
echo ">>> [live-readiness] pending OCR 扫描"
|
||||
node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 | tee "$REPORT_DIR/pending-ocr.txt"
|
||||
|
||||
if has_live_smoke_credentials; then
|
||||
echo ">>> [live-readiness] 执行公网 live smoke"
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt"
|
||||
|
||||
@@ -73,8 +73,10 @@ const travelAuthorizationsRoutes = require("./routes/travelAuthorizations");
|
||||
const travelTransactionsRoutes = require("./routes/travelTransactions");
|
||||
const travelReconciliationRoutes = require("./routes/travelReconciliation");
|
||||
const aiRoutes = require("./routes/ai");
|
||||
const eventsRoutes = require("./routes/events");
|
||||
const { startJobs } = require("./jobs/jobSystem");
|
||||
const { initMetrics } = require("./observability");
|
||||
const { createAppEventBus } = require("./services/events");
|
||||
const { AppError } = require("./utils/AppError");
|
||||
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
|
||||
|
||||
@@ -122,6 +124,7 @@ async function buildApp() {
|
||||
});
|
||||
|
||||
app.decorate("config", config);
|
||||
app.decorate("appEvents", createAppEventBus());
|
||||
await fs.mkdir(config.storageDir, { recursive: true });
|
||||
|
||||
await app.register(cookie);
|
||||
@@ -332,6 +335,7 @@ async function buildApp() {
|
||||
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
|
||||
await app.register(eventsRoutes, { prefix: "/api" });
|
||||
await app.register(meRoutes, { prefix: "/api" });
|
||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
||||
|
||||
+66
-10
@@ -1,6 +1,7 @@
|
||||
const cron = require("node-cron");
|
||||
|
||||
const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/ocr");
|
||||
const { publishInvoiceUpdated } = require("../services/events");
|
||||
const { resolveInvoiceDedupBlocker } = require("../services/invoiceGovernance");
|
||||
const { readFileBuffer } = require("../services/storage");
|
||||
|
||||
@@ -70,7 +71,30 @@ async function claimPendingInvoices(prisma, config, workerId) {
|
||||
ocr_lock_owner = ${workerId}
|
||||
FROM candidates
|
||||
WHERE i.id = candidates.id
|
||||
RETURNING i.id, i.owner_employee_id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts
|
||||
RETURNING i.id, i.owner_employee_id, i.uploaded_by_employee_id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts
|
||||
`;
|
||||
}
|
||||
|
||||
async function claimPendingInvoiceById(prisma, config, workerId, invoiceId) {
|
||||
const lockTtlSeconds = Math.max(60, Number(config.ocrLockTtlSeconds || 300));
|
||||
|
||||
return prisma.$queryRaw`
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM "invoice_pool"
|
||||
WHERE id = ${invoiceId}::uuid
|
||||
AND status = 'pending_ocr'
|
||||
AND (ocr_locked_at IS NULL OR ocr_locked_at < NOW() - make_interval(secs => ${lockTtlSeconds}))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE "invoice_pool" AS i
|
||||
SET ocr_locked_at = NOW(),
|
||||
ocr_lock_owner = ${workerId}
|
||||
FROM candidates
|
||||
WHERE i.id = candidates.id
|
||||
RETURNING i.id, i.owner_employee_id, i.uploaded_by_employee_id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -226,11 +250,12 @@ async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config)
|
||||
const now = new Date();
|
||||
const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1;
|
||||
const isTerminal = nextAttempts >= config.ocrMaxAttempts;
|
||||
const nextStatus = isTerminal ? "ocr_failed" : "pending_ocr";
|
||||
|
||||
await prisma.invoicePool.update({
|
||||
where: { id: claimedInvoice.id },
|
||||
data: {
|
||||
status: isTerminal ? "ocr_failed" : "pending_ocr",
|
||||
status: nextStatus,
|
||||
ocr_provider: getEffectiveOcrProvider(config),
|
||||
ocr_attempts: nextAttempts,
|
||||
ocr_last_error: errorMessage,
|
||||
@@ -239,6 +264,8 @@ async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config)
|
||||
ocr_lock_owner: null,
|
||||
},
|
||||
});
|
||||
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, logger) {
|
||||
@@ -276,7 +303,7 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
||||
},
|
||||
"OCR invoice marked as failed for missing required fields",
|
||||
);
|
||||
return { outcome: "failed" };
|
||||
return { outcome: "failed", status: "ocr_failed" };
|
||||
}
|
||||
|
||||
const duplicate = await resolveInvoiceDedupBlocker(
|
||||
@@ -302,14 +329,14 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
||||
},
|
||||
"OCR invoice marked as duplicate",
|
||||
);
|
||||
return { outcome: "duplicate" };
|
||||
return { outcome: "duplicate", status: "ocr_failed" };
|
||||
}
|
||||
|
||||
await markInvoiceAsAvailable(prisma, config, claimedInvoice.id, normalized, ocrResultId);
|
||||
return { outcome: "available" };
|
||||
return { outcome: "available", status: "available" };
|
||||
} catch (error) {
|
||||
const message = trimErrorMessage(error);
|
||||
await markInvoiceAsFailed(prisma, claimedInvoice, message, config);
|
||||
const status = await markInvoiceAsFailed(prisma, claimedInvoice, message, config);
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
@@ -320,12 +347,11 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
||||
},
|
||||
"OCR processing failed",
|
||||
);
|
||||
return { outcome: "failed" };
|
||||
return { outcome: "failed", status };
|
||||
}
|
||||
}
|
||||
|
||||
async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`) {
|
||||
const claimed = await claimPendingInvoices(prisma, config, workerId);
|
||||
async function processClaimedInvoices(prisma, config, logger, workerId, claimed, options = {}) {
|
||||
if (!claimed.length) {
|
||||
return { claimed: 0, available: 0, duplicate: 0, failed: 0 };
|
||||
}
|
||||
@@ -341,11 +367,31 @@ async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}
|
||||
} else if (result?.outcome === "failed") {
|
||||
stats.failed += 1;
|
||||
}
|
||||
|
||||
if (typeof options.onInvoiceProcessed === "function") {
|
||||
await options.onInvoiceProcessed({
|
||||
invoiceId: item.id,
|
||||
status: result?.status ?? item.status ?? null,
|
||||
outcome: result?.outcome ?? null,
|
||||
ownerEmployeeId: item.owner_employee_id ?? null,
|
||||
uploadedByEmployeeId: item.uploaded_by_employee_id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`, options = {}) {
|
||||
const claimed = await claimPendingInvoices(prisma, config, workerId);
|
||||
return processClaimedInvoices(prisma, config, logger, workerId, claimed, options);
|
||||
}
|
||||
|
||||
async function runOcrForInvoiceId(prisma, config, logger, invoiceId, workerId = `ocr:${process.pid}`, options = {}) {
|
||||
const claimed = await claimPendingInvoiceById(prisma, config, workerId, invoiceId);
|
||||
return processClaimedInvoices(prisma, config, logger, workerId, claimed, options);
|
||||
}
|
||||
|
||||
function startOcrJob(fastify) {
|
||||
if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) {
|
||||
fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled");
|
||||
@@ -359,6 +405,12 @@ function startOcrJob(fastify) {
|
||||
|
||||
let running = false;
|
||||
const workerId = `ocr-worker:${process.pid}`;
|
||||
const onInvoiceProcessed = (event) =>
|
||||
publishInvoiceUpdated(fastify, {
|
||||
invoiceId: event.invoiceId,
|
||||
status: event.status,
|
||||
audienceEmployeeIds: [event.ownerEmployeeId, event.uploadedByEmployeeId],
|
||||
});
|
||||
|
||||
const task = cron.schedule(fastify.config.ocrPollCron, async () => {
|
||||
if (running) {
|
||||
@@ -367,7 +419,9 @@ function startOcrJob(fastify) {
|
||||
|
||||
running = true;
|
||||
try {
|
||||
const result = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId);
|
||||
const result = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId, {
|
||||
onInvoiceProcessed,
|
||||
});
|
||||
fastify.metrics?.jobRunsTotal?.inc?.({
|
||||
job: "ocr.tick",
|
||||
driver: "cron",
|
||||
@@ -423,9 +477,11 @@ function startOcrJob(fastify) {
|
||||
|
||||
module.exports = {
|
||||
OCR_DUPLICATE_ERROR,
|
||||
claimPendingInvoiceById,
|
||||
claimPendingInvoices,
|
||||
getEffectiveOcrProvider,
|
||||
markInvoiceAsQualityFailed,
|
||||
runOcrForInvoiceId,
|
||||
runOcrOnce,
|
||||
startOcrJob,
|
||||
};
|
||||
|
||||
@@ -2,16 +2,18 @@ const PgBoss = require("pg-boss");
|
||||
|
||||
const { revokeExpiredDelegations } = require("./delegationRevokeJob");
|
||||
const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
||||
const { runOcrOnce } = require("./ocrJob");
|
||||
const { runOcrForInvoiceId, runOcrOnce } = require("./ocrJob");
|
||||
const { runVerificationForRecord } = require("./verificationJob");
|
||||
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
|
||||
const { runErpMasterDataSyncOnce } = require("./erpMasterDataSyncJob");
|
||||
const { publishInvoiceUpdated } = require("../services/events");
|
||||
const {
|
||||
FEATURE_FLAG_KEYS,
|
||||
evaluateFeatureFlag,
|
||||
} = require("../services/featureFlags");
|
||||
|
||||
const JOB_OCR_TICK = "ocr.tick";
|
||||
const JOB_OCR_INVOICE = "ocr.invoice";
|
||||
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
|
||||
const JOB_INVOICE_ARCHIVE = "invoice.archive-unused";
|
||||
const JOB_INVOICE_VERIFY = "invoice.verify";
|
||||
@@ -43,6 +45,7 @@ async function startPgBossJobs(fastify) {
|
||||
});
|
||||
|
||||
await boss.createQueue(JOB_OCR_TICK);
|
||||
await boss.createQueue(JOB_OCR_INVOICE);
|
||||
await boss.createQueue(JOB_DELEGATION_REVOKE);
|
||||
await boss.createQueue(JOB_INVOICE_ARCHIVE);
|
||||
await boss.createQueue(JOB_INVOICE_VERIFY);
|
||||
@@ -51,6 +54,13 @@ async function startPgBossJobs(fastify) {
|
||||
await boss.createQueue(JOB_ERP_MASTER_DATA_SYNC);
|
||||
}
|
||||
|
||||
const onOcrInvoiceProcessed = (event) =>
|
||||
publishInvoiceUpdated(fastify, {
|
||||
invoiceId: event.invoiceId,
|
||||
status: event.status,
|
||||
audienceEmployeeIds: [event.ownerEmployeeId, event.uploadedByEmployeeId],
|
||||
});
|
||||
|
||||
await boss.work(JOB_DELEGATION_REVOKE, { batchSize: 1 }, async (jobs) => {
|
||||
const job = jobs?.[0];
|
||||
try {
|
||||
@@ -122,7 +132,9 @@ async function startPgBossJobs(fastify) {
|
||||
const job = jobs?.[0];
|
||||
const workerId = `pgboss:ocr:${process.pid}`;
|
||||
try {
|
||||
const stats = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId);
|
||||
const stats = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId, {
|
||||
onInvoiceProcessed: onOcrInvoiceProcessed,
|
||||
});
|
||||
fastify.metrics?.jobRunsTotal?.inc?.({
|
||||
job: JOB_OCR_TICK,
|
||||
driver: "pgboss",
|
||||
@@ -164,6 +176,67 @@ async function startPgBossJobs(fastify) {
|
||||
}
|
||||
});
|
||||
|
||||
await boss.work(JOB_OCR_INVOICE, { batchSize: 1 }, async (jobs) => {
|
||||
if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs?.[0];
|
||||
const invoiceId = job?.data?.invoiceId;
|
||||
if (!invoiceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workerId = `pgboss:ocr-invoice:${process.pid}`;
|
||||
try {
|
||||
const stats = await runOcrForInvoiceId(
|
||||
fastify.prisma,
|
||||
fastify.config,
|
||||
fastify.log,
|
||||
invoiceId,
|
||||
workerId,
|
||||
{ onInvoiceProcessed: onOcrInvoiceProcessed },
|
||||
);
|
||||
fastify.metrics?.jobRunsTotal?.inc?.({
|
||||
job: JOB_OCR_INVOICE,
|
||||
driver: "pgboss",
|
||||
outcome: "success",
|
||||
});
|
||||
if (stats.available > 0) {
|
||||
fastify.metrics?.jobProcessedTotal?.inc?.(
|
||||
{ job: JOB_OCR_INVOICE, driver: "pgboss", kind: "available" },
|
||||
stats.available,
|
||||
);
|
||||
}
|
||||
if (stats.duplicate > 0) {
|
||||
fastify.metrics?.jobProcessedTotal?.inc?.(
|
||||
{ job: JOB_OCR_INVOICE, driver: "pgboss", kind: "duplicate" },
|
||||
stats.duplicate,
|
||||
);
|
||||
}
|
||||
if (stats.failed > 0) {
|
||||
fastify.metrics?.jobProcessedTotal?.inc?.(
|
||||
{ job: JOB_OCR_INVOICE, driver: "pgboss", kind: "failed" },
|
||||
stats.failed,
|
||||
);
|
||||
}
|
||||
if (stats.claimed > 0) {
|
||||
fastify.log.info(
|
||||
{ jobId: job?.id, invoiceId, workerId, ...stats },
|
||||
"pg-boss OCR invoice job processed",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.metrics?.jobRunsTotal?.inc?.({
|
||||
job: JOB_OCR_INVOICE,
|
||||
driver: "pgboss",
|
||||
outcome: "failure",
|
||||
});
|
||||
fastify.log.error({ err: error, jobId: job?.id, invoiceId }, "pg-boss OCR invoice job failed");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await boss.work(JOB_INVOICE_VERIFY, { batchSize: 1 }, async (jobs) => {
|
||||
const job = jobs?.[0];
|
||||
const recordId = job?.data?.recordId;
|
||||
@@ -301,7 +374,7 @@ async function startPgBossJobs(fastify) {
|
||||
await boss.schedule(JOB_OCR_TICK, fastify.config.ocrPollCron, null, {
|
||||
tz: fastify.config.appTimezone,
|
||||
singletonKey: JOB_OCR_TICK,
|
||||
singletonSeconds: 55,
|
||||
singletonSeconds: 1,
|
||||
});
|
||||
} else {
|
||||
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
|
||||
@@ -359,12 +432,27 @@ async function startPgBossJobs(fastify) {
|
||||
return boss;
|
||||
}
|
||||
|
||||
async function enqueueOcrInvoice(fastify, invoiceId) {
|
||||
if (
|
||||
fastify?.config?.jobDriver === "pgboss" &&
|
||||
fastify.boss &&
|
||||
["tencent", "mock"].includes(fastify.config.ocrProvider)
|
||||
) {
|
||||
await fastify.boss.send(JOB_OCR_INVOICE, { invoiceId });
|
||||
return { mode: "queued" };
|
||||
}
|
||||
|
||||
return { mode: "fallback" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
JOB_OCR_INVOICE,
|
||||
JOB_OCR_TICK,
|
||||
JOB_DELEGATION_REVOKE,
|
||||
JOB_INVOICE_ARCHIVE,
|
||||
JOB_INVOICE_VERIFY,
|
||||
JOB_INTEGRATION_OUTBOX,
|
||||
JOB_ERP_MASTER_DATA_SYNC,
|
||||
enqueueOcrInvoice,
|
||||
startPgBossJobs,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -10,6 +10,7 @@ const { sha256Hex } = require("../utils/crypto");
|
||||
const { toMoneyString } = require("../utils/workflow");
|
||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||
const { getMissingRequiredOcrFields, isOcrEnabledForType } = require("../services/ocr");
|
||||
const { enqueueOcrInvoice } = require("../jobs/pgBossJobs");
|
||||
const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification");
|
||||
const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
|
||||
const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers");
|
||||
@@ -17,6 +18,7 @@ const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects");
|
||||
const { writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
mapInvoiceUnavailableReason,
|
||||
resolveInvoiceAvailability,
|
||||
resolveInvoiceDedupBlocker,
|
||||
resolveUploadDedupDecision,
|
||||
} = require("../services/invoiceGovernance");
|
||||
@@ -509,6 +511,8 @@ async function invoicePoolRoutes(fastify) {
|
||||
uploadAction: uploadDedupDecision.action,
|
||||
unavailableReason: uploadDedupDecision.reason,
|
||||
unavailableMessage: message,
|
||||
availabilityReason: uploadDedupDecision.reason,
|
||||
availabilityMessage: message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -570,10 +574,20 @@ async function invoicePoolRoutes(fastify) {
|
||||
return created;
|
||||
});
|
||||
|
||||
if (invoice.status === "pending_ocr") {
|
||||
enqueueOcrInvoice(fastify, invoice.id).catch((error) => {
|
||||
request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue OCR invoice job");
|
||||
});
|
||||
}
|
||||
|
||||
const availability = resolveInvoiceAvailability(invoice);
|
||||
return sendData(reply, request, {
|
||||
invoicePoolId: invoice.id,
|
||||
status: invoice.status,
|
||||
fileSha256: invoice.file_sha256,
|
||||
uploadAction: "create",
|
||||
availabilityReason: availability.reason,
|
||||
availabilityMessage: availability.message,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1231,6 +1245,10 @@ async function invoicePoolRoutes(fastify) {
|
||||
return updated;
|
||||
});
|
||||
|
||||
enqueueOcrInvoice(fastify, invoice.id).catch((error) => {
|
||||
request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue retry OCR invoice job");
|
||||
});
|
||||
|
||||
return sendData(reply, request, normalizeInvoiceResponse(invoice));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { signPayload, verifyToken } = require("../../utils/signedUrl");
|
||||
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
|
||||
const { toMoneyString } = require("../../utils/workflow");
|
||||
const { mapInvoiceUnavailableReason } = require("../../services/invoiceGovernance");
|
||||
const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
|
||||
const {
|
||||
mapInvoiceSummary,
|
||||
normalizeInvoiceResponse,
|
||||
@@ -106,48 +106,7 @@ function parseInvoiceStatusQuery(query) {
|
||||
}
|
||||
|
||||
function resolveInvoiceStatusReason(invoice) {
|
||||
if (invoice.status === "pending_ocr") {
|
||||
return {
|
||||
reason: "pending_ocr",
|
||||
message: "发票正在识别,请稍后刷新",
|
||||
};
|
||||
}
|
||||
|
||||
if (invoice.status === "ocr_failed") {
|
||||
const rawError = String(invoice.ocr_last_error ?? "");
|
||||
if (rawError === "duplicate_invoice_code_number" || rawError.includes("duplicate_invoice")) {
|
||||
return {
|
||||
reason: "duplicate_invoice_code_number",
|
||||
message: mapInvoiceUnavailableReason("used_by_company") ?? "发票重复,不能添加",
|
||||
};
|
||||
}
|
||||
return {
|
||||
reason: "ocr_failed",
|
||||
message: rawError || "发票识别失败,请重新识别或手动处理",
|
||||
};
|
||||
}
|
||||
|
||||
if (invoice.status === "bound") {
|
||||
return {
|
||||
reason: "already_added",
|
||||
message: "发票已添加到报销单",
|
||||
};
|
||||
}
|
||||
|
||||
if (invoice.status === "archived") {
|
||||
return {
|
||||
reason: invoice.archived_reason === "used_complete" ? "used_by_company" : "archived",
|
||||
message:
|
||||
invoice.archived_reason === "used_complete"
|
||||
? "该发票已用于报销,不能重复添加"
|
||||
: "发票已归档,不可添加",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reason: "available",
|
||||
message: "发票可添加",
|
||||
};
|
||||
return resolveInvoiceAvailability(invoice);
|
||||
}
|
||||
|
||||
function ensureInvoiceReadableByUser(request, invoice) {
|
||||
@@ -254,6 +213,8 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
status: invoice.status,
|
||||
reason: statusReason.reason,
|
||||
message: statusReason.message,
|
||||
availabilityReason: statusReason.reason,
|
||||
availabilityMessage: statusReason.message,
|
||||
invoiceCode: invoice.invoice_code ?? null,
|
||||
invoiceNumber: invoice.invoice_number ?? null,
|
||||
invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { toMoneyString } = require("../../../utils/workflow");
|
||||
const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance");
|
||||
|
||||
function mapVerificationRecord(item) {
|
||||
if (!item) {
|
||||
@@ -26,10 +27,13 @@ function mapInvoiceSummary(item) {
|
||||
const latestVerification = Array.isArray(item.verification_records)
|
||||
? item.verification_records[0] ?? null
|
||||
: null;
|
||||
const availability = resolveInvoiceAvailability(item);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
availabilityReason: availability.reason,
|
||||
availabilityMessage: availability.message,
|
||||
ocrType: item.ocr_type,
|
||||
ocrProvider: item.ocr_provider,
|
||||
ocrAttempts: item.ocr_attempts,
|
||||
@@ -71,10 +75,13 @@ function normalizeInvoiceResponse(invoice) {
|
||||
const latestVerification = Array.isArray(invoice.verification_records)
|
||||
? invoice.verification_records[0] ?? null
|
||||
: null;
|
||||
const availability = resolveInvoiceAvailability(invoice);
|
||||
|
||||
return {
|
||||
id: invoice.id,
|
||||
status: invoice.status,
|
||||
availabilityReason: availability.reason,
|
||||
availabilityMessage: availability.message,
|
||||
archivedReason: invoice.archived_reason ?? null,
|
||||
archivedAt: invoice.archived_at?.toISOString?.() ?? null,
|
||||
ownerEmployeeId: invoice.owner_employee_id,
|
||||
|
||||
@@ -28,6 +28,7 @@ function buildInvoiceSummarySelect() {
|
||||
return {
|
||||
id: true,
|
||||
status: true,
|
||||
archived_reason: true,
|
||||
ocr_type: true,
|
||||
ocr_provider: true,
|
||||
ocr_attempts: true,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -8,6 +8,14 @@ const INVOICE_UNAVAILABLE_REASONS = {
|
||||
usedByCompany: "used_by_company",
|
||||
};
|
||||
|
||||
const OCR_FIELD_LABELS = {
|
||||
invoiceCode: "发票代码",
|
||||
invoiceNumber: "发票号码",
|
||||
invoiceDate: "开票日期",
|
||||
totalAmount: "总金额",
|
||||
sellerName: "销售方",
|
||||
};
|
||||
|
||||
function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) {
|
||||
const filters = [];
|
||||
if (fileSha256) {
|
||||
@@ -182,13 +190,100 @@ function mapInvoiceUnavailableReason(reason) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseMissingInvoiceOcrFields(rawError) {
|
||||
if (typeof rawError !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matched = rawError.match(/^missing_required_fields:(.+)$/);
|
||||
if (!matched?.[1]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return matched[1]
|
||||
.split(",")
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatInvoiceOcrFailureMessage(rawError) {
|
||||
const missingFields = parseMissingInvoiceOcrFields(rawError);
|
||||
if (missingFields.length) {
|
||||
const labels = missingFields.map((field) => OCR_FIELD_LABELS[field] ?? field);
|
||||
return `票据识别缺少关键字段:${labels.join("、")}。`;
|
||||
}
|
||||
|
||||
if (rawError === "mock_ocr_failed") {
|
||||
return "票据识别失败,请重试或人工补齐关键信息。";
|
||||
}
|
||||
|
||||
if (!rawError) {
|
||||
return "票据识别失败,请稍后重试。";
|
||||
}
|
||||
|
||||
return "票据识别失败,请重新识别或手动处理。";
|
||||
}
|
||||
|
||||
function resolveInvoiceAvailability(invoice) {
|
||||
if (!invoice) {
|
||||
return {
|
||||
reason: "not_found",
|
||||
message: "未找到发票",
|
||||
};
|
||||
}
|
||||
|
||||
if (invoice.status === "pending_ocr") {
|
||||
return {
|
||||
reason: "pending_ocr",
|
||||
message: "发票正在识别,完成后会自动刷新",
|
||||
};
|
||||
}
|
||||
|
||||
if (invoice.status === "ocr_failed") {
|
||||
const rawError = String(invoice.ocr_last_error ?? "");
|
||||
if (rawError === "duplicate_invoice_code_number" || rawError.includes("duplicate_invoice")) {
|
||||
return {
|
||||
reason: "duplicate_invoice",
|
||||
message: "已识别出相同发票,不能重复添加",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reason: "ocr_failed",
|
||||
message: formatInvoiceOcrFailureMessage(rawError),
|
||||
};
|
||||
}
|
||||
|
||||
if (isUsedInvoice(invoice)) {
|
||||
return {
|
||||
reason: INVOICE_UNAVAILABLE_REASONS.usedByCompany,
|
||||
message: mapInvoiceUnavailableReason(INVOICE_UNAVAILABLE_REASONS.usedByCompany),
|
||||
};
|
||||
}
|
||||
|
||||
if (invoice.status === "archived") {
|
||||
return {
|
||||
reason: "archived",
|
||||
message: "发票已归档,不可添加",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reason: "available",
|
||||
message: "发票可添加",
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INVOICE_UNAVAILABLE_REASONS,
|
||||
buildInvoiceDedupFilters,
|
||||
classifyInvoiceConflict,
|
||||
findInvoiceDedupConflicts,
|
||||
formatInvoiceOcrFailureMessage,
|
||||
isUsedInvoice,
|
||||
mapInvoiceUnavailableReason,
|
||||
parseMissingInvoiceOcrFields,
|
||||
resolveInvoiceAvailability,
|
||||
resolveInvoiceDedupBlocker,
|
||||
resolveUploadDedupDecision,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const {
|
||||
INVOICE_UNAVAILABLE_REASONS,
|
||||
classifyInvoiceConflict,
|
||||
formatInvoiceOcrFailureMessage,
|
||||
resolveInvoiceAvailability,
|
||||
resolveUploadDedupDecision,
|
||||
} = require("../../src/services/invoiceGovernance");
|
||||
|
||||
@@ -17,6 +19,7 @@ function buildInvoice(overrides = {}) {
|
||||
file_sha256: overrides.file_sha256 ?? "sha256-a",
|
||||
invoice_code: overrides.invoice_code ?? null,
|
||||
invoice_number: overrides.invoice_number ?? null,
|
||||
ocr_last_error: overrides.ocr_last_error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,4 +93,34 @@ describe("invoiceGovernance dedup", () => {
|
||||
expect(decision.action).toBe("blocked");
|
||||
expect(decision.reason).toBe(INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner);
|
||||
});
|
||||
|
||||
it("formats invoice availability without leaking raw OCR error codes", () => {
|
||||
expect(
|
||||
resolveInvoiceAvailability(
|
||||
buildInvoice({
|
||||
status: "ocr_failed",
|
||||
ocr_last_error: "missing_required_fields:invoiceCode,sellerName",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
reason: "ocr_failed",
|
||||
message: "票据识别缺少关键字段:发票代码、销售方。",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveInvoiceAvailability(
|
||||
buildInvoice({
|
||||
status: "ocr_failed",
|
||||
ocr_last_error: "duplicate_invoice_code_number",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
reason: "duplicate_invoice",
|
||||
message: "已识别出相同发票,不能重复添加",
|
||||
});
|
||||
|
||||
expect(formatInvoiceOcrFailureMessage("mock_ocr_failed")).toBe(
|
||||
"票据识别失败,请重试或人工补齐关键信息。",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { runOcrOnce } = require("../../../src/jobs/ocrJob");
|
||||
const { runOcrForInvoiceId, runOcrOnce } = require("../../../src/jobs/ocrJob");
|
||||
|
||||
function buildClaimedInvoice(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
file_sha256: "sha256-1",
|
||||
owner_employee_id: "employee-owner-1",
|
||||
uploaded_by_employee_id: "employee-upload-1",
|
||||
ocr_provider: "mock",
|
||||
ocr_type: "vat",
|
||||
storage_path: "20260428/invoice.txt",
|
||||
@@ -91,6 +93,45 @@ describe("ocrJob quality gate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("processes a directed invoice and emits processed metadata", async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "20260428/invoice.txt"),
|
||||
"MOCK_DIRECTED_SUCCESS_R73",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const prisma = buildPrisma([buildClaimedInvoice()]);
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const onInvoiceProcessed = vi.fn();
|
||||
|
||||
const result = await runOcrForInvoiceId(
|
||||
prisma,
|
||||
{
|
||||
storageDir: tempDir,
|
||||
ocrBatchSize: 10,
|
||||
ocrLockTtlSeconds: 300,
|
||||
ocrMaxAttempts: 3,
|
||||
ocrProvider: "mock",
|
||||
},
|
||||
logger,
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"ocr-directed-worker",
|
||||
{ onInvoiceProcessed },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 });
|
||||
expect(onInvoiceProcessed).toHaveBeenCalledWith({
|
||||
invoiceId: "11111111-1111-4111-8111-111111111111",
|
||||
status: "available",
|
||||
outcome: "available",
|
||||
ownerEmployeeId: "employee-owner-1",
|
||||
uploadedByEmployeeId: "employee-upload-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks invoice ocr_failed when required VAT fields are missing", async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "20260428/invoice.txt"),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
ocrProvider = "off",
|
||||
jobDriver = "cron",
|
||||
bossSend = null,
|
||||
invoiceConflicts = [],
|
||||
statusRows = [],
|
||||
permissionCodes = ["invoice.upload"],
|
||||
@@ -75,7 +77,11 @@ async function buildApp({
|
||||
maxUploadSizeBytes: 5 * 1024 * 1024,
|
||||
invoiceArchiveUnusedDays: 30,
|
||||
ocrProvider,
|
||||
jobDriver,
|
||||
});
|
||||
if (bossSend) {
|
||||
app.decorate("boss", { send: bossSend });
|
||||
}
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
@@ -177,8 +183,13 @@ describe("invoicePool upload route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("puts VAT uploads into pending_ocr when OCR provider is enabled", async () => {
|
||||
const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock" });
|
||||
it("puts VAT uploads into pending_ocr and enqueues a directed OCR job when OCR provider is enabled", async () => {
|
||||
const bossSend = vi.fn(async () => ({ id: "ocr-job-1" }));
|
||||
const { app, prisma, tempDir } = await buildApp({
|
||||
ocrProvider: "mock",
|
||||
jobDriver: "pgboss",
|
||||
bossSend,
|
||||
});
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
@@ -214,6 +225,9 @@ describe("invoicePool upload route", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(bossSend).toHaveBeenCalledWith("ocr.invoice", {
|
||||
invoiceId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -133,6 +133,7 @@ async function buildApp(options = {}) {
|
||||
],
|
||||
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
|
||||
auditLogs: [],
|
||||
sequenceNumber: 0,
|
||||
};
|
||||
|
||||
function buildReimbursementSelect(record, select) {
|
||||
@@ -236,6 +237,28 @@ async function buildApp(options = {}) {
|
||||
return found ? cloneTravelRequest(found) : null;
|
||||
}),
|
||||
},
|
||||
documentSequence: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
upsert: vi.fn(async ({ create, update }) => {
|
||||
stateRef.sequenceNumber = (stateRef.sequenceNumber ?? 0) + 1;
|
||||
return {
|
||||
document_type: create.document_type,
|
||||
prefix: update.prefix ?? create.prefix,
|
||||
date_format: update.date_format ?? create.date_format,
|
||||
separator: update.separator ?? create.separator,
|
||||
number_length: update.number_length ?? create.number_length,
|
||||
reset_period: update.reset_period ?? create.reset_period,
|
||||
current_number: stateRef.sequenceNumber,
|
||||
last_reset_date: null,
|
||||
updated_at: new Date("2026-04-08T12:00:00.000Z"),
|
||||
updated_by: update.updated_by ?? create.updated_by ?? null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
documentCategory: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
reimbursementForm: {
|
||||
create: vi.fn(async ({ data, select }) => {
|
||||
const record = {
|
||||
@@ -317,6 +340,7 @@ async function buildApp(options = {}) {
|
||||
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
|
||||
travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)),
|
||||
auditLogs: state.auditLogs.map((item) => ({ ...item })),
|
||||
sequenceNumber: state.sequenceNumber,
|
||||
};
|
||||
const tx = buildPrismaLike(txState);
|
||||
const result = await callback(tx);
|
||||
@@ -327,6 +351,7 @@ async function buildApp(options = {}) {
|
||||
state.expenseTypes = txState.expenseTypes;
|
||||
state.travelRequests = txState.travelRequests;
|
||||
state.auditLogs = txState.auditLogs;
|
||||
state.sequenceNumber = txState.sequenceNumber;
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ function buildReimbursementRecord(overrides = {}) {
|
||||
},
|
||||
},
|
||||
],
|
||||
attachments: [],
|
||||
invoice_pool_items: [
|
||||
{
|
||||
id: "invoice-1",
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户