OINV1 加固真实发票OCR脱敏报告
这个提交包含在:
@@ -17,6 +17,20 @@ export type LiveInvoiceOcrResult = {
|
||||
invoiceDate: string | null;
|
||||
};
|
||||
|
||||
export type SafeLiveInvoiceOcrEvidence = {
|
||||
alias: string;
|
||||
status: LiveInvoiceOcrStatus;
|
||||
durationMs: number;
|
||||
reasonCategory: "available" | "duplicate_or_same_invoice" | "already_used" | "ocr_failed" | "timeout_or_pending" | "unknown";
|
||||
fieldHits: {
|
||||
invoiceId: boolean;
|
||||
invoiceCodeOrNumber: boolean;
|
||||
sellerName: boolean;
|
||||
totalAmount: boolean;
|
||||
invoiceDate: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type UploadInvoiceResponse = {
|
||||
data?: {
|
||||
invoicePoolId?: string | null;
|
||||
@@ -35,6 +49,35 @@ function extractField(detailText: string, label: string) {
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function categorizeText(text: string) {
|
||||
if (/重复|相同发票|duplicate/i.test(text)) return "duplicate_or_same_invoice";
|
||||
if (/已用于报销|已使用|used/i.test(text)) return "already_used";
|
||||
if (/超时|timeout|pending/i.test(text)) return "timeout_or_pending";
|
||||
if (/失败|识别失败|ocr_failed|failed/i.test(text)) return "ocr_failed";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function categorizeLiveInvoiceOcrResult(result: LiveInvoiceOcrResult): SafeLiveInvoiceOcrEvidence["reasonCategory"] {
|
||||
if (result.status === "available") return "available";
|
||||
return categorizeText(`${result.bannerText}\n${result.detailText}`);
|
||||
}
|
||||
|
||||
export function createSafeLiveInvoiceOcrEvidence(result: LiveInvoiceOcrResult, alias: string): SafeLiveInvoiceOcrEvidence {
|
||||
return {
|
||||
alias,
|
||||
status: result.status,
|
||||
durationMs: result.durationMs,
|
||||
reasonCategory: categorizeLiveInvoiceOcrResult(result),
|
||||
fieldHits: {
|
||||
invoiceId: Boolean(result.invoiceId),
|
||||
invoiceCodeOrNumber: Boolean(result.invoiceCode || result.invoiceNumber),
|
||||
sellerName: Boolean(result.sellerName),
|
||||
totalAmount: Boolean(result.totalAmount && result.totalAmount !== "-"),
|
||||
invoiceDate: Boolean(result.invoiceDate),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLiveOcrUser(fallback: E2EUser): E2EUser {
|
||||
const identifier =
|
||||
process.env.PLAYWRIGHT_OCR_LIVE_IDENTIFIER?.trim() ||
|
||||
@@ -124,14 +167,13 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
|
||||
} catch (error) {
|
||||
const detailText = await detailPanel.innerText().catch(() => "");
|
||||
const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "");
|
||||
const invoiceId = uploadedInvoiceId ?? extractField(detailText, "票据ID:");
|
||||
const reasonCategory = categorizeText(`${bannerText}\n${detailText}`);
|
||||
throw new Error(
|
||||
[
|
||||
"live 腾讯 OCR 等待终态超时",
|
||||
`invoiceId=${invoiceId ?? "unknown"}`,
|
||||
`invoiceIdPresent=${Boolean(uploadedInvoiceId ?? extractField(detailText, "票据ID:"))}`,
|
||||
`timeoutMs=${timeout}`,
|
||||
`detail=${detailText || "<empty>"}`,
|
||||
`banner=${bannerText || "<empty>"}`,
|
||||
`reasonCategory=${reasonCategory}`,
|
||||
`cause=${error instanceof Error ? error.message : String(error)}`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
@@ -3,15 +3,24 @@ import path from "node:path";
|
||||
|
||||
import { expect, test } from "../helpers/fixtures";
|
||||
import { EMPLOYEE, login } from "../helpers/auth";
|
||||
import { openInvoicePool, resolveLiveOcrUser, uploadLiveInvoiceAndWait, type LiveInvoiceOcrResult } from "../helpers/liveInvoiceOcr";
|
||||
import {
|
||||
createSafeLiveInvoiceOcrEvidence,
|
||||
openInvoicePool,
|
||||
resolveLiveOcrUser,
|
||||
uploadLiveInvoiceAndWait,
|
||||
type SafeLiveInvoiceOcrEvidence,
|
||||
} from "../helpers/liveInvoiceOcr";
|
||||
|
||||
type BatchResult = LiveInvoiceOcrResult & {
|
||||
type BatchSample = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
alias: string;
|
||||
};
|
||||
|
||||
const rootDir = path.resolve(process.cwd(), "..");
|
||||
const reportPath = path.join(rootDir, "artifacts/r70/live-ocr-batch-report.md");
|
||||
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 resolveInvoiceFiles() {
|
||||
const files = new Set<string>();
|
||||
@@ -37,39 +46,75 @@ function resolveInvoiceFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
return [...files].filter((filePath) => existsSync(filePath));
|
||||
return [...files]
|
||||
.filter((filePath) => existsSync(filePath))
|
||||
.sort((left, right) => left.localeCompare(right, "zh-CN"))
|
||||
.map((filePath, index): BatchSample => ({
|
||||
filePath,
|
||||
alias: `SAMPLE-PDF-${String(index + 1).padStart(3, "0")}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function writeReport(results: BatchResult[]) {
|
||||
mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
function writeReport(results: SafeLiveInvoiceOcrEvidence[]) {
|
||||
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(),
|
||||
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 = [
|
||||
"# R70 批量 live OCR 辅助复核报告",
|
||||
"# 批量 live OCR 脱敏辅助复核报告",
|
||||
"",
|
||||
`生成时间:${new Date().toISOString()}`,
|
||||
"",
|
||||
"## 结果",
|
||||
"",
|
||||
`- 总文件数:${results.length}`,
|
||||
`- 成功:${results.filter((item) => item.status === "available").length}`,
|
||||
`- 失败:${results.filter((item) => item.status === "ocr_failed").length}`,
|
||||
`- 总文件数:${counts.total}`,
|
||||
`- 可用:${counts.available}`,
|
||||
`- 识别失败:${counts.ocrFailed}`,
|
||||
`- 重复或相同发票:${counts.duplicateOrSameInvoice}`,
|
||||
`- 已用于报销/已使用:${counts.alreadyUsed}`,
|
||||
`- 超时或仍在处理中:${counts.timeoutOrPending}`,
|
||||
`- 未分类:${counts.unknown}`,
|
||||
"",
|
||||
"## 明细",
|
||||
"",
|
||||
"| 文件 | 状态 | 耗时(ms) | 票据ID | 发票代码 | 发票号码 | 销售方 | 开票日期 | 备注 |",
|
||||
"| 样本 | 状态 | 耗时(ms) | 原因分类 | 票据ID命中 | 发票号命中 | 销售方命中 | 金额命中 | 日期命中 |",
|
||||
"| --- | --- | ---: | --- | --- | --- | --- | --- | --- |",
|
||||
...results.map((item) => {
|
||||
const remark = item.status === "ocr_failed" ? (item.bannerText || item.detailText).replaceAll("|", "\\|") : "ok";
|
||||
return `| ${item.fileName} | ${item.status} | ${item.durationMs} | ${item.invoiceId ?? "-"} | ${item.invoiceCode ?? "-"} | ${item.invoiceNumber ?? "-"} | ${item.sellerName ?? "-"} | ${item.invoiceDate ?? "-"} | ${remark} |`;
|
||||
}),
|
||||
...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"} |`
|
||||
)),
|
||||
"",
|
||||
"## 说明",
|
||||
"",
|
||||
"- 本报告用于真实腾讯 OCR 的本地辅助复核,不进入常规本地回归。",
|
||||
"- 本报告用于真实腾讯 OCR 的受控辅助复核,不进入常规本地回归。",
|
||||
"- 本报告只记录样本别名、状态、耗时、字段命中布尔值和原因分类。",
|
||||
"- 本报告不得记录发票号码、销售方、金额、完整文件名、完整路径、原始 OCR payload 或票面原图。",
|
||||
"- 如某张票失败,会继续处理后续文件,并在本报告统一汇总。",
|
||||
];
|
||||
|
||||
writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8");
|
||||
writeFileSync(markdownReportPath, `${lines.join("\n")}\n`, "utf-8");
|
||||
writeFileSync(jsonReportPath, `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输出报告", async ({ page }) => {
|
||||
@@ -79,27 +124,27 @@ test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输
|
||||
const invoiceFiles = resolveInvoiceFiles();
|
||||
test.skip(!invoiceFiles.length, "需要提供 PDF 格式的 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR");
|
||||
|
||||
const results: BatchResult[] = [];
|
||||
const results: SafeLiveInvoiceOcrEvidence[] = [];
|
||||
const liveOcrUser = resolveLiveOcrUser(EMPLOYEE);
|
||||
await login(page, liveOcrUser);
|
||||
|
||||
try {
|
||||
await openInvoicePool(page);
|
||||
|
||||
for (const invoiceFilePath of invoiceFiles) {
|
||||
for (const sample of invoiceFiles) {
|
||||
await openInvoicePool(page);
|
||||
const result = await uploadLiveInvoiceAndWait(page, invoiceFilePath);
|
||||
results.push({
|
||||
...result,
|
||||
filePath: invoiceFilePath,
|
||||
fileName: path.basename(invoiceFilePath),
|
||||
});
|
||||
const result = await uploadLiveInvoiceAndWait(page, sample.filePath);
|
||||
results.push(createSafeLiveInvoiceOcrEvidence(result, sample.alias));
|
||||
}
|
||||
} finally {
|
||||
writeReport(results);
|
||||
await page.context().clearCookies().catch(() => undefined);
|
||||
}
|
||||
|
||||
const failures = results.filter((item) => item.status === "ocr_failed");
|
||||
expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { EMPLOYEE, login } from "../helpers/auth";
|
||||
import { openInvoicePool, resolveLiveOcrUser, uploadLiveInvoiceAndWait } from "../helpers/liveInvoiceOcr";
|
||||
import { createSafeLiveInvoiceOcrEvidence, openInvoicePool, resolveLiveOcrUser, uploadLiveInvoiceAndWait } from "../helpers/liveInvoiceOcr";
|
||||
|
||||
test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可处理状态", async ({ page }, testInfo) => {
|
||||
test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行");
|
||||
@@ -18,17 +18,7 @@ test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可
|
||||
await openInvoicePool(page);
|
||||
|
||||
const result = await uploadLiveInvoiceAndWait(page, invoiceFilePath);
|
||||
const evidence = {
|
||||
status: result.status,
|
||||
durationMs: result.durationMs,
|
||||
invoiceId: result.invoiceId,
|
||||
invoiceCode: result.invoiceCode,
|
||||
invoiceNumber: result.invoiceNumber,
|
||||
sellerName: result.sellerName,
|
||||
totalAmount: result.totalAmount,
|
||||
invoiceDate: result.invoiceDate,
|
||||
bannerText: result.bannerText,
|
||||
};
|
||||
const evidence = createSafeLiveInvoiceOcrEvidence(result, process.env.PLAYWRIGHT_OCR_LIVE_ALIAS?.trim() || "SAMPLE-PDF-001");
|
||||
console.log(`[live-ocr] ${JSON.stringify(evidence)}`);
|
||||
await testInfo.attach("live-ocr-result.json", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
@@ -37,18 +27,17 @@ test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可
|
||||
|
||||
if (result.status === "ocr_failed") {
|
||||
const allowDuplicate = process.env.PLAYWRIGHT_OCR_ALLOW_DUPLICATE === "1";
|
||||
const resultText = `${result.bannerText}\n${result.detailText}`;
|
||||
const explainableTerminal = /重复|相同发票|duplicate|已用于报销|已使用|used/i.test(resultText);
|
||||
const explainableTerminal = ["duplicate_or_same_invoice", "already_used"].includes(evidence.reasonCategory);
|
||||
if (!allowDuplicate || !explainableTerminal) {
|
||||
throw new Error(`live 腾讯 OCR 失败:${result.bannerText || result.detailText}`);
|
||||
throw new Error(`live 腾讯 OCR 失败:reasonCategory=${evidence.reasonCategory}`);
|
||||
}
|
||||
await page.context().clearCookies().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.invoiceCode ?? result.invoiceNumber, JSON.stringify(result)).toBeTruthy();
|
||||
expect(result.totalAmount, JSON.stringify(result)).toBeTruthy();
|
||||
expect(result.totalAmount, JSON.stringify(result)).not.toBe("-");
|
||||
expect(result.invoiceCode ?? result.invoiceNumber, JSON.stringify(evidence)).toBeTruthy();
|
||||
expect(result.totalAmount, JSON.stringify(evidence)).toBeTruthy();
|
||||
expect(result.totalAmount, JSON.stringify(evidence)).not.toBe("-");
|
||||
|
||||
await page.context().clearCookies().catch(() => undefined);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户