import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test"; import { ADMIN } from "./helpers/auth"; import { TEST_IDS } from "./helpers/testIds"; function envelope(data: unknown) { return { requestId: "r92-mock", data }; } async function fulfillJson(route: Route, data: unknown) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { await testInfo.attach(name, { body: await page.screenshot({ fullPage: true }), contentType: "image/png", }); } async function mockAuthenticatedShell(page: Page) { await page.route((url) => url.pathname === "/api/auth/sso/providers", (route) => fulfillJson(route, [])); await page.route((url) => url.pathname === "/api/auth/wecom/config", (route) => fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/mobile", status: "disabled", statusReasons: ["disabled"], }), ); await page.route((url) => url.pathname === "/api/auth/login", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }, body: JSON.stringify(envelope({ accessToken: "r92-access-token", expiresIn: 900, mustChangePassword: false, })), }); }); await page.route((url) => url.pathname === "/api/auth/refresh", (route) => fulfillJson(route, { accessToken: "r92-access-token", expiresIn: 900, mustChangePassword: false, }), ); await page.route((url) => url.pathname === "/api/me", (route) => fulfillJson(route, { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee", phone: "13800000000", email: "smoke@hyinda.com", departmentId: "dept-1", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["admin"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50, }, wecomBinding: null, }), ); await page.route((url) => url.pathname === "/api/system/navigation", (route) => fulfillJson(route, { workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "mobile", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null, }, sections: { main: [ { key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "mobile", href: "/mobile", label: "移动工作台", icon: "phone_iphone", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "expense", href: "/reimbursements", label: "我的报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "invoice", href: "/invoice-pool", label: "票据中心", icon: "receipt_long", placement: "main", permissionCodes: [], featureFlagKey: null }, ], admin: [], backup: [], }, recommendedRolePresets: [], }), ); await page.route((url) => url.pathname === "/api/notifications", (route) => fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: true, unreadCount: 0, items: [] }), ); await page.route((url) => url.pathname === "/api/portal/home", (route) => fulfillJson(route, { workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" }, greeting: { name: "Smoke Employee" }, cards: [], pendingTasks: { count: 0, href: "/todos" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], todayPresence: null, teamPresence: null, weeklyReport: null, weeklySummary: null, todoSummary: null, projectSummary: null, peopleLifecycle: null, adoptionSummary: null, recentActivities: [], teamStatusBoard: [], }), ); } function reimbursementListItem() { return { id: "19439274-66ff-4301-964d-fcb43c304454", formNumber: null, status: "draft", reimbursementDate: "2026-05-05", totalAmount: "0.00", description: null, payment: { paymentVoucherNo: null, paymentDate: null, paymentChannel: null, paidAt: null, paidBy: null, }, owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, submitter: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, project: null, pendingWorkflow: null, createdAt: null, updatedAt: null, }; } async function mockReimbursements(page: Page) { await page.route((url) => url.pathname === "/api/reimbursement-templates", (route) => fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }), ); await page.route((url) => url.pathname === "/api/reimbursements/linkable-travel-requests", (route) => fulfillJson(route, { items: [] }), ); await page.route((url) => url.pathname === "/api/reimbursements", (route) => fulfillJson(route, { page: 1, pageSize: 20, total: 1, scope: "own", items: [reimbursementListItem()], }), ); } const invoiceListItem = { id: "invoice-pending-1", status: "pending_ocr", ocrType: "vat", ocrProvider: "mock", ocrAttempts: 0, ocrLastError: null, ocrCompletedAt: null, invoiceCode: null, invoiceNumber: null, invoiceDate: null, amount: null, taxAmount: null, totalAmount: null, linkedReimbursementId: null, linkedItemId: null, linkedTravelOrderId: null, originalName: "pending.pdf", mimeType: "application/pdf", fileSize: 128, verification: null, owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, uploadedBy: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" }, createdAt: null, updatedAt: null, }; function invoiceDetail(id: string, status: "pending_ocr" | "available" = "available") { return { id, status, archivedReason: null, archivedAt: null, ownerEmployeeId: "employee-mock-1", uploadedByEmployeeId: "employee-mock-1", originalName: `${id}.pdf`, mimeType: "application/pdf", fileSize: 128, fileSha256: `sha-${id}`, remarks: null, ocrType: "vat", ocrProvider: "mock", ocrAttempts: status === "pending_ocr" ? 0 : 1, ocrLastRequestedAt: null, ocrLastError: null, ocrCompletedAt: status === "pending_ocr" ? null : "2026-05-05T13:00:00.000Z", invoiceCode: status === "available" ? "MOCKCODE" : null, invoiceNumber: status === "available" ? "MOCKNO" : null, invoiceDate: status === "available" ? "2026-05-05" : null, sellerName: status === "available" ? "Mock Seller" : null, buyerName: status === "available" ? "Mock Buyer" : null, amount: status === "available" ? "100.00" : null, taxAmount: status === "available" ? "10.00" : null, totalAmount: status === "available" ? "110.00" : null, linkedReimbursementId: null, linkedItemId: null, linkedTravelOrderId: null, verification: null, isDuplicateOverride: false, overrideRefFormNumber: null, overrideReason: null, overriddenBy: null, createdAt: null, updatedAt: null, }; } async function mockInvoicePool(page: Page, uploadCounter: { value: number }) { await page.route((url) => url.pathname === "/api/travel-requests", (route) => fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] }), ); await page.route((url) => url.pathname.startsWith("/api/invoice-pool"), (route) => { const pathname = new URL(route.request().url()).pathname; if (route.request().method() === "POST" && pathname === "/api/invoice-pool/upload") { uploadCounter.value += 1; return fulfillJson(route, { invoicePoolId: `invoice-upload-${uploadCounter.value}`, status: uploadCounter.value === 1 ? "pending_ocr" : "available", fileSha256: `sha-upload-${uploadCounter.value}`, }); } if (pathname.endsWith("/verification")) { return fulfillJson(route, { invoiceId: "invoice-pending-1", items: [] }); } if (pathname === "/api/invoice-pool") { return fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceListItem], }); } const id = pathname.split("/").at(-1) ?? "invoice-pending-1"; return fulfillJson( route, invoiceDetail(id, id === "invoice-pending-1" || id === "invoice-upload-1" ? "pending_ocr" : "available"), ); }); } async function loginThenUseMobileViewport(page: Page) { await page.setViewportSize({ width: 1280, height: 800 }); await page.goto("/login"); await page.getByTestId(TEST_IDS.login.identifier).fill(ADMIN.identifier); await page.getByTestId(TEST_IDS.login.password).fill(ADMIN.password); await page.getByTestId(TEST_IDS.login.submit).click(); await expect(page).toHaveURL(/\/portal/); await page.setViewportSize({ width: 390, height: 844 }); } test.describe("R92 手机端票据与报销可用性", () => { test.beforeEach(async ({ page }) => { await mockAuthenticatedShell(page); await mockReimbursements(page); }); test("报销草稿在手机视口使用卡片并暴露继续编辑入口", async ({ page }, testInfo) => { await loginThenUseMobileViewport(page); await page.goto("/reimbursements?status=draft&scope=own"); await expect(page.getByTestId("reimbursement-mobile-card")).toBeVisible(); await expect(page.getByTestId("reimbursements-table")).toBeHidden(); const editLink = page.getByTestId("reimbursement-mobile-detail-link"); await expect(editLink).toHaveText("继续编辑"); const box = await editLink.boundingBox(); expect(box).not.toBeNull(); expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390); await attachScreenshot(page, testInfo, "r97-reimbursements-mobile-draft.png"); }); test("票据中心手机视口可批量选择并顺序上传", async ({ page }, testInfo) => { const uploadCounter = { value: 0 }; await mockInvoicePool(page, uploadCounter); await loginThenUseMobileViewport(page); await page.goto("/invoice-pool"); await expect(page.getByTestId("invoice-mobile-row")).toBeVisible(); await expect(page.getByTestId("invoice-pool-table")).toBeHidden(); await page.getByTestId("invoice-mobile-detail-button").click(); await expect(page.getByTestId("invoice-detail-panel")).toBeVisible(); await expect(page.getByText("票据详情")).toBeVisible(); await attachScreenshot(page, testInfo, "r97-invoice-pool-mobile.png"); await page.getByTestId("invoice-upload-file").setInputFiles([ { name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") }, { name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") }, ]); await expect(page.getByText("已选择 2 个文件")).toBeVisible(); await page.getByTestId("invoice-upload-submit").click(); await expect.poll(() => uploadCounter.value).toBe(2); }); test("手机票据上传页支持一次选择多张并逐张上传", async ({ page }, testInfo) => { const uploadCounter = { value: 0 }; await mockInvoicePool(page, uploadCounter); await loginThenUseMobileViewport(page); await page.goto("/mobile/capture"); await page.getByTestId("mobile-capture-file").setInputFiles([ { name: "mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-a") }, { name: "mobile-b.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-b") }, ]); await expect(page.getByText("已选择 2 个")).toBeVisible(); await attachScreenshot(page, testInfo, "r97-mobile-capture-multi-upload.png"); await page.getByTestId("mobile-capture-upload").click(); await expect.poll(() => uploadCounter.value).toBe(2); }); });