OINV1 修正OCR批量小样本轮询
这个提交包含在:
@@ -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,
|
||||
|
||||
@@ -2,12 +2,13 @@ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "nod
|
||||
import path from "node:path";
|
||||
|
||||
import { expect, test } from "../helpers/fixtures";
|
||||
import { EMPLOYEE, login } from "../helpers/auth";
|
||||
import { createAuthenticatedApiClient, EMPLOYEE, login, type AuthenticatedApiClient } from "../helpers/auth";
|
||||
import {
|
||||
createSafeLiveInvoiceOcrEvidence,
|
||||
openInvoicePool,
|
||||
resolveLiveOcrUser,
|
||||
uploadLiveInvoiceAndWait,
|
||||
uploadLiveInvoiceViaApiAndWait,
|
||||
type SafeLiveInvoiceOcrEvidence,
|
||||
} from "../helpers/liveInvoiceOcr";
|
||||
|
||||
@@ -16,12 +17,23 @@ type BatchSample = {
|
||||
alias: string;
|
||||
};
|
||||
|
||||
type BatchCoverage = {
|
||||
hasAvailable: boolean;
|
||||
hasDuplicateOrUsed: boolean;
|
||||
};
|
||||
|
||||
const rootDir = path.resolve(process.cwd(), "..");
|
||||
const reportDir = process.env.PLAYWRIGHT_OCR_BATCH_REPORT_DIR?.trim()
|
||||
|| path.join(rootDir, "artifacts/qa-flow", `live-ocr-batch-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`);
|
||||
const markdownReportPath = path.join(reportDir, "summary.md");
|
||||
const jsonReportPath = path.join(reportDir, "summary.json");
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number | null = null) {
|
||||
if (!value?.trim()) return fallback;
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveInvoiceFiles() {
|
||||
const files = new Set<string>();
|
||||
const directFile = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim();
|
||||
@@ -49,13 +61,26 @@ function resolveInvoiceFiles() {
|
||||
return [...files]
|
||||
.filter((filePath) => existsSync(filePath))
|
||||
.sort((left, right) => left.localeCompare(right, "zh-CN"))
|
||||
.slice(0, parsePositiveInteger(process.env.PLAYWRIGHT_OCR_BATCH_MAX_SAMPLES, Number.POSITIVE_INFINITY) ?? undefined)
|
||||
.map((filePath, index): BatchSample => ({
|
||||
filePath,
|
||||
alias: `SAMPLE-PDF-${String(index + 1).padStart(3, "0")}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function writeReport(results: SafeLiveInvoiceOcrEvidence[]) {
|
||||
function getCoverage(results: SafeLiveInvoiceOcrEvidence[]): BatchCoverage {
|
||||
return {
|
||||
hasAvailable: results.some((item) => item.status === "available"),
|
||||
hasDuplicateOrUsed: results.some((item) => ["duplicate_or_same_invoice", "already_used"].includes(item.reasonCategory)),
|
||||
};
|
||||
}
|
||||
|
||||
function isCoverageSatisfied(results: SafeLiveInvoiceOcrEvidence[]) {
|
||||
const coverage = getCoverage(results);
|
||||
return coverage.hasAvailable && coverage.hasDuplicateOrUsed;
|
||||
}
|
||||
|
||||
function writeReport(results: SafeLiveInvoiceOcrEvidence[], options: { plannedSamples: number; stopReason: string }) {
|
||||
mkdirSync(reportDir, { recursive: true });
|
||||
const counts = {
|
||||
total: results.length,
|
||||
@@ -76,6 +101,9 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) {
|
||||
const summary = {
|
||||
mode: "live-ocr-batch-redacted",
|
||||
checkedAt: new Date().toISOString(),
|
||||
plannedSamples: options.plannedSamples,
|
||||
stopReason: options.stopReason,
|
||||
coverage: getCoverage(results),
|
||||
counts,
|
||||
fieldHitCounts,
|
||||
privacy: "redacted aliases and boolean field hits only; no invoice number, seller, amount, full filename, full path, raw OCR payload, invoice image, token, or password",
|
||||
@@ -89,6 +117,8 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) {
|
||||
"",
|
||||
"## 结果",
|
||||
"",
|
||||
`- 计划样本数:${options.plannedSamples}`,
|
||||
`- 停止原因:${options.stopReason}`,
|
||||
`- 总文件数:${counts.total}`,
|
||||
`- 可用:${counts.available}`,
|
||||
`- 识别失败:${counts.ocrFailed}`,
|
||||
@@ -99,10 +129,10 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) {
|
||||
"",
|
||||
"## 明细",
|
||||
"",
|
||||
"| 样本 | 状态 | 耗时(ms) | 原因分类 | 票据ID命中 | 发票号命中 | 销售方命中 | 金额命中 | 日期命中 |",
|
||||
"| --- | --- | ---: | --- | --- | --- | --- | --- | --- |",
|
||||
"| 样本 | 通道 | 状态 | 总耗时(ms) | 上传(ms) | 终态等待(ms) | 原因分类 | 票据ID命中 | 发票号命中 | 销售方命中 | 金额命中 | 日期命中 |",
|
||||
"| --- | --- | --- | ---: | ---: | ---: | --- | --- | --- | --- | --- | --- |",
|
||||
...results.map((item) => (
|
||||
`| ${item.alias} | ${item.status} | ${item.durationMs} | ${item.reasonCategory} | ${item.fieldHits.invoiceId ? "Y" : "N"} | ${item.fieldHits.invoiceCodeOrNumber ? "Y" : "N"} | ${item.fieldHits.sellerName ? "Y" : "N"} | ${item.fieldHits.totalAmount ? "Y" : "N"} | ${item.fieldHits.invoiceDate ? "Y" : "N"} |`
|
||||
`| ${item.alias} | ${item.transport ?? "-"} | ${item.status} | ${item.durationMs} | ${item.timings?.uploadMs ?? "-"} | ${item.timings?.terminalWaitMs ?? "-"} | ${item.reasonCategory} | ${item.fieldHits.invoiceId ? "Y" : "N"} | ${item.fieldHits.invoiceCodeOrNumber ? "Y" : "N"} | ${item.fieldHits.sellerName ? "Y" : "N"} | ${item.fieldHits.totalAmount ? "Y" : "N"} | ${item.fieldHits.invoiceDate ? "Y" : "N"} |`
|
||||
)),
|
||||
"",
|
||||
"## 说明",
|
||||
@@ -111,6 +141,8 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) {
|
||||
"- 本报告只记录样本别名、状态、耗时、字段命中布尔值和原因分类。",
|
||||
"- 本报告不得记录发票号码、销售方、金额、完整文件名、完整路径、原始 OCR payload 或票面原图。",
|
||||
"- 如某张票失败,会继续处理后续文件,并在本报告统一汇总。",
|
||||
"- 如开启覆盖即停止,达到可用终态和重复/已用终态覆盖后会停止继续上传。",
|
||||
"- 批量模式默认使用 API 状态轮询,避免 UI 详情面板刷新造成 OCR 耗时误判;如需 UI 复核可设置 PLAYWRIGHT_OCR_BATCH_TRANSPORT=ui。",
|
||||
];
|
||||
|
||||
writeFileSync(markdownReportPath, `${lines.join("\n")}\n`, "utf-8");
|
||||
@@ -125,20 +157,44 @@ test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输
|
||||
test.skip(!invoiceFiles.length, "需要提供 PDF 格式的 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR");
|
||||
|
||||
const results: SafeLiveInvoiceOcrEvidence[] = [];
|
||||
let stopReason = "completed_all_samples";
|
||||
const stopWhenCovered = process.env.PLAYWRIGHT_OCR_BATCH_STOP_WHEN_COVERED === "1";
|
||||
const transport = process.env.PLAYWRIGHT_OCR_BATCH_TRANSPORT?.trim() || "api";
|
||||
const liveOcrUser = resolveLiveOcrUser(EMPLOYEE);
|
||||
await login(page, liveOcrUser);
|
||||
let apiClient: AuthenticatedApiClient | null = null;
|
||||
|
||||
if (transport === "ui") {
|
||||
await login(page, liveOcrUser);
|
||||
} else {
|
||||
apiClient = await createAuthenticatedApiClient(liveOcrUser);
|
||||
}
|
||||
|
||||
try {
|
||||
await openInvoicePool(page);
|
||||
if (transport === "ui") {
|
||||
await openInvoicePool(page);
|
||||
}
|
||||
|
||||
for (const sample of invoiceFiles) {
|
||||
await openInvoicePool(page);
|
||||
const result = await uploadLiveInvoiceAndWait(page, sample.filePath);
|
||||
let result;
|
||||
if (transport === "ui") {
|
||||
await openInvoicePool(page);
|
||||
result = await uploadLiveInvoiceAndWait(page, sample.filePath);
|
||||
} else {
|
||||
if (!apiClient) throw new Error("api_client_not_initialized");
|
||||
result = await uploadLiveInvoiceViaApiAndWait(apiClient.api, sample.filePath, sample.alias);
|
||||
}
|
||||
results.push(createSafeLiveInvoiceOcrEvidence(result, sample.alias));
|
||||
if (stopWhenCovered && isCoverageSatisfied(results)) {
|
||||
stopReason = "coverage_satisfied";
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
writeReport(results);
|
||||
await page.context().clearCookies().catch(() => undefined);
|
||||
writeReport(results, { plannedSamples: invoiceFiles.length, stopReason });
|
||||
await apiClient?.dispose().catch(() => undefined);
|
||||
if (transport === "ui") {
|
||||
await page.context().clearCookies().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const allowDuplicate = process.env.PLAYWRIGHT_OCR_ALLOW_DUPLICATE === "1";
|
||||
|
||||
在新工单中引用
屏蔽一个用户