397 行
13 KiB
TypeScript
397 行
13 KiB
TypeScript
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, "开票日期:"),
|
||
};
|
||
}
|