H1-H10 优化OCR实时体验闭环
这个提交包含在:
@@ -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") {
|
||||||
|
|||||||
@@ -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 = {
|
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;
|
||||||
|
|||||||
@@ -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) {
|
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 ?? []))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`,不视为真实数据库健康检查通过。
|
||||||
|
|
||||||
## 公网与部署
|
## 公网与部署
|
||||||
|
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
|
|
||||||
## 票据上传
|
## 票据上传
|
||||||
|
|
||||||
- 拍照上传入口打开摄像头。
|
- 报销详情“添加发票”上传页应同时提供“拍照或选图片”和“选择 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""]","面向用户可测改动必须部署后再通知;真实企业微信登录由人工验收。"
|
||||||
|
@@ -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) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { 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));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
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(
|
||||||
|
"票据识别失败,请重试或人工补齐关键信息。",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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({
|
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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户