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(); 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); });