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

397 行
13 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { readFileSync } from "node:fs";
import { expect, type APIRequestContext, 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;
uploadMs?: number;
terminalWaitMs?: number;
transport?: "ui" | "api";
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;
transport?: "ui" | "api";
timings?: {
uploadMs?: number;
terminalWaitMs?: 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;
};
};
type UploadInvoiceData = NonNullable<UploadInvoiceResponse["data"]>;
type ApiEnvelope<T> = {
data?: T;
error?: {
code?: string;
message?: string;
};
};
type InvoiceStatusResponse = {
items?: InvoiceStatusItem[];
};
type InvoiceStatusItem = {
status?: string | null;
reason?: string | null;
message?: string | null;
availabilityReason?: string | null;
availabilityMessage?: string | null;
invoiceCode?: string | null;
invoiceNumber?: string | null;
invoiceDate?: string | null;
sellerName?: string | null;
totalAmount?: 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,
transport: result.transport,
timings: {
uploadMs: result.uploadMs,
terminalWaitMs: result.terminalWaitMs,
},
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),
},
};
}
async function readApiData<T>(response: { ok(): boolean; status(): number; text(): Promise<string> }, context: string) {
const rawText = await response.text();
const parsed = rawText ? (JSON.parse(rawText) as ApiEnvelope<T>) : {};
if (!response.ok()) {
const code = parsed.error?.code ?? `HTTP_${response.status()}`;
throw new Error(`${context}:${code}`);
}
return parsed.data as T;
}
function mapApiStatusToResultStatus(status: string | null | undefined): LiveInvoiceOcrStatus {
return status === "available" ? "available" : "ocr_failed";
}
function buildApiDetailText(input: {
status?: string | null;
reason?: string | null;
message?: string | null;
invoiceCode?: string | null;
invoiceNumber?: string | null;
invoiceDate?: string | null;
sellerName?: string | null;
totalAmount?: string | null;
}) {
return [
"票据上传结果",
`状态:${input.status ?? "-"}`,
`原因:${input.reason ?? "-"}`,
`提示:${input.message ?? "-"}`,
`发票号:${[input.invoiceCode, input.invoiceNumber].filter(Boolean).join("-") || "-"}`,
`销售方:${input.sellerName ?? "-"}`,
`总金额:${input.totalAmount ?? "-"}`,
`开票日期:${input.invoiceDate ?? "-"}`,
].join("\n");
}
export async function uploadLiveInvoiceViaApiAndWait(
api: APIRequestContext,
invoiceFilePath: string,
alias: string,
timeout = 60_000,
): Promise<LiveInvoiceOcrResult> {
const startedAt = Date.now();
const uploadStartedAt = Date.now();
const uploadResponse = await api.post("/api/invoice-pool/upload", {
multipart: {
ocr_type: "vat",
file: {
name: `${alias}.pdf`,
mimeType: "application/pdf",
buffer: readFileSync(invoiceFilePath),
},
},
});
const uploadMs = Date.now() - uploadStartedAt;
const uploadData = await readApiData<UploadInvoiceData>(uploadResponse, "upload_invoice");
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 = buildApiDetailText({
status: uploadData?.status ?? "ocr_failed",
reason,
message,
});
return {
status: "ocr_failed",
detailText,
bannerText: message,
durationMs: Date.now() - startedAt,
uploadMs,
terminalWaitMs: 0,
transport: "api",
invoiceId: null,
invoiceCode: null,
invoiceNumber: null,
sellerName: null,
totalAmount: null,
invoiceDate: null,
};
}
const pollStartedAt = Date.now();
let latest: InvoiceStatusItem | null = null;
while (Date.now() - pollStartedAt <= timeout) {
const statusResponse = await api.get(`/api/invoice-pool/status?ids=${encodeURIComponent(uploadedInvoiceId)}`);
const statusPayload = await readApiData<InvoiceStatusResponse>(statusResponse, "invoice_status");
latest = statusPayload?.items?.[0] ?? null;
if (latest && ["available", "ocr_failed", "bound", "archived"].includes(String(latest.status ?? ""))) {
const reason = latest.availabilityReason ?? latest.reason ?? null;
const message = latest.availabilityMessage ?? latest.message ?? null;
const detailText = buildApiDetailText({
status: latest.status,
reason,
message,
invoiceCode: latest.invoiceCode,
invoiceNumber: latest.invoiceNumber,
invoiceDate: latest.invoiceDate,
sellerName: latest.sellerName,
totalAmount: latest.totalAmount,
});
return {
status: mapApiStatusToResultStatus(latest.status),
detailText,
bannerText: message ?? "",
durationMs: Date.now() - startedAt,
uploadMs,
terminalWaitMs: Date.now() - pollStartedAt,
transport: "api",
invoiceId: uploadedInvoiceId,
invoiceCode: latest.invoiceCode ?? null,
invoiceNumber: latest.invoiceNumber ?? null,
sellerName: latest.sellerName ?? null,
totalAmount: latest.totalAmount ?? null,
invoiceDate: latest.invoiceDate ?? null,
};
}
await new Promise((resolve) => {
setTimeout(resolve, 1_000);
});
}
const detailText = buildApiDetailText({
status: latest?.status ?? "pending_ocr",
reason: latest?.availabilityReason ?? latest?.reason ?? "timeout_or_pending",
message: latest?.availabilityMessage ?? latest?.message ?? "OCR 仍在处理中",
invoiceCode: latest?.invoiceCode ?? null,
invoiceNumber: latest?.invoiceNumber ?? null,
invoiceDate: latest?.invoiceDate ?? null,
sellerName: latest?.sellerName ?? null,
totalAmount: latest?.totalAmount ?? null,
});
throw new Error(
[
"live 腾讯 OCR API 等待终态超时",
`invoiceIdPresent=${Boolean(uploadedInvoiceId)}`,
`timeoutMs=${timeout}`,
`reasonCategory=${categorizeText(detailText)}`,
`uploadMs=${uploadMs}`,
].join("\n"),
);
}
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.getByRole("button", { name: "台账工具" }).click();
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,
uploadMs: Date.now() - startedAt,
terminalWaitMs: 0,
transport: "ui",
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,
transport: "ui",
invoiceId: uploadedInvoiceId ?? extractField(detailText, "票据ID:"),
invoiceCode,
invoiceNumber,
sellerName: extractField(detailText, "销售方:"),
totalAmount: extractField(detailText, "总金额:"),
invoiceDate: extractField(detailText, "开票日期:"),
};
}