import { expect, test, type Page, type Route } from "@playwright/test"; test.use({ serviceWorkers: "block" }); type SyntheticActor = { id: string; employeeNo: string; name: string; email: string; roleCodes: string[]; }; type SyntheticContract = { id: string; contractNo: string; projectId: string | null; contractType: "purchase" | "sales" | "service" | "framework" | "other"; counterpartyName: string; summary: string; amount: string | null; currency: string; signedDate: string | null; effectiveDate: string | null; expiryDate: string | null; confidentialityLevel: "public" | "internal" | "confidential" | "restricted"; archiveLocation: string; offlineArchiveNo: string | null; ownerEmployeeId: string; status: "draft" | "active" | "expired" | "archived"; persistedStatus: "draft" | "active" | "archived"; createdAt: string | null; updatedAt: string | null; owner: { id: string; employeeNo: string; name: string } | null; project: { id: string; projectNo: string; name: string } | null; }; type MockScenario = { readiness?: { ready: boolean; blockers: Array<{ reasonCode: string }> }; createFailure?: string; paymentCandidates?: Array<{ entityId: string; entityType: "reimbursement"; businessNo: string; employee: { id: string; employeeNo: string; name: string }; amount: string; currencyCode: "CNY"; businessDate: string | null; summary: string | null; readiness?: { ready: boolean; blockers: Array<{ reasonCode: string }> }; }>; paymentCandidateTotal?: number; onCandidateQuery?: (query: URLSearchParams) => void; contracts?: SyntheticContract[]; onCreatePaymentBatch?: (payload: unknown) => void; }; const REVIEWER_ACTOR: SyntheticActor = { id: "employee-payment-reviewer", employeeNo: "SYN-REVIEWER", name: "合成付款复核人", email: "reviewer@example.test", roleCodes: ["manager"], }; const FINANCE_ACTOR: SyntheticActor = { id: "employee-payment-finance", employeeNo: "SYN-FINANCE", name: "合成财务人员", email: "finance@example.test", roleCodes: ["finance"], }; const CONTRACT_READER_ACTOR: SyntheticActor = { id: "employee-contract-reader", employeeNo: "SYN-CONTRACT-READER", name: "合成合同只读人员", email: "contract-reader@example.test", roleCodes: ["employee"], }; const NO_INVOICE_UPLOAD_ACTOR: SyntheticActor = { id: "employee-no-invoice-upload", employeeNo: "SYN-NO-INVOICE-UPLOAD", name: "合成无票据上传人员", email: "no-invoice-upload@example.test", roleCodes: ["hr"], }; const PAYMENT_CANDIDATE_ENTITY_ID = "11111111-1111-4111-8111-111111111111"; const CREATED_PAYMENT_BATCH_ID = "22222222-2222-4222-8222-222222222222"; const PAYMENT_CANDIDATE = { entityId: PAYMENT_CANDIDATE_ENTITY_ID, entityType: "reimbursement" as const, businessNo: "BX-SYN-20260718-01", employee: { id: "employee-payment-candidate", employeeNo: "SYN-PAYEE", name: "合成付款员工", }, amount: "1280.50", currencyCode: "CNY" as const, businessDate: "2026-07-18", summary: "合成已审批报销", }; const CONTRACT_ITEM: SyntheticContract = { id: "contract-synthetic-readonly", contractNo: "HT-SYN-READ-001", projectId: "project-synthetic-contract", contractType: "service", counterpartyName: "合成合作方", summary: "合成只读合同", amount: "8000.00", currency: "CNY", signedDate: "2026-07-01", effectiveDate: "2026-07-01", expiryDate: "2027-06-30", confidentialityLevel: "internal", archiveLocation: "受控线下归档", offlineArchiveNo: "SYN-ARCHIVE-001", ownerEmployeeId: CONTRACT_READER_ACTOR.id, status: "active", persistedStatus: "active", createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z", owner: { id: CONTRACT_READER_ACTOR.id, employeeNo: CONTRACT_READER_ACTOR.employeeNo, name: CONTRACT_READER_ACTOR.name, }, project: { id: "project-synthetic-contract", projectNo: "XM-SYN-001", name: "合成合同项目", }, }; function envelope(data: unknown) { return { requestId: "mock-payment-reviewer-permissions", data }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } function capturePageErrors(page: Page) { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); return errors; } function expectNoPageErrors(errors: string[]) { expect(errors, "页面不应产生未捕获异常").toEqual([]); } async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const requestUrl = new URL(request.url()); const pathName = requestUrl.pathname; if (pathName === "/api/auth/capabilities") { await fulfillJson(route, { emailDelivery: { ready: false }, emailLoginCode: { enabled: false }, passwordReset: { enabled: false }, }); return; } if (pathName === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (pathName === "/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 (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-token-${actor.employeeNo}`, expiresIn: 900, mustChangePassword: false, })), }); return; } if (pathName === "/api/me") { await fulfillJson(route, { ...actor, phone: null, departmentId: `department-${actor.employeeNo}`, directManagerId: null, status: "active", notificationPushEnabled: true, profileCompleteness: { hasEmergencyContact: true, hasBankAccount: false, hasAvatar: false, hasIdNumber: false, score: 40, }, wecomBinding: null, }); return; } if (pathName === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: `workspace-${actor.employeeNo}`, 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: [], admin: [], backup: [] }, recommendedRolePresets: [], }); return; } if (pathName === "/api/notifications") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: false, unreadCount: 0, items: [], }); return; } if (pathName === "/api/portal/home") { await fulfillJson(route, { workspace: { id: `workspace-${actor.employeeNo}`, persisted: true, profile: "small_team_internal" }, greeting: { name: actor.name }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], recentActivities: [], teamStatusBoard: [], }); return; } if (pathName === "/api/payment-batches/candidates") { const items = (scenario.paymentCandidates ?? []).map((item) => ({ ...item, readiness: item.readiness ?? { ready: true, blockers: [] } })); scenario.onCandidateQuery?.(requestUrl.searchParams); await fulfillJson(route, { items, page: Number(requestUrl.searchParams.get("page") ?? "1"), pageSize: Number(requestUrl.searchParams.get("pageSize") ?? "20"), total: scenario.paymentCandidateTotal ?? items.length, }); return; } if (pathName === "/api/payment-batches/readiness") { await fulfillJson(route, scenario.readiness ?? { ready: true, blockers: [] }); return; } if (pathName === "/api/payment-batches" && request.method() === "POST") { scenario.onCreatePaymentBatch?.(request.postDataJSON()); if (scenario.createFailure) { await route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ requestId: "synthetic-readiness-race", error: { code: "VALID_001", message: "internal payload", details: { reasonCode: scenario.createFailure, private: "/private/hidden" } } }) }); return; } await fulfillJson(route, { item: { id: CREATED_PAYMENT_BATCH_ID } }); return; } if (pathName === "/api/payment-batches") { await fulfillJson(route, { items: [] }); return; } if (pathName === "/api/risk-flags") { await fulfillJson(route, { items: [] }); return; } if (pathName === "/api/ap-operations") { await fulfillJson(route, { summary: { pendingPaymentCount: 0, partialMatchCount: 0, exceptionCount: 0, budgetOverspendCount: 0, }, pendingPaymentBatches: [], failedPaymentBatchItems: [], exceptionRiskFlags: [], budgetOverspendRisks: [], partialMatchItems: [], }); return; } if (pathName === "/api/ap-operations/reimbursement-ledger") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); return; } if (pathName === "/api/contracts-registry/expiring") { await fulfillJson(route, { days: 30, total: 0, items: [] }); return; } if (pathName === "/api/contracts-registry") { await fulfillJson(route, { items: scenario.contracts ?? [] }); return; } if (pathName === "/api/projects") { await fulfillJson(route, { items: [] }); return; } await fulfillJson(route, { message: "unhandled synthetic endpoint" }, 404); }); } async function login(page: Page, actor: SyntheticActor) { await page.goto("/login"); await page.getByTestId("login-identifier").fill(actor.email); await page.getByTestId("login-password").fill("SyntheticPassword1234"); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/(?:portal|mobile)$/); } test("只有付款复核职责的经理不能直连应付账款且不请求台账", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); await mockSyntheticApis(page, REVIEWER_ACTOR); await login(page, REVIEWER_ACTOR); let apRequests = 0; page.on("request", (request) => { if (new URL(request.url()).pathname.startsWith("/api/ap-operations")) apRequests += 1; }); await page.goto("/ap-operations"); await expect(page).toHaveURL(/\/403$/); await expect(page.getByRole("link", { name: "应付账款", exact: true })).toHaveCount(0); expect(apRequests).toBe(0); await expect(page.getByTestId("ap-export")).toHaveCount(0); expectNoPageErrors(pageErrors); }); for (const roleCodes of [["ceo"], ["finance"], ["finance_mgr"], ["manager", "finance"]]) { test(`AP 专用读取权限允许 ${roleCodes.join("+")} 进入台账且不提升导出权限`, async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const actor = { ...FINANCE_ACTOR, roleCodes }; await mockSyntheticApis(page, actor); await login(page, actor); await page.goto("/ap-operations"); await expect(page).toHaveURL(/\/ap-operations$/); await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible(); await page.getByRole("tab", { name: /^财务台账/ }).click(); await expect(page.getByTestId("finance-ledger-table")).toBeVisible(); if (roleCodes.includes("ceo")) await expect(page.getByTestId("ap-export")).toHaveCount(0); else await expect(page.getByTestId("ap-export")).toBeVisible(); }); } test("只有付款复核职责的经理看不到新建批次", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); let candidateQueryCount = 0; await mockSyntheticApis(page, REVIEWER_ACTOR, { onCandidateQuery: () => { candidateQueryCount += 1; }, }); await login(page, REVIEWER_ACTOR); await page.goto("/payment-batches?view=pending"); await expect(page).toHaveURL(/\/payment-batches\?view=batches$/); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /待组批/ })).toHaveCount(0); await expect(page.getByRole("button", { name: /付款批次/ })).toBeVisible(); expect(candidateQueryCount).toBe(0); expectNoPageErrors(pageErrors); }); test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] }); await login(page, CONTRACT_READER_ACTOR); await page.goto("/contracts-registry"); await expect(page).toHaveURL(/\/contracts-registry$/); await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0); const contractRow = page .getByTestId("contracts-registry-table") .getByRole("row") .filter({ hasText: CONTRACT_ITEM.contractNo }); await expect(contractRow).toBeVisible(); await contractRow.getByRole("button", { name: "更多", exact: true }).click(); await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0); await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0); await expect(page).toHaveURL(/\/contracts-registry$/); expectNoPageErrors(pageErrors); }); test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); await mockSyntheticApis(page, NO_INVOICE_UPLOAD_ACTOR); await login(page, NO_INVOICE_UPLOAD_ACTOR); await page.goto("/invoice-pool"); await expect(page).toHaveURL(/\/403$/); await expect(page.getByRole("heading", { level: 1, name: "无权限访问", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "上传票据", exact: true })).toHaveCount(0); expectNoPageErrors(pageErrors); }); for (const [reasonCode, message] of [ ["payment_batch.legal_entity_required", "报销人尚未维护所属公司,请联系管理员补充后再安排付款。"], ["payment_batch.payee_bank_required", "收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。"], ["payment_batch.payee_account_required", "收款人尚未维护银行卡,请联系管理员补充真实收款资料。"], ["payment_batch.payee_bank_unsupported", "当前银行付款仅支持建行收款账户,请停止本次银行付款并联系财务确认处理方式,不要修改真实开户行。"], ["payment_batch.active_batch_exists", "所选单据已进入其他付款安排,请刷新列表并查看已有批次。"], ["payment_batch.reviewer_required", "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。"], ]) { test(`付款候选就近解释并禁用 ${reasonCode}`, async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据"); await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode }] } }] }); await login(page, FINANCE_ACTOR); await page.goto("/payment-batches"); const row = page.getByTestId("payment-batch-candidate-row"); await expect(row.getByText(message, { exact: true })).toBeVisible(); await expect(row.getByRole("checkbox")).toBeDisabled(); await expect(row.getByRole("checkbox")).toHaveAccessibleDescription(message); await expect(row.getByRole("link")).toHaveCount(0); }); } test("跨公司组合原因紧邻禁用主动作并可被读屏读取", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据"); let creates = 0; await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.multiple_legal_entities" }] }, onCreatePaymentBatch: () => { creates += 1; } }); await login(page, FINANCE_ACTOR); await page.goto("/payment-batches"); const checkbox = page.getByTestId("payment-batch-candidate-row").getByRole("checkbox"); await checkbox.focus(); await page.keyboard.press("Space"); await expect(checkbox).toBeChecked(); const button = page.getByRole("button", { name: "创建并提交复核", exact: true }); await expect(button).toBeDisabled(); await expect(button).toHaveAccessibleDescription("所选单据属于不同公司,请按公司分别创建付款安排。"); await expect(page.locator("#payment-creation-reason")).toHaveAttribute("role", "alert"); expect(creates).toBe(0); }); test("可先换渠道再选择,已选项失效后仍可取消", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据"); const scenario: MockScenario = { paymentCandidates: [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] } }] }; await mockSyntheticApis(page, FINANCE_ACTOR, scenario); await login(page, FINANCE_ACTOR); await page.goto("/payment-batches"); const checkbox = page.getByTestId("payment-batch-candidate-row").getByRole("checkbox"); await expect(checkbox).toBeDisabled(); const channel = page.getByTestId("payment-batch-default-channel"); await expect(channel).toBeVisible(); scenario.paymentCandidates = [{ ...PAYMENT_CANDIDATE, readiness: { ready: true, blockers: [] } }]; await channel.selectOption("cash"); await expect(checkbox).toBeEnabled(); await checkbox.check(); scenario.paymentCandidates = [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] } }]; scenario.readiness = { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] }; await channel.selectOption("bank"); await expect(page.getByTestId("payment-batch-candidate-row")).toContainText("收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。"); await expect(checkbox).toBeChecked(); await expect(checkbox).toBeEnabled(); await checkbox.uncheck(); await expect(checkbox).not.toBeChecked(); await expect(page.getByRole("button", { name: "创建并提交复核", exact: true })).toHaveCount(0); }); for (const [reasonCode, message] of [ ["payment_batch.active_batch_exists", "所选单据已进入其他付款安排,请刷新列表并查看已有批次。"], ["unknown.synthetic", "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。 排障编号:synthetic-readiness-race"], ]) { test(`创建并发失败留在动作旁 ${reasonCode}`, async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据"); await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], createFailure: reasonCode }); await login(page, FINANCE_ACTOR); await page.goto("/payment-batches"); await page.getByTestId("payment-batch-candidate-row").getByRole("checkbox").check(); if (reasonCode === "unknown.synthetic") await page.setViewportSize({ width: 375, height: 812 }); await page.getByRole("button", { name: "创建并提交复核", exact: true }).click(); await expect(page.locator("#payment-creation-reason")).toHaveText(message); await expect(page.getByRole("button", { name: "创建并提交复核", exact: true })).toHaveAccessibleDescription(message); await expect(page.getByText("internal payload", { exact: false })).toHaveCount(0); await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0); }); } test("完整银行候选没有解释噪音并可提交复核", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据"); let created: unknown = null; await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], onCreatePaymentBatch: (payload) => { created = payload; } }); await login(page, FINANCE_ACTOR); await page.goto("/payment-batches"); const row = page.getByTestId("payment-batch-candidate-row"); await expect(row.locator('[id^="payment-candidate-reason-"]')).toHaveCount(0); await row.getByRole("checkbox").check(); const button = page.getByRole("button", { name: "创建并提交复核", exact: true }); await expect(button).toBeEnabled(); await expect(page.locator("#payment-creation-reason")).toHaveCount(0); await button.click(); await expect(page).toHaveURL(new RegExp(`id=${CREATED_PAYMENT_BATCH_ID}`)); expect(created).toEqual({ entityType: "reimbursement", entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], defaultPaymentChannel: "bank" }); }); test("付款失败摘要与历史风险不向 PC 或移动视口暴露技术文案", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据"); await mockSyntheticApis(page, FINANCE_ACTOR); const payload: Record = { batchId: CREATED_PAYMENT_BATCH_ID, itemId: "failed-item", reasonCode: "payment_batch.source_status_invalid" }; const detail = { id: CREATED_PAYMENT_BATCH_ID, batchNo: "PB-SYN-FAIL", name: "合成失败批次", status: "failed", entityType: "reimbursement", totalAmount: "1280.50", highRisk: false, defaultPaymentDate: null, defaultPaymentChannel: "cash", createdBy: FINANCE_ACTOR.id, paymentAccount: null, paymentOrderExportCount: 0, canExportPaymentOrder: false, paymentOrderExportBlockers: [], processingProgress: { stages: [] }, reviewRequirement: { completedReviewCount: 1, requiredReviewCount: 1, nextStep: "ready_for_execute" }, items: [{ id: "failed-item", entityId: PAYMENT_CANDIDATE_ENTITY_ID, entityCode: PAYMENT_CANDIDATE.businessNo, entityTitle: "合成失败明细", amount: "1280.50", currencyCode: "CNY", status: "failed", paymentChannelOverride: null, paymentDateOverride: null, paymentVoucherNoOverride: null, failureCode: "VALID_001", failureMessage: "参数校验失败 /private/hidden", expenseItems: [], expenseItemsTotal: "0.00", openRiskFlags: [{ id: "failure-risk", riskCode: "payment_batch_execute_failed", title: "付款登记失败", level: "high", detail: "参数校验失败 /private/hidden", source: "payment_batch", status: "open", payloadJson: payload }], }], }; await page.route(`**/api/payment-batches/${CREATED_PAYMENT_BATCH_ID}`, (route) => fulfillJson(route, { item: detail })); await page.route(`**/api/payment-batches/${CREATED_PAYMENT_BATCH_ID}/receipt-attachments`, (route) => fulfillJson(route, { items: [] })); await login(page, FINANCE_ACTOR); await page.goto(`/payment-batches?id=${CREATED_PAYMENT_BATCH_ID}`); await expect(page.getByTestId("payment-batch-items-panel")).toContainText("失败原因:来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。"); await expect(page.getByText("参数校验失败", { exact: false })).toHaveCount(0); await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0); delete payload.reasonCode; await page.reload(); await expect(page.getByTestId("payment-batch-items-panel")).toContainText("失败原因:操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。"); await page.setViewportSize({ width: 375, height: 812 }); await expect(page.getByTestId("payment-batch-items-panel")).toBeVisible(); await expect(page.getByText("参数校验失败", { exact: false })).toHaveCount(0); await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0); }); test("财务从业务候选选择报销并使用所选实体创建付款批次", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); let createPayload: unknown = null; const candidateQueries: string[] = []; await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], paymentCandidateTotal: 21, onCandidateQuery: (query) => candidateQueries.push(query.toString()), onCreatePaymentBatch: (payload) => { createPayload = payload; }, }); await login(page, FINANCE_ACTOR); await page.goto("/payment-batches"); await expect(page).toHaveURL(/\/payment-batches\?view=pending$/); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "待组批 21", exact: true })).toHaveAttribute("aria-current", "page"); await expect(page.getByTestId("payment-batch-candidates")).toHaveCount(1); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2); await page.getByRole("button", { name: "下一页", exact: true }).click(); await expect(page).toHaveURL(/view=pending&page=2/); await expect.poll(() => candidateQueries.some((query) => query.includes("page=2") && query.includes("pageSize=20"))).toBe(true); await page.getByRole("button", { name: "上一页", exact: true }).click(); const candidateRow = page.getByTestId("payment-batch-candidate-row"); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name); await expect(candidateRow).toContainText("¥1,280.50"); await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0); await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0); await page.getByTestId("payment-batch-candidate-search").fill(PAYMENT_CANDIDATE.businessNo); await expect(page).toHaveURL(new RegExp(`view=pending&q=${PAYMENT_CANDIDATE.businessNo}`)); await page.setViewportSize({ width: 375, height: 812 }); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2); await candidateRow.getByRole("checkbox").check(); await page.getByTestId("payment-batch-default-channel").selectOption("cash"); await expect(page.getByText("按现金提交负责人复核", { exact: false })).toBeVisible(); await page.getByRole("button", { name: "创建并提交复核", exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/payment-batches\\?view=batches&id=${CREATED_PAYMENT_BATCH_ID}$`)); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByText("已提交付款复核,等待负责人处理", { exact: true })).toBeVisible(); expect(createPayload).toEqual({ entityType: "reimbursement", entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], defaultPaymentChannel: "cash", }); expectNoPageErrors(pageErrors); });