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; type ApiEnvelope = { 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(response: { ok(): boolean; status(): number; text(): Promise }, context: string) { const rawText = await response.text(); const parsed = rawText ? (JSON.parse(rawText) as ApiEnvelope) : {}; 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 { 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(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(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 { 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, 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, "开票日期:"), }; }