207 行
9.1 KiB
TypeScript
207 行
9.1 KiB
TypeScript
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { expect, test } from "../helpers/fixtures";
|
|
import { createAuthenticatedApiClient, EMPLOYEE, login, type AuthenticatedApiClient } from "../helpers/auth";
|
|
import {
|
|
createSafeLiveInvoiceOcrEvidence,
|
|
openInvoicePool,
|
|
resolveLiveOcrUser,
|
|
uploadLiveInvoiceAndWait,
|
|
uploadLiveInvoiceViaApiAndWait,
|
|
type SafeLiveInvoiceOcrEvidence,
|
|
} from "../helpers/liveInvoiceOcr";
|
|
|
|
type BatchSample = {
|
|
filePath: string;
|
|
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();
|
|
const batchFiles = process.env.PLAYWRIGHT_OCR_LIVE_FILES?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) ?? [];
|
|
const batchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() ?? "";
|
|
|
|
if (directFile && /\.pdf$/i.test(directFile)) {
|
|
files.add(path.resolve(directFile));
|
|
}
|
|
|
|
for (const file of batchFiles) {
|
|
if (!/\.pdf$/i.test(file)) continue;
|
|
files.add(path.resolve(file));
|
|
}
|
|
|
|
if (batchDir && existsSync(batchDir) && statSync(batchDir).isDirectory()) {
|
|
for (const entry of readdirSync(batchDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
|
|
const fullPath = path.join(batchDir, entry);
|
|
if (!statSync(fullPath).isFile()) continue;
|
|
if (!/\.pdf$/i.test(entry)) continue;
|
|
files.add(path.resolve(fullPath));
|
|
}
|
|
}
|
|
|
|
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 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,
|
|
available: results.filter((item) => item.status === "available").length,
|
|
ocrFailed: results.filter((item) => item.status === "ocr_failed").length,
|
|
duplicateOrSameInvoice: results.filter((item) => item.reasonCategory === "duplicate_or_same_invoice").length,
|
|
alreadyUsed: results.filter((item) => item.reasonCategory === "already_used").length,
|
|
timeoutOrPending: results.filter((item) => item.reasonCategory === "timeout_or_pending").length,
|
|
unknown: results.filter((item) => item.reasonCategory === "unknown").length,
|
|
};
|
|
const fieldHitCounts = {
|
|
invoiceId: results.filter((item) => item.fieldHits.invoiceId).length,
|
|
invoiceCodeOrNumber: results.filter((item) => item.fieldHits.invoiceCodeOrNumber).length,
|
|
sellerName: results.filter((item) => item.fieldHits.sellerName).length,
|
|
totalAmount: results.filter((item) => item.fieldHits.totalAmount).length,
|
|
invoiceDate: results.filter((item) => item.fieldHits.invoiceDate).length,
|
|
};
|
|
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",
|
|
results,
|
|
};
|
|
|
|
const lines = [
|
|
"# 批量 live OCR 脱敏辅助复核报告",
|
|
"",
|
|
`生成时间:${new Date().toISOString()}`,
|
|
"",
|
|
"## 结果",
|
|
"",
|
|
`- 计划样本数:${options.plannedSamples}`,
|
|
`- 停止原因:${options.stopReason}`,
|
|
`- 总文件数:${counts.total}`,
|
|
`- 可用:${counts.available}`,
|
|
`- 识别失败:${counts.ocrFailed}`,
|
|
`- 重复或相同发票:${counts.duplicateOrSameInvoice}`,
|
|
`- 已用于报销/已使用:${counts.alreadyUsed}`,
|
|
`- 超时或仍在处理中:${counts.timeoutOrPending}`,
|
|
`- 未分类:${counts.unknown}`,
|
|
"",
|
|
"## 明细",
|
|
"",
|
|
"| 样本 | 通道 | 状态 | 总耗时(ms) | 上传(ms) | 终态等待(ms) | 原因分类 | 票据ID命中 | 发票号命中 | 销售方命中 | 金额命中 | 日期命中 |",
|
|
"| --- | --- | --- | ---: | ---: | ---: | --- | --- | --- | --- | --- | --- |",
|
|
...results.map((item) => (
|
|
`| ${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"} |`
|
|
)),
|
|
"",
|
|
"## 说明",
|
|
"",
|
|
"- 本报告用于真实腾讯 OCR 的受控辅助复核,不进入常规本地回归。",
|
|
"- 本报告只记录样本别名、状态、耗时、字段命中布尔值和原因分类。",
|
|
"- 本报告不得记录发票号码、销售方、金额、完整文件名、完整路径、原始 OCR payload 或票面原图。",
|
|
"- 如某张票失败,会继续处理后续文件,并在本报告统一汇总。",
|
|
"- 如开启覆盖即停止,达到可用终态和重复/已用终态覆盖后会停止继续上传。",
|
|
"- 批量模式默认使用 API 状态轮询,避免 UI 详情面板刷新造成 OCR 耗时误判;如需 UI 复核可设置 PLAYWRIGHT_OCR_BATCH_TRANSPORT=ui。",
|
|
];
|
|
|
|
writeFileSync(markdownReportPath, `${lines.join("\n")}\n`, "utf-8");
|
|
writeFileSync(jsonReportPath, `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
|
|
}
|
|
|
|
test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输出报告", async ({ page }) => {
|
|
test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行");
|
|
test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行");
|
|
|
|
const invoiceFiles = resolveInvoiceFiles();
|
|
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);
|
|
let apiClient: AuthenticatedApiClient | null = null;
|
|
|
|
if (transport === "ui") {
|
|
await login(page, liveOcrUser);
|
|
} else {
|
|
apiClient = await createAuthenticatedApiClient(liveOcrUser);
|
|
}
|
|
|
|
try {
|
|
if (transport === "ui") {
|
|
await openInvoicePool(page);
|
|
}
|
|
|
|
for (const sample of invoiceFiles) {
|
|
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, { 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";
|
|
const blockingFailures = results.filter((item) => {
|
|
if (item.status !== "ocr_failed") return false;
|
|
return !(allowDuplicate && ["duplicate_or_same_invoice", "already_used"].includes(item.reasonCategory));
|
|
});
|
|
expect(blockingFailures, JSON.stringify(blockingFailures, null, 2)).toHaveLength(0);
|
|
});
|