import { mkdirSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page, type Route } from "@playwright/test"; const ROOT_DIR = path.resolve(process.cwd(), ".."); const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r111-invoice-reimbursement"); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; const reimbursementId = "11111111-1111-4111-8111-111111111111"; const ownerId = "employee-r111"; const pendingInvoiceId = "22222222-2222-4222-8222-222222222222"; const unlinkedInvoiceId = "66666666-6666-4666-8666-666666666666"; const attachmentId = "33333333-3333-4333-8333-333333333333"; const availableInvoiceId = "44444444-4444-4444-8444-444444444444"; const failedInvoiceId = "55555555-5555-4555-8555-555555555555"; function envelope(data: unknown) { return { requestId: "mock-r111-invoice-reimbursement", data }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } function screenshotPath(fileName: string) { mkdirSync(SCREENSHOT_DIR, { recursive: true }); return path.join(SCREENSHOT_DIR, fileName.endsWith(".png") ? fileName : `${fileName}.png`); } function buildMe() { return { id: ownerId, employeeNo: "E111", name: "测试员工", phone: "13800000000", email: "employee@hyinda.com", departmentId: "dept-r111", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["employee"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 60, }, wecomBinding: { corpId: "ww-test", userId: "r111-user", boundAt: "2026-05-08T08:00:00.000Z" }, }; } function buildExpenseTypes() { return { items: [ { id: "expense-type-r111", code: "business_client_meal", name: "业务招待", category: "expense", enabled: true, }, ], }; } function buildInvoice() { return { id: pendingInvoiceId, status: "bound", invoiceCode: null as string | null, invoiceNumber: "12345678", invoiceDate: "2026-05-08", sellerName: "北京悦海堂餐饮管理有限公司" as string | null, buyerName: null as string | null, documentCategory: "full_vat_invoice" as string | null, documentSubtype: "VatElectronicInvoiceFull" as string | null, totalAmount: "1683.00", originalName: "北京悦海堂发票.pdf", mimeType: "application/pdf", fileSize: 4096, linkedItemId: "item-r111" as string | null, }; } function buildUnlinkedInvoice() { return { ...buildInvoice(), id: unlinkedInvoiceId, invoiceNumber: "12345679", sellerName: null, buyerName: "测试购买方", documentCategory: "electronic_train_ticket", documentSubtype: "ElectronicTrainTicketFull", originalName: "未关联发票.pdf", linkedItemId: null, }; } function buildAttachment() { return { id: attachmentId, originalName: "付款截图.png", mimeType: "image/png", fileSize: 2048, fileSha256: "sha256-attachment-r111", uploadedBy: { id: ownerId, name: "测试员工", employeeNo: "E111" }, createdAt: "2026-05-08T08:10:00.000Z", }; } function buildAvailableInvoice() { return { id: availableInvoiceId, invoiceCode: "044002600111", invoiceNumber: "87654321", invoiceDate: "2026-05-08", sellerName: "北京悦海堂餐饮管理有限公司", totalAmount: "1683.00", originalName: "已有发票.pdf", createdAt: "2026-05-08T08:00:00.000Z", }; } function buildFailedInvoice() { return { id: failedInvoiceId, status: "ocr_failed", ocrType: "vat", ocrProvider: "tencent", ocrAttempts: 1, ocrLastError: "识别服务未返回完整发票信息", ocrCompletedAt: "2026-05-08T08:05:00.000Z", invoiceCode: null, invoiceNumber: null, invoiceDate: null, amount: null, taxAmount: null, totalAmount: null, linkedReimbursementId: null, linkedItemId: null, linkedTravelOrderId: null, originalName: "识别失败发票.pdf", mimeType: "application/pdf", fileSize: 4096, verification: null, owner: { id: ownerId, employeeNo: "E111", name: "测试员工" }, uploadedBy: { id: ownerId, employeeNo: "E111", name: "测试员工" }, createdAt: "2026-05-08T08:01:00.000Z", updatedAt: "2026-05-08T08:05:00.000Z", }; } function buildReimbursementState() { return { invoices: [] as ReturnType[], attachments: [] as ReturnType[], availableInvoices: [buildAvailableInvoice()], failedInvoices: [buildFailedInvoice()], statusPollCount: 0, }; } function buildReimbursementDetail(state: ReturnType) { return { id: reimbursementId, formNumber: "BX20260508001", submitterId: ownerId, ownerId, departmentId: "dept-r111", department: { id: "dept-r111", name: "研发部" }, status: "draft", createdAt: "2026-05-08T08:00:00.000Z", submittedAt: null, reimbursementDate: "2026-05-08", totalAmount: "1683.00", description: "客户招待报销", travelSummary: { isTravel: true, purpose: "合成差旅概要", location: "测试地点", startDate: "2026-05-07", endDate: "2026-05-08", }, projectId: null, linkedTravelRequestId: null, linkedTravelRequest: null, project: null, owner: { id: ownerId, employee_no: "E111", name: "测试员工" }, submitter: { id: ownerId, employee_no: "E111", name: "测试员工" }, invoices: state.invoices, attachments: state.attachments, budgetHint: null, pendingWorkflow: null, workflowProgress: null, processingProgress: { stages: [ { key: "submitted", label: "申请提交", state: "current", actorName: "测试员工", occurredAt: null, result: "待提交", comment: null, detail: "待申请人提交", targetHref: null }, { key: "manager_approval", label: "主管审批", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null }, { key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null }, { key: "payment_batch_review", label: "付款复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务安排付款", targetHref: null }, { key: "payment_and_receipt", label: "付款完成", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款复核后由财务登记,非审批环节", targetHref: null }, ], }, approvalHistory: [], riskFlags: [], items: [ { id: "item-r111", expenseTypeId: "expense-type-r111", expenseDate: "2026-05-08", amount: "1683.00", description: "客户招待", expenseType: { id: "expense-type-r111", code: "business_client_meal", name: "业务招待", category: "expense", enabled: true, }, }, ], }; } function navigationItem(key: string, href: string, label: string, icon: string) { return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; } async function mockApis(page: Page, state: ReturnType) { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const url = new URL(request.url()); const pathName = url.pathname; if (pathName === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (pathName === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: true, entryEnabled: true, oauthEnabled: true, corpId: "ww-test", agentId: "1000002", loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile", bindEnabled: true, defaultReturnTo: "/mobile", status: "ready", statusReasons: [], }); return; } if (pathName === "/api/auth/wecom/js-sdk-signature" && request.method() === "POST") { await fulfillJson(route, { corpId: "ww-test", timestamp: 1785484800, nonceStr: "synthetic-r111-nonce", signature: "1111111111111111111111111111111111111111", }); return; } if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") { await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }, body: JSON.stringify(envelope({ accessToken: "mock-r111-token", expiresIn: 900, mustChangePassword: false })), }); return; } if (pathName === "/api/me") { await fulfillJson(route, buildMe()); return; } if (pathName === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-r111", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "wecom", notificationStrategy: "in_app", devicePriority: "mobile", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null, }, sections: { main: [ navigationItem("portal", "/portal", "工作台", "home_app_logo"), navigationItem("expense", "/reimbursements", "报销", "request_quote"), navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"), ], admin: [], backup: [], }, recommendedRolePresets: [], }); return; } if (pathName === "/api/notifications") { await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] }); return; } if (pathName === "/api/portal/home") { await fulfillJson(route, { workspace: { id: "workspace-r111", persisted: true, profile: "small_team_internal" }, greeting: { name: "测试员工" }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [{ key: "expense", title: "新建报销", href: "/reimbursements" }], todayPresence: null, teamPresence: null, weeklyReport: null, weeklySummary: null, todoSummary: null, projectSummary: null, peopleLifecycle: null, adoptionSummary: null, recentActivities: [], teamStatusBoard: [], }); return; } if (pathName === "/api/expense-types") { await fulfillJson(route, buildExpenseTypes()); return; } if (pathName === "/api/projects") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); return; } if (pathName === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") { await fulfillJson(route, buildReimbursementDetail(state)); return; } if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) { await fulfillJson(route, { decision: "pass", standardChecks: [], budget: null, riskFlags: [], }); return; } if (pathName === `/api/reimbursements/${reimbursementId}/attachments` && request.method() === "POST") { state.attachments = [buildAttachment()]; await fulfillJson(route, { items: state.attachments }); return; } if (pathName === `/api/reimbursements/${reimbursementId}/materials/preview-bundle` && request.method() === "POST") { await fulfillJson(route, { expiresAt: "2026-07-31T10:02:00.000Z", signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777?previewToken=merged-pdf`, originalName: "reimbursement-materials-preview.pdf", mimeType: "application/pdf", fileSize: 8192, pageCount: state.invoices.length + state.attachments.length, }); return; } if (pathName.includes(`/api/reimbursements/${reimbursementId}/materials/preview/`) && request.method() === "GET") { await route.fulfill({ status: 200, contentType: "application/pdf", body: Buffer.from("%PDF synthetic-preview") }); return; } if ( pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url` && request.method() === "POST" ) { await fulfillJson(route, { signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file?previewToken=synthetic`, expiresAt: "2026-07-31T10:02:00.000Z", originalName: "付款截图.png", mimeType: "image/png", fileSize: 2048, }); return; } if ( pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file` && request.method() === "GET" ) { await route.fulfill({ status: 200, contentType: "image/png", headers: { "content-disposition": 'inline; filename="attachment.png"' }, body: Buffer.from("synthetic-r111-attachment"), }); return; } if (pathName === "/api/invoice-pool/bindable") { await fulfillJson(route, { items: state.availableInvoices, }); return; } if (pathName === "/api/invoice-pool" && url.searchParams.get("status") === "ocr_failed") { await fulfillJson(route, { page: 1, pageSize: 20, total: state.failedInvoices.length, ownerScope: "own", items: state.failedInvoices, }); return; } if (pathName === "/api/invoice-pool/upload") { state.statusPollCount = 0; await fulfillJson(route, { invoicePoolId: pendingInvoiceId, status: "pending_ocr", fileSha256: "sha256-r111", uploadAction: "create", unavailableReason: null, unavailableMessage: null, availabilityReason: "pending_ocr", availabilityMessage: "发票正在识别,完成后会自动刷新", }); return; } if (pathName === "/api/invoice-pool/status") { state.statusPollCount += 1; const stillPending = state.statusPollCount === 1; await fulfillJson(route, { items: [ { id: pendingInvoiceId, status: stillPending ? "pending_ocr" : "available", reason: stillPending ? "pending_ocr" : "available", message: stillPending ? "发票正在识别" : "发票可添加", availabilityReason: stillPending ? "pending_ocr" : "available", availabilityMessage: stillPending ? "发票正在识别" : "发票可添加", invoiceCode: stillPending ? null : "044002600111", invoiceNumber: stillPending ? null : "12345678", invoiceDate: stillPending ? null : "2026-05-08", sellerName: stillPending ? null : "北京悦海堂餐饮管理有限公司", totalAmount: stillPending ? null : "1683.00", originalName: "北京悦海堂发票.pdf", linkedReimbursementId: null, linkedItemId: null, linkedTravelOrderId: null, ocrAttempts: stillPending ? 0 : 1, ocrLastError: null, ocrCompletedAt: stillPending ? null : "2026-05-08T08:05:00.000Z", updatedAt: stillPending ? "2026-05-08T08:04:00.000Z" : "2026-05-08T08:05:00.000Z", }, ], missingIds: [], }); return; } if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) { state.invoices = [buildInvoice(), buildUnlinkedInvoice()]; await fulfillJson(route, { id: pendingInvoiceId, status: "bound", linkedReimbursementId: reimbursementId, linkedItemId: "item-r111", linkedTravelOrderId: null, totalAmount: "1683.00", }); return; } if (pathName === `/api/invoice-pool/${pendingInvoiceId}/signed-url` && request.method() === "POST") { await fulfillJson(route, { signedUrl: `https://oa.hyinda.com/api/invoice-pool/${pendingInvoiceId}/file?token=synthetic`, expiresAt: "2026-07-31T10:02:00.000Z", originalName: "北京悦海堂发票.pdf", mimeType: "application/pdf", fileSize: 4096, }); return; } if (pathName === `/api/invoice-pool/${pendingInvoiceId}/unbind`) { state.invoices = []; await fulfillJson(route, { id: pendingInvoiceId, status: "available", linkedReimbursementId: null, linkedItemId: null, linkedTravelOrderId: null, totalAmount: "1683.00", }); return; } if (pathName === `/api/invoice-pool/${availableInvoiceId}/archive`) { state.availableInvoices = []; await fulfillJson(route, { id: availableInvoiceId, status: "archived", archivedReason: "manual", archivedAt: "2026-05-08T08:20:00.000Z", }); return; } if (pathName === `/api/invoice-pool/${failedInvoiceId}/archive`) { state.failedInvoices = []; await fulfillJson(route, { id: failedInvoiceId, status: "archived", archivedReason: "manual", archivedAt: "2026-05-08T08:21:00.000Z", }); return; } await fulfillJson(route, {}); }); } async function login(page: Page) { await page.goto("/login"); const accountLogin = page.getByRole("button", { name: "使用账号密码登录" }); if (await accountLogin.isVisible().catch(() => false)) { await accountLogin.click(); } await page.getByTestId("login-identifier").fill("employee@hyinda.com"); await page.getByTestId("login-password").fill("EmployeePassword1234"); await page.getByTestId("login-submit").click(); await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 }); } async function newWecomPage(browser: Browser) { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN", serviceWorkers: "block", }); return { context, page: await context.newPage() }; } async function collectVisualMetrics(page: Page) { return page.evaluate(() => { function parseRgb(value: string) { const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); if (!match) return null; return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: match[4] == null ? 1 : Number(match[4]) }; } const nav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav"); const navRect = nav?.getBoundingClientRect(); const activeNav = document.querySelector("[data-testid='mobile-nav-reimbursements']") ?? document.querySelector("nav a[aria-current='page']"); const activeStyle = activeNav ? getComputedStyle(activeNav) : null; const activeRgb = activeStyle ? parseRgb(activeStyle.backgroundColor) : null; const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']"); const stickyRect = stickyActions?.getBoundingClientRect(); const materialAddButton = document.querySelector("[data-testid='reimbursement-invoice-add']"); const stickyAddButton = document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"); const submitButton = document.querySelector("[data-testid='reimbursement-submit']"); const submitRect = submitButton?.getBoundingClientRect(); const bodyText = document.body?.innerText ?? ""; return { width: window.innerWidth, height: window.innerHeight, scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length, navCount: document.querySelectorAll("[data-testid^='mobile-nav-']").length, navHeight: navRect ? Math.round(navRect.height) : 0, activeNavBackground: activeStyle?.backgroundColor ?? "", activeNavAverage: activeRgb ? Math.round((activeRgb.r + activeRgb.g + activeRgb.b) / 3) : 255, activeNavAlpha: activeRgb?.a ?? 0, stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2), stickyBottomGap: stickyRect ? Math.round(window.innerHeight - stickyRect.bottom) : null, stickyHeight: stickyRect ? Math.round(stickyRect.height) : 0, materialInvoiceAddVisible: Boolean(materialAddButton?.getBoundingClientRect().height), stickyInvoiceAddVisible: Boolean(stickyAddButton?.getBoundingClientRect().height), stickyButtonMinHeight: submitRect?.height ?? 0, activeElementTestId: document.activeElement?.getAttribute("data-testid") ?? "", bodyText, }; }); } test.describe("R111 国内报销发票与附件双端体验", () => { test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => { const state = buildReimbursementState(); await page.addInitScript(() => { const openCalls: unknown[][] = []; Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true }); window.open = ((...args: unknown[]) => { openCalls.push(args); return null; }) as typeof window.open; }); await mockApis(page, state); await login(page); await page.setViewportSize({ width: 1440, height: 960 }); await page.goto(`/reimbursements/${reimbursementId}`); await expect(page.getByText("报销详情 BX20260508001")).toBeVisible(); await expect(page.getByText("预算与审核提示")).toHaveCount(0); await expect(page.getByTestId("reimbursement-attachments")).toBeVisible(); await expect(page.getByTestId("reimbursement-related-records")).toBeVisible(); const pcSectionOrder = await page.locator("[data-testid='reimbursement-overview'], [data-testid='reimbursement-items'], [data-testid='reimbursement-materials'], [data-testid='reimbursement-workflow-progress']") .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); expect(pcSectionOrder).toEqual([ "reimbursement-overview", "reimbursement-items", "reimbursement-materials", "reimbursement-workflow-progress", ]); await expect(page.getByText("ERP")).toHaveCount(0); await page.getByTestId("reimbursement-invoice-add").click(); await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible(); await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r111 invoice"), }); await page.getByTestId("reimbursement-invoice-upload-submit").click(); await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 }); expect(state.statusPollCount).toBeGreaterThanOrEqual(1); await page.keyboard.press("Escape"); await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible(); await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible(); await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0"); await expect(page.getByTestId("reimbursement-materials")).toContainText("数电发票 · 12345678"); await expect(page.getByTestId("reimbursement-materials")).toContainText("电子火车票 · 12345679"); await expect(page.getByTestId("reimbursement-materials")).not.toContainText("无代码"); await expect(page.getByTestId("reimbursement-materials")).not.toContainText("未识别销售方"); const itemRow = page.getByTestId("reimbursement-item-row"); await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0); await expect(itemRow).not.toContainText("电子火车票"); await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2); await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0); await page.locator("#reimbursement-attachment-upload").setInputFiles({ name: "付款截图.png", mimeType: "image/png", buffer: Buffer.from("r111 attachment"), }); await page.getByRole("button", { name: "上传附件" }).click(); await expect(page.getByText("付款截图.png")).toBeVisible(); await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("合成差旅概要"); await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("测试地点"); await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1"); await page.getByTestId("reimbursement-materials-open-all").click(); await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1); expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0])).toContain("previewToken=merged-pdf"); const bodyText = await page.locator("body").innerText(); expect(bodyText).not.toContain("绑定整单"); expect(bodyText).not.toContain("不绑定明细"); expect(bodyText).not.toContain("发票池"); expect(bodyText).not.toContain("OCR"); expect(bodyText).not.toMatch(/\bdraft\b/); expect(bodyText).not.toMatch(/\bbound\b/); expect(bodyText).not.toContain("business_client_meal"); expect(bodyText).not.toContain("expense-type-r111"); await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true }); }); test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => { const state = buildReimbursementState(); const mobile = await newWecomPage(browser); await mobile.page.addInitScript(() => { const bridgeCalls: Array<{ name: string; params: Record }> = []; Object.defineProperty(window, "__r111BridgeCalls", { value: bridgeCalls, configurable: true }); Object.defineProperty(window, "WeixinJSBridge", { configurable: true, value: { invoke(name: string, params: Record, callback: (result: Record) => void) { bridgeCalls.push({ name, params }); callback({ err_msg: `${name}:ok` }); }, on() {}, }, }); const openCalls: unknown[][] = []; Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true }); window.open = ((...args: unknown[]) => { openCalls.push(args); return null; }) as typeof window.open; }); await mockApis(mobile.page, state); try { await login(mobile.page); await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`); await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible(); await expect(mobile.page.getByRole("heading", { name: "报销材料" })).toBeVisible(); const beforeDialogMetrics = await collectVisualMetrics(mobile.page); expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2); expect(beforeDialogMetrics.tableCount).toBe(0); expect(beforeDialogMetrics.navCount).toBe(0); expect(beforeDialogMetrics.navHeight).toBe(0); expect(beforeDialogMetrics.stickyActionsVisible).toBe(true); expect(beforeDialogMetrics.stickyBottomGap).toBeLessThanOrEqual(2); expect(beforeDialogMetrics.materialInvoiceAddVisible).toBe(true); expect(beforeDialogMetrics.stickyInvoiceAddVisible).toBe(false); expect(beforeDialogMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44); await mobile.page.getByTestId("reimbursement-invoice-add").click(); await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).toBeVisible(); const dialogMetrics = await collectVisualMetrics(mobile.page); expect(dialogMetrics.activeElementTestId).not.toBe("mobile-reimbursement-invoice-search"); const invoiceDialog = mobile.page.getByTestId("reimbursement-invoice-dialog"); await invoiceDialog.getByRole("button", { name: "已有发票" }).click(); const availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600111-87654321" }); const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" }); await expect(availableInvoiceCard).toBeVisible(); await expect(availableInvoiceCard.getByText("可添加", { exact: true })).toBeVisible(); await expect(failedInvoiceCard.getByText("识别失败", { exact: true })).toBeVisible(); await expect(failedInvoiceCard).toContainText("可删除后重新上传"); await availableInvoiceCard.getByRole("button", { name: "删除" }).click(); await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible(); await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click(); await expect(availableInvoiceCard).toHaveCount(0); await expect(failedInvoiceCard).toBeVisible(); await failedInvoiceCard.getByRole("button", { name: "删除" }).click(); await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible(); await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click(); await expect(failedInvoiceCard).toHaveCount(0); await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible(); await invoiceDialog.getByRole("button", { name: "上传" }).click(); await expect(invoiceDialog.getByText("选择 PDF 发票")).toBeVisible(); await expect(invoiceDialog.getByText("拍照或选图片")).toHaveCount(0); await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", ".pdf,application/pdf"); await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).not.toHaveAttribute("capture", /.+/); await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r111 mobile invoice"), }); await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click(); await expect(mobile.page.getByTestId("reimbursement-invoice-pending-hint")).toHaveCount(1); await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).not.toContainText("识别完成后可在"); await expect( mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"), ).toBeVisible({ timeout: 15_000 }); await mobile.page.keyboard.press("Escape"); await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible(); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 0"); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("数电发票 · 12345678"); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("电子火车票 · 12345679"); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码"); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("未识别销售方"); const mobileSectionOrder = await mobile.page.locator("[data-testid='mobile-reimbursement-detail-focus'], [data-testid='mobile-reimbursement-items'], [data-testid='mobile-reimbursement-materials'], [data-testid='mobile-reimbursement-processing-section']") .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); expect(mobileSectionOrder).toEqual([ "mobile-reimbursement-detail-focus", "mobile-reimbursement-items", "mobile-reimbursement-materials", "mobile-reimbursement-processing-section", ]); const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first(); await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0); await expect(mobileItem).not.toContainText("电子火车票"); await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0); await mobile.page.getByRole("button", { name: "添加附件" }).click(); await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({ name: "付款截图.png", mimeType: "image/png", buffer: Buffer.from("r111 mobile attachment"), }); await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click(); await expect(mobile.page.getByText("付款截图.png")).toBeVisible(); await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("合成差旅概要"); await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("测试地点"); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1"); const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all"); const openAllTarget = await openAllButton.boundingBox(); expect(openAllTarget?.height ?? 0).toBeGreaterThanOrEqual(44); await openAllButton.click(); await expect.poll(() => mobile.page.evaluate(() => ( window as typeof window & { __r111BridgeCalls: Array<{ name: string }> } ).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1); const nativePreviewCalls = await mobile.page.evaluate(() => ( window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record }> } ).__r111BridgeCalls.filter((call) => call.name === "previewFile")); expect(nativePreviewCalls).toEqual([ expect.objectContaining({ params: expect.objectContaining({ url: expect.stringContaining("previewToken=merged-pdf"), name: "reimbursement-materials-preview.pdf", size: 8192, }), }), ]); expect(await mobile.page.evaluate(() => ( window as typeof window & { __r111OpenCalls: unknown[][] } ).__r111OpenCalls.length)).toBe(0); await mobile.page.setViewportSize({ width: 667, height: 375 }); const landscapeMetrics = await collectVisualMetrics(mobile.page); expect(landscapeMetrics.scrollWidth).toBeLessThanOrEqual(landscapeMetrics.width + 2); await mobile.page.setViewportSize({ width: 390, height: 844 }); const bodyText = await mobile.page.locator("body").innerText(); expect(bodyText).not.toContain("绑定整单"); expect(bodyText).not.toContain("不绑定明细"); expect(bodyText).not.toContain("发票池"); expect(bodyText).not.toContain("OCR"); await mobile.page.screenshot({ path: screenshotPath("mobile-wecom-reimbursement-detail.png"), fullPage: true }); } finally { await mobile.context.close(); } }); });