161 行
5.6 KiB
TypeScript
161 行
5.6 KiB
TypeScript
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;
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
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 invoiceId = uploadedInvoiceId ?? extractField(detailText, "票据ID:");
|
||
throw new Error(
|
||
[
|
||
"live 腾讯 OCR 等待终态超时",
|
||
`invoiceId=${invoiceId ?? "unknown"}`,
|
||
`timeoutMs=${timeout}`,
|
||
`detail=${detailText || "<empty>"}`,
|
||
`banner=${bannerText || "<empty>"}`,
|
||
`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, "开票日期:"),
|
||
};
|
||
}
|