OINV1 修正OCR批量小样本轮询

这个提交包含在:
2026-05-23 21:47:22 +08:00
父节点 ce5bdfcc1c
当前提交 724fce0344
共修改 4 个文件,包含 263 行新增和 14 行删除
+194 -1
查看文件
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { expect, type APIRequestContext, type Page } from "@playwright/test";
import type { E2EUser } from "./auth";
@@ -9,6 +10,9 @@ export type LiveInvoiceOcrResult = {
detailText: string;
bannerText: string;
durationMs: number;
uploadMs?: number;
terminalWaitMs?: number;
transport?: "ui" | "api";
invoiceId: string | null;
invoiceCode: string | null;
invoiceNumber: string | null;
@@ -21,6 +25,11 @@ export type SafeLiveInvoiceOcrEvidence = {
alias: string;
status: LiveInvoiceOcrStatus;
durationMs: number;
transport?: "ui" | "api";
timings?: {
uploadMs?: number;
terminalWaitMs?: number;
};
reasonCategory: "available" | "duplicate_or_same_invoice" | "already_used" | "ocr_failed" | "timeout_or_pending" | "unknown";
fieldHits: {
invoiceId: boolean;
@@ -42,6 +51,32 @@ type UploadInvoiceResponse = {
availabilityMessage?: string | null;
};
};
type UploadInvoiceData = NonNullable<UploadInvoiceResponse["data"]>;
type ApiEnvelope<T> = {
data?: T;
error?: {
code?: string;
message?: string;
};
};
type InvoiceStatusResponse = {
items?: InvoiceStatusItem[];
};
type InvoiceStatusItem = {
status?: string | null;
reason?: string | null;
message?: string | null;
availabilityReason?: string | null;
availabilityMessage?: string | null;
invoiceCode?: string | null;
invoiceNumber?: string | null;
invoiceDate?: string | null;
sellerName?: string | null;
totalAmount?: string | null;
};
function extractField(detailText: string, label: string) {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -67,6 +102,11 @@ export function createSafeLiveInvoiceOcrEvidence(result: LiveInvoiceOcrResult, a
alias,
status: result.status,
durationMs: result.durationMs,
transport: result.transport,
timings: {
uploadMs: result.uploadMs,
terminalWaitMs: result.terminalWaitMs,
},
reasonCategory: categorizeLiveInvoiceOcrResult(result),
fieldHits: {
invoiceId: Boolean(result.invoiceId),
@@ -78,6 +118,155 @@ export function createSafeLiveInvoiceOcrEvidence(result: LiveInvoiceOcrResult, a
};
}
async function readApiData<T>(response: { ok(): boolean; status(): number; text(): Promise<string> }, context: string) {
const rawText = await response.text();
const parsed = rawText ? (JSON.parse(rawText) as ApiEnvelope<T>) : {};
if (!response.ok()) {
const code = parsed.error?.code ?? `HTTP_${response.status()}`;
throw new Error(`${context}:${code}`);
}
return parsed.data as T;
}
function mapApiStatusToResultStatus(status: string | null | undefined): LiveInvoiceOcrStatus {
return status === "available" ? "available" : "ocr_failed";
}
function buildApiDetailText(input: {
status?: string | null;
reason?: string | null;
message?: string | null;
invoiceCode?: string | null;
invoiceNumber?: string | null;
invoiceDate?: string | null;
sellerName?: string | null;
totalAmount?: string | null;
}) {
return [
"票据上传结果",
`状态:${input.status ?? "-"}`,
`原因:${input.reason ?? "-"}`,
`提示:${input.message ?? "-"}`,
`发票号:${[input.invoiceCode, input.invoiceNumber].filter(Boolean).join("-") || "-"}`,
`销售方:${input.sellerName ?? "-"}`,
`总金额:${input.totalAmount ?? "-"}`,
`开票日期:${input.invoiceDate ?? "-"}`,
].join("\n");
}
export async function uploadLiveInvoiceViaApiAndWait(
api: APIRequestContext,
invoiceFilePath: string,
alias: string,
timeout = 60_000,
): Promise<LiveInvoiceOcrResult> {
const startedAt = Date.now();
const uploadStartedAt = Date.now();
const uploadResponse = await api.post("/api/invoice-pool/upload", {
multipart: {
ocr_type: "vat",
file: {
name: `${alias}.pdf`,
mimeType: "application/pdf",
buffer: readFileSync(invoiceFilePath),
},
},
});
const uploadMs = Date.now() - uploadStartedAt;
const uploadData = await readApiData<UploadInvoiceData>(uploadResponse, "upload_invoice");
const uploadedInvoiceId = uploadData?.invoicePoolId ?? null;
if (!uploadedInvoiceId) {
const message =
uploadData?.unavailableMessage ??
uploadData?.availabilityMessage ??
uploadData?.unavailableReason ??
uploadData?.availabilityReason ??
"发票上传未生成新票据";
const reason = uploadData?.unavailableReason ?? uploadData?.availabilityReason ?? uploadData?.uploadAction ?? "-";
const detailText = buildApiDetailText({
status: uploadData?.status ?? "ocr_failed",
reason,
message,
});
return {
status: "ocr_failed",
detailText,
bannerText: message,
durationMs: Date.now() - startedAt,
uploadMs,
terminalWaitMs: 0,
transport: "api",
invoiceId: null,
invoiceCode: null,
invoiceNumber: null,
sellerName: null,
totalAmount: null,
invoiceDate: null,
};
}
const pollStartedAt = Date.now();
let latest: InvoiceStatusItem | null = null;
while (Date.now() - pollStartedAt <= timeout) {
const statusResponse = await api.get(`/api/invoice-pool/status?ids=${encodeURIComponent(uploadedInvoiceId)}`);
const statusPayload = await readApiData<InvoiceStatusResponse>(statusResponse, "invoice_status");
latest = statusPayload?.items?.[0] ?? null;
if (latest && ["available", "ocr_failed", "bound", "archived"].includes(String(latest.status ?? ""))) {
const reason = latest.availabilityReason ?? latest.reason ?? null;
const message = latest.availabilityMessage ?? latest.message ?? null;
const detailText = buildApiDetailText({
status: latest.status,
reason,
message,
invoiceCode: latest.invoiceCode,
invoiceNumber: latest.invoiceNumber,
invoiceDate: latest.invoiceDate,
sellerName: latest.sellerName,
totalAmount: latest.totalAmount,
});
return {
status: mapApiStatusToResultStatus(latest.status),
detailText,
bannerText: message ?? "",
durationMs: Date.now() - startedAt,
uploadMs,
terminalWaitMs: Date.now() - pollStartedAt,
transport: "api",
invoiceId: uploadedInvoiceId,
invoiceCode: latest.invoiceCode ?? null,
invoiceNumber: latest.invoiceNumber ?? null,
sellerName: latest.sellerName ?? null,
totalAmount: latest.totalAmount ?? null,
invoiceDate: latest.invoiceDate ?? null,
};
}
await new Promise((resolve) => {
setTimeout(resolve, 1_000);
});
}
const detailText = buildApiDetailText({
status: latest?.status ?? "pending_ocr",
reason: latest?.availabilityReason ?? latest?.reason ?? "timeout_or_pending",
message: latest?.availabilityMessage ?? latest?.message ?? "OCR 仍在处理中",
invoiceCode: latest?.invoiceCode ?? null,
invoiceNumber: latest?.invoiceNumber ?? null,
invoiceDate: latest?.invoiceDate ?? null,
sellerName: latest?.sellerName ?? null,
totalAmount: latest?.totalAmount ?? null,
});
throw new Error(
[
"live 腾讯 OCR API 等待终态超时",
`invoiceIdPresent=${Boolean(uploadedInvoiceId)}`,
`timeoutMs=${timeout}`,
`reasonCategory=${categorizeText(detailText)}`,
`uploadMs=${uploadMs}`,
].join("\n"),
);
}
export function resolveLiveOcrUser(fallback: E2EUser): E2EUser {
const identifier =
process.env.PLAYWRIGHT_OCR_LIVE_IDENTIFIER?.trim() ||
@@ -143,6 +332,9 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
detailText,
bannerText: message,
durationMs: Date.now() - startedAt,
uploadMs: Date.now() - startedAt,
terminalWaitMs: 0,
transport: "ui",
invoiceId: null,
invoiceCode: null,
invoiceNumber: null,
@@ -192,6 +384,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
detailText,
bannerText,
durationMs: Date.now() - startedAt,
transport: "ui",
invoiceId: uploadedInvoiceId ?? extractField(detailText, "票据ID:"),
invoiceCode,
invoiceNumber,