文件
hyb-oa/client/tests/e2e/helpers/liveInvoiceOcr.ts
T

203 行
7.2 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { expect, type Page } from "@playwright/test";
import type { E2EUser } from "./auth";
export type LiveInvoiceOcrStatus = "available" | "ocr_failed";
export type LiveInvoiceOcrResult = {
status: LiveInvoiceOcrStatus;
detailText: string;
bannerText: string;
durationMs: number;
invoiceId: string | null;
invoiceCode: string | null;
invoiceNumber: string | null;
sellerName: string | null;
totalAmount: string | null;
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;
status?: string | null;
uploadAction?: string | null;
unavailableReason?: string | null;
unavailableMessage?: string | null;
availabilityReason?: string | null;
availabilityMessage?: string | null;
};
};
function extractField(detailText: string, label: string) {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = detailText.match(new RegExp(`${escaped}([^\\n]+)`));
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() ||
process.env.LIVE_SMOKE_EMPLOYEE_IDENTIFIER?.trim() ||
"";
const password =
process.env.PLAYWRIGHT_OCR_LIVE_PASSWORD?.trim() ||
process.env.LIVE_SMOKE_EMPLOYEE_PASSWORD?.trim() ||
"";
if (!identifier && !password) {
return fallback;
}
if (!identifier || !password) {
throw new Error("PLAYWRIGHT_OCR_LIVE_IDENTIFIER/PASSWORD 或 LIVE_SMOKE_EMPLOYEE_* 必须成对提供");
}
return {
identifier,
password,
name: process.env.PLAYWRIGHT_OCR_LIVE_NAME?.trim() || "Smoke Employee",
};
}
export async function openInvoicePool(page: Page) {
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
}
export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: string, timeout = 180_000): Promise<LiveInvoiceOcrResult> {
const startedAt = Date.now();
const uploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as UploadInvoiceResponse;
const uploadData = uploadPayload.data;
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 = [
"票据上传结果",
"状态:识别失败",
`文件名:${invoiceFilePath.split(/[\\/]/).pop() ?? invoiceFilePath}`,
`上传动作:${uploadData?.uploadAction ?? "-"}`,
`原因:${reason}`,
`提示:${message}`,
].join("\n");
return {
status: "ocr_failed",
detailText,
bannerText: message,
durationMs: Date.now() - startedAt,
invoiceId: null,
invoiceCode: null,
invoiceNumber: null,
sellerName: null,
totalAmount: null,
invoiceDate: null,
};
}
const detailPanel = page.getByTestId("invoice-detail-panel");
await expect(detailPanel).toBeVisible({ timeout: 30_000 });
try {
await expect
.poll(async () => {
const text = await detailPanel.innerText().catch(() => "");
if (text.includes("状态:available") || text.includes("状态:可用")) return "available";
if (text.includes("状态:ocr_failed") || text.includes("状态:识别失败")) return "ocr_failed";
return "pending_ocr";
}, { timeout })
.toMatch(/^(available|ocr_failed)$/);
} catch (error) {
const detailText = await detailPanel.innerText().catch(() => "");
const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "");
const reasonCategory = categorizeText(`${bannerText}\n${detailText}`);
throw new Error(
[
"live 腾讯 OCR 等待终态超时",
`invoiceIdPresent=${Boolean(uploadedInvoiceId ?? extractField(detailText, "票据ID:"))}`,
`timeoutMs=${timeout}`,
`reasonCategory=${reasonCategory}`,
`cause=${error instanceof Error ? error.message : String(error)}`,
].join("\n"),
);
}
const detailText = await detailPanel.innerText();
const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "");
const status = detailText.includes("状态:ocr_failed") || detailText.includes("状态:识别失败") ? "ocr_failed" : "available";
const invoiceNo = extractField(detailText, "发票号:") ?? extractField(detailText, "发票号码:");
const [invoiceCode, invoiceNumber] = invoiceNo && invoiceNo.includes("-")
? invoiceNo.split("-", 2).map((item) => item.trim() || null)
: [invoiceNo, null];
return {
status,
detailText,
bannerText,
durationMs: Date.now() - startedAt,
invoiceId: uploadedInvoiceId ?? extractField(detailText, "票据ID:"),
invoiceCode,
invoiceNumber,
sellerName: extractField(detailText, "销售方:"),
totalAmount: extractField(detailText, "总金额:"),
invoiceDate: extractField(detailText, "开票日期:"),
};
}