import { mkdirSync, mkdtempSync } 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_ROOT = process.env.QA_VISUAL_OUTPUT_DIR || path.join(ROOT_DIR, "output/playwright/r125-invoice-reupload-after-delete"); mkdirSync(SCREENSHOT_ROOT, { recursive: true }); const SCREENSHOT_DIR = mkdtempSync(path.join(SCREENSHOT_ROOT, "r125-")); test.use({ serviceWorkers: "block" }); 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 = "12511111-1111-4111-8111-111111111111"; const ownerId = "employee-r125"; const reusableInvoiceId = "12522222-2222-4222-8222-222222222222"; const archivedInvoiceId = "12533333-3333-4333-8333-333333333333"; const newInvoiceId = "12544444-4444-4444-8444-444444444444"; function envelope(data: unknown) { return { requestId: "mock-r125-invoice-reupload", 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`); } async function expectNoRawReimbursementCodes(page: Page) { const bodyText = await page.locator("body").innerText(); 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-r125"); } function buildMe() { return { id: ownerId, employeeNo: "E125", name: "测试员工", phone: "13800000000", email: "employee@hyinda.com", departmentId: "dept-r125", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["employee"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 60, }, wecomBinding: { externalUserId: "wxwork-r125-user", createdAt: "2026-05-08T08:00:00.000Z" }, }; } function buildExpenseTypes() { return { items: [ { id: "expense-type-r125", code: "business_client_meal", name: "业务招待", category: "expense", enabled: true, }, ], }; } function buildBoundInvoice(id: string) { return { id, status: "bound", invoiceCode: "044002600125", invoiceNumber: "12345678", invoiceDate: "2026-05-08", sellerName: "北京悦海堂餐饮管理有限公司", totalAmount: "1683.00", originalName: "北京悦海堂发票.pdf", linkedItemId: "item-r125", }; } function buildAvailableInvoice() { return { id: archivedInvoiceId, invoiceCode: "044002600125", invoiceNumber: "87654321", invoiceDate: "2026-05-08", sellerName: "北京悦海堂餐饮管理有限公司", totalAmount: "1683.00", originalName: "已有发票.pdf", createdAt: "2026-05-08T08:00:00.000Z", }; } function buildState() { return { invoices: [] as ReturnType[], availableInvoices: [buildAvailableInvoice()], uploadCount: 0, archived: false, bindRequests: [] as Array<{ id: string; reimbursementId?: string; linkedItemId?: string }>, }; } function buildReimbursementDetail(state: ReturnType) { return { id: reimbursementId, formNumber: "BX202605080125", submitterId: ownerId, ownerId, departmentId: "dept-r125", department: { id: "dept-r125", name: "研发部" }, status: "draft", createdAt: "2026-05-08T08:00:00.000Z", submittedAt: null, reimbursementDate: "2026-05-08", totalAmount: "1683.00", description: "R125 发票重传", projectId: null, linkedTravelRequestId: null, linkedTravelRequest: null, project: null, owner: { id: ownerId, employee_no: "E125", name: "测试员工" }, submitter: { id: ownerId, employee_no: "E125", name: "测试员工" }, invoices: state.invoices, attachments: [], budgetHint: null, pendingWorkflow: null, workflowProgress: null, approvalHistory: [], riskFlags: [], items: [ { id: "item-r125", expenseTypeId: "expense-type-r125", expenseDate: "2026-05-08", amount: "1683.00", description: "客户招待", expenseType: { id: "expense-type-r125", 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/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-r125-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-r125", 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-r125", persisted: true, profile: "small_team_internal" }, greeting: { name: "测试员工" }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], 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/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: 0, ownerScope: "own", items: [] }); return; } if (pathName === "/api/invoice-pool/upload") { state.uploadCount += 1; const invoicePoolId = state.archived ? newInvoiceId : reusableInvoiceId; state.availableInvoices = [...state.availableInvoices.filter((item) => item.id !== invoicePoolId), { ...buildAvailableInvoice(), id: invoicePoolId }]; await fulfillJson(route, { invoicePoolId, status: "available", fileSha256: "sha256-r125", uploadAction: state.uploadCount === 1 ? "create" : "reuse_existing", unavailableReason: null, unavailableMessage: null, }); return; } const bindMatch = pathName.match(/^\/api\/invoice-pool\/([^/]+)\/bind$/); if (bindMatch) { const payload = request.postDataJSON() as { reimbursementId?: string; linkedItemId?: string }; state.bindRequests.push({ id: bindMatch[1], ...payload }); state.invoices = [buildBoundInvoice(bindMatch[1])]; state.availableInvoices = state.availableInvoices.filter((item) => item.id !== bindMatch[1]); await fulfillJson(route, { id: bindMatch[1], status: "bound", linkedReimbursementId: reimbursementId, linkedItemId: "item-r125", linkedTravelOrderId: null, totalAmount: "1683.00", }); return; } const unbindMatch = pathName.match(/^\/api\/invoice-pool\/([^/]+)\/unbind$/); if (unbindMatch) { state.invoices = []; await fulfillJson(route, { id: unbindMatch[1], status: "available", linkedReimbursementId: null, linkedItemId: null, linkedTravelOrderId: null, totalAmount: "1683.00", }); return; } if (pathName === `/api/invoice-pool/${archivedInvoiceId}/archive`) { state.availableInvoices = []; state.archived = true; await fulfillJson(route, { id: archivedInvoiceId, status: "archived", archivedReason: "manual", archivedAt: "2026-05-08T08:20: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() }; } test.describe("R125 发票删除后重传", () => { test("PC 草稿移除发票后,同文件重传可重新添加", async ({ page }) => { const state = buildState(); await mockApis(page, state); await login(page); await page.setViewportSize({ width: 1440, height: 960 }); await page.goto(`/reimbursements/${reimbursementId}`); await page.getByTestId("reimbursement-invoice-add").click(); await page.getByLabel("新发票添加位置").selectOption("item-r125"); await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r125 invoice"), }); await page.getByTestId("reimbursement-invoice-upload-submit").click(); await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 }); expect(state.bindRequests).toHaveLength(0); await page.getByRole("button", { name: "添加到报销单", exact: true }).click(); await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible(); expect(state.bindRequests).toEqual([{ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" }]); await page.locator(`#reimbursement-invoice-${reusableInvoiceId}`).getByRole("button", { name: "移除对应关系", exact: true }).click(); await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toHaveCount(0); await page.getByTestId("reimbursement-invoice-add").click(); await page.getByLabel("新发票添加位置").selectOption("item-r125"); await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r125 invoice"), }); await page.getByTestId("reimbursement-invoice-upload-submit").click(); await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 }); expect(state.bindRequests).toHaveLength(1); await page.getByRole("button", { name: "添加到报销单", exact: true }).click(); await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible(); expect(state.bindRequests).toHaveLength(2); expect(state.bindRequests[1]).toEqual({ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" }); await expectNoRawReimbursementCodes(page); await page.screenshot({ path: screenshotPath("pc-reupload-after-unbind.png"), fullPage: true }); }); test("企业微信移动端删除已有发票后,同文件重传可重新添加", async ({ browser }) => { const state = buildState(); const mobile = await newWecomPage(browser); await mockApis(mobile.page, state); try { await login(mobile.page); await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`); await mobile.page.getByTestId("reimbursement-invoice-add").click(); const invoiceDialog = mobile.page.getByTestId("reimbursement-invoice-dialog"); await invoiceDialog.getByRole("button", { name: "已有发票" }).click(); const availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600125-87654321" }); await expect(availableInvoiceCard).toBeVisible(); await availableInvoiceCard.getByRole("button", { name: "删除" }).click(); await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click(); await expect(availableInvoiceCard).toHaveCount(0); await invoiceDialog.getByRole("button", { name: "上传" }).click(); await mobile.page.getByLabel("新发票添加位置").selectOption("item-r125"); await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r125 invoice"), }); await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click(); await expect(invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true })).toBeEnabled(); expect(state.bindRequests).toHaveLength(0); await invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true }).click(); await expect(invoiceDialog.getByText("已添加 1 张发票", { exact: true })).toBeVisible(); expect(state.archived).toBe(true); expect(state.bindRequests).toEqual([{ id: newInvoiceId, reimbursementId, linkedItemId: "item-r125" }]); await invoiceDialog.getByRole("button", { name: "返回报销单", exact: true }).click(); await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible(); await mobile.page.screenshot({ path: screenshotPath("mobile-reupload-after-archive.png"), fullPage: true }); } finally { await mobile.context.close(); } }); });