import { expect, test, type Page, type Route } from "@playwright/test"; test.use({ serviceWorkers: "block" }); function envelope(data: unknown) { return { requestId: "mock-r102-reimbursement", data }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } const me = { id: "employee-r102", employeeNo: "E102", name: "陈工程", phone: "13800000000", email: "employee@hyinda.com", departmentId: "dept-r102", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["employee"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50, }, wecomBinding: null, }; const reimbursementId = "11111111-1111-4111-8111-111111111111"; const itemId = "22222222-2222-4222-8222-222222222222"; const invoiceId = "33333333-3333-4333-8333-333333333333"; const formNumber = "BX-20260506-0001"; function navigationItem(key: string, href: string, label: string, icon: string) { return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; } function reimbursementListItem() { return { id: reimbursementId, formNumber, status: "draft", reimbursementDate: "2026-05-06", totalAmount: "168.30", description: "办公室宽带费用", payment: { paymentVoucherNo: null, paymentDate: null, paymentChannel: null, paidAt: null, paidBy: null, }, owner: { id: me.id, employeeNo: me.employeeNo, name: me.name }, submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name }, project: null, pendingWorkflow: null, createdAt: "2026-05-06T09:00:00.000Z", updatedAt: "2026-05-06T09:10:00.000Z", }; } function reimbursementDetail(overrides: { reimbursementDate?: string; description?: string } = {}) { return { id: reimbursementId, formNumber, submitterId: me.id, ownerId: me.id, departmentId: me.departmentId, department: { id: "dept-r102", name: "研发部" }, status: "draft", createdAt: "2026-05-06T09:00:00.000Z", submittedAt: null, reimbursementDate: overrides.reimbursementDate ?? "2026-05-06", totalAmount: "168.30", description: overrides.description ?? "办公室宽带费用", projectId: null, linkedTravelRequestId: null, linkedTravelRequest: null, project: null, owner: { id: me.id, employee_no: me.employeeNo, name: me.name }, submitter: { id: me.id, employee_no: me.employeeNo, name: me.name }, items: [ { id: itemId, expenseTypeId: "expense-office-communication", expenseDate: "2026-05-06", amount: "168.30", description: "办公室宽带费用", expenseType: { id: "expense-office-communication", code: "office_communication", name: "通讯费用", category: "office", enabled: true, }, }, ], invoices: [], attachments: [], budgetHint: null, pendingWorkflow: null, workflowProgress: null, approvalHistory: [], riskFlags: [], }; } async function mockApis(page: Page) { let draftExists = true; let detailReimbursementDate = "2026-05-06"; let detailDescription = "办公室宽带费用"; await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname; if (path === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (path === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [], }); return; } if (path === "/api/auth/login" || path === "/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-r102-token", expiresIn: 900, mustChangePassword: false })), }); return; } if (path === "/api/me") { await fulfillJson(route, me); return; } if (path === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-r102", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "desktop", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null, }, sections: { main: [ navigationItem("portal", "/portal", "工作台", "home_app_logo"), navigationItem("expense", "/reimbursements", "报销", "request_quote"), navigationItem("leave", "/leave-requests", "请假记录", "event_available"), ], admin: [], backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")], }, recommendedRolePresets: [], }); return; } if (path === "/api/portal/home") { await fulfillJson(route, { workspace: { id: "workspace-r102", persisted: true, profile: "small_team_internal" }, greeting: { name: me.name }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [{ domain: "reimbursement", count: draftExists ? 1 : 0, href: "/reimbursements" }], riskSummary: null, announcements: [], quickLinks: [], todayPresence: null, teamPresence: null, weeklyReport: null, weeklySummary: null, todoSummary: null, projectSummary: null, peopleLifecycle: null, adoptionSummary: null, recentActivities: [], teamStatusBoard: [], }); return; } if (path === "/api/reimbursement-templates") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); return; } if (path === "/api/reimbursements/linkable-travel-requests") { await fulfillJson(route, { ownerEmployeeId: me.id, items: [] }); return; } if (path === "/api/reimbursements" && request.method() === "GET") { const items = draftExists ? [reimbursementListItem()] : []; await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: "own", items }); return; } if (path === "/api/reimbursements" && request.method() === "POST") { draftExists = true; await fulfillJson(route, { id: reimbursementId, formNumber, submitterId: me.id, ownerId: me.id, reimbursementDate: "2026-05-06", status: "draft", description: null, projectId: null, project: null, totalAmount: "0", linkedTravelRequestId: null, linkedTravelRequest: null, items: [], }); return; } if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") { await fulfillJson(route, reimbursementDetail({ reimbursementDate: detailReimbursementDate, description: detailDescription, })); return; } if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") { const body = request.postDataJSON() as { reimbursementDate?: string; description?: string | null }; detailReimbursementDate = body.reimbursementDate ?? detailReimbursementDate; detailDescription = body.description ?? ""; await fulfillJson(route, { id: reimbursementId, status: "draft", reimbursementDate: detailReimbursementDate, description: detailDescription || null, totalAmount: "168.30", travelSummary: { purpose: null, location: null, startDate: null, endDate: null }, }); return; } if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") { draftExists = false; await fulfillJson(route, { reimbursementId, outcome: "deleted", unboundInvoiceCount: 0 }); return; } if (path === `/api/reimbursements/${reimbursementId}/precheck`) { await fulfillJson(route, { entityType: "reimbursement", entityId: reimbursementId, decision: "warn", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], evidenceSummary: { invoiceCount: 0, attachmentCount: 0, items: [{ itemId, linkedInvoiceCount: 0, invoiceState: "unlinked" }], }, timelinessChecks: [], assetHint: { required: false, itemCount: 0 }, warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["确认提示后可继续提交"], }); return; } if (path === "/api/expense-types") { await fulfillJson(route, { items: [] }); return; } if (path === "/api/projects") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); return; } if (path === "/api/invoice-pool/bindable") { await fulfillJson(route, { items: [ { id: invoiceId, invoiceCode: "26112000000938423851", invoiceNumber: "38423851", invoiceDate: "2026-05-06", sellerName: "北京悦海堂餐饮管理有限公司", totalAmount: "168.30", originalName: "发票.pdf", createdAt: "2026-05-06T09:30:00.000Z", }, ], }); return; } if (path === `/api/invoice-pool/${invoiceId}/bind`) { await fulfillJson(route, { outcome: "bound" }); return; } if (path === "/api/notifications") { await fulfillJson(route, { items: [], total: 0, page: 1, pageSize: 5, unreadCount: 0 }); return; } await fulfillJson(route, {}); }); } async function login(page: Page) { await page.goto("/login"); await page.getByTestId("login-identifier").fill("employee@hyinda.com"); await page.getByTestId("login-password").fill("EmployeePassword1234"); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/portal/); } test.describe("R102 reimbursement main flow", () => { test.beforeEach(async ({ page }) => { await mockApis(page); await login(page); }); test("PC 列表显示业务编号并可删除草稿", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto("/reimbursements?status=draft"); await expect(page.getByTestId("reimbursements-tab-draft")).toBeVisible(); await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible(); await expect(page.getByText(reimbursementId)).toHaveCount(0); await page.getByRole("button", { name: "更多" }).first().click(); await page.getByRole("menuitem", { name: "删除草稿" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber); await page.getByTestId("action-confirm-submit").click(); await expect(page.getByTestId("reimbursement-row")).toHaveCount(0); await expect(page.getByTestId("reimbursements-table").getByText("还没有报销草稿")).toBeVisible(); }); test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto(`/reimbursements/${reimbursementId}`); await expect(page.getByRole("heading", { name: `报销单 ${formNumber}` })).toBeVisible(); await expect(page.getByText("报销ID")).toHaveCount(0); await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible(); await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible(); await expect(page.getByTestId("approval-attachments")).toBeVisible(); await expect(page.getByText("执行支付")).toHaveCount(0); await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0); await page.getByTestId("reimbursement-more-toggle").click(); await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible(); await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0); await page.getByTestId("reimbursement-invoice-add").click(); await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible(); await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("添加后自动生成或匹配报销明细"); await expect(page.getByText("绑定整单")).toHaveCount(0); await expect(page.getByText("不绑定明细")).toHaveCount(0); await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveCount(0); const uploadInput = page.getByTestId("reimbursement-invoice-upload-file"); await expect(uploadInput).toHaveAttribute("multiple", ""); await expect(page.getByRole("button", { name: "上传发票" })).toBeVisible(); await page.getByRole("checkbox").first().check(); await expect(page.getByText("已选 1 张发票")).toBeVisible(); const bindResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/invoice-pool/${invoiceId}/bind` && response.ok(); }); await page.getByRole("button", { name: "添加选中发票" }).click(); await bindResponsePromise; await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0); }); test("PC 详情显示权威合计并可编辑报销日期和事由", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto(`/reimbursements/${reimbursementId}`); await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥168.30"); await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计"); await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30"); await expect(page.getByTestId("reimbursement-expense-items-table").getByRole("columnheader")).toHaveText(["序号", "费用类型", "发生日期", "费用说明", "金额", "票据", "操作"]); await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联"); await expect(page.getByTestId("approval-item-evidence")).toContainText("这条明细尚无对应票据"); await page.getByRole("button", { name: "编辑", exact: true }).first().click(); const editor = page.getByTestId("reimbursement-header-editor"); await editor.getByLabel("报销日期").fill("2026-05-07"); await editor.getByLabel("报销事由").fill("办公网络费用"); await editor.getByRole("button", { name: "保存", exact: true }).click(); await expect(page.getByTestId("reimbursement-header-editor")).toHaveCount(0); await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("办公网络费用"); await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月7日"); }); test("移动报销卡片显示业务编号", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const listRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return request.method() === "GET" && url.pathname === "/api/reimbursements"; }); await page.goto("/mobile/reimbursements?scope=all"); const requestedUrl = new URL((await listRequest).url()); expect(requestedUrl.searchParams.get("scope")).toBe("own"); await expect(page.getByRole("heading", { name: "移动报销", exact: true })).toBeVisible(); await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveCount(0); await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveCount(0); await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible(); await expect(page.getByText(formNumber)).toBeVisible(); await expect(page.getByText(reimbursementId)).toHaveCount(0); await expect(page.locator("table:visible")).toHaveCount(0); await page.getByTestId("mobile-reimbursement-card").getByRole("link").click(); await expect(page.getByTestId("reimbursement-approval-evidence").getByTestId("approval-item-evidence")).toHaveCount(1); await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥168.30"); await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计"); await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30"); }); test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => { const adminMe = { ...me, id: "admin-r102", employeeNo: "A102", name: "测试管理员", roleCodes: ["admin"], }; const otherReimbursementId = "44444444-4444-4444-8444-444444444444"; const requestedScopes: Array = []; await page.route("**/api/me", async (route) => { await fulfillJson(route, adminMe); }); await page.route(`**/api/reimbursements/${otherReimbursementId}`, async (route) => { if (route.request().method() !== "GET") { await route.fallback(); return; } await fulfillJson(route, { ...reimbursementDetail(), id: otherReimbursementId, formNumber: "BX-20260731-0002", status: "draft", ownerId: "employee-other-r102", submitterId: "employee-other-r102", owner: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" }, submitter: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" }, invoices: [{ id: "invoice-other-r102", status: "bound", invoiceCode: "SYNTHETIC", invoiceNumber: "R102-OTHER", invoiceDate: "2026-07-31", sellerName: "合成供应方", buyerName: "合成购买方", totalAmount: "168.30", originalName: "合成发票.pdf", mimeType: "application/pdf", fileSize: 1024, linkedItemId: null, }], }); }); await page.route((url) => url.pathname === "/api/reimbursements", async (route) => { const request = route.request(); if (request.method() !== "GET") { await route.fallback(); return; } const url = new URL(request.url()); const scope = url.searchParams.get("scope"); requestedScopes.push(scope); const ownItem = { ...reimbursementListItem(), status: "reviewing", owner: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name }, submitter: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name }, }; const otherItem = { ...ownItem, id: otherReimbursementId, formNumber: "BX-20260731-0002", owner: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" }, submitter: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" }, }; const items = scope === "all" ? [ownItem, otherItem] : [ownItem]; await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: scope ?? "own", items }); }); await page.reload(); await expect(page).toHaveURL(/\/portal/); await page.setViewportSize({ width: 1440, height: 960 }); await page.goto(`/reimbursements/${otherReimbursementId}`); await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible(); await expect(page.getByTestId("reimbursement-withdraw")).toHaveCount(0); await expect(page.getByTestId("reimbursement-withdraw-comment")).toHaveCount(0); await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0); await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0); await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("R102-OTHER"); await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联"); await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0); await page.setViewportSize({ width: 375, height: 812 }); await page.goto("/mobile/reimbursements?status=reviewing"); await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveAttribute("data-state", "active"); await expect(page.getByText("其他员工")).toHaveCount(0); expect(requestedScopes.at(-1)).toBe("own"); const ownBounds = await page.getByTestId("mobile-reimbursements-scope-own").boundingBox(); const allBounds = await page.getByTestId("mobile-reimbursements-scope-all").boundingBox(); expect(ownBounds?.height ?? 0).toBeGreaterThanOrEqual(44); expect(allBounds?.height ?? 0).toBeGreaterThanOrEqual(44); const allResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/reimbursements" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("mobile-reimbursements-scope-all").click(); await allResponse; await expect(page).toHaveURL(/scope=all/); await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveAttribute("data-state", "active"); await expect(page.getByText("其他员工")).toBeVisible(); expect(requestedScopes.at(-1)).toBe("all"); await expect(page.getByTestId("mobile-reimbursement-delete")).toHaveCount(0); await page.getByText("其他员工").click(); await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`)); await expect(page.getByText("只读查看")).toBeVisible(); await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /新增|编辑|删除/ })).toHaveCount(0); await expect(page.getByTestId("approval-unlinked-invoices")).toBeVisible(); await expect(page.getByTestId("approval-item-evidence")).toContainText("票据:未关联"); await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0); const routeState = await page.evaluate(() => window.history.state?.usr ?? null); expect(routeState?.from).toContain("status=reviewing"); expect(routeState?.from).toContain("scope=all"); await page.goBack(); await page.setViewportSize({ width: 667, height: 375 }); await expect(page.getByTestId("mobile-reimbursements-scope-all")).toBeVisible(); const horizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ); expect(horizontalOverflow).toBeLessThanOrEqual(2); }); test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(`/reimbursements/${reimbursementId}`); await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑", exact: true }).click(); const addItem = page.getByTestId("reimbursement-item-add"); await addItem.click(); await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("报销表单尚未保存"); await page.getByTestId("unsaved-changes-continue").click(); await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`)); await expect(page.getByTestId("reimbursement-item-editor")).toContainText("编辑明细"); await addItem.click(); await page.getByTestId("unsaved-changes-discard").click(); await expect(page.getByTestId("reimbursement-item-editor")).toContainText("新增明细"); }); });