import type { Page, Route } from "@playwright/test"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { test, expect } from "./helpers/fixtures"; import { login } from "./helpers/auth"; import type { ProcessingProgress } from "../../src/types/processingProgress"; test.use({ serviceWorkers: "block" }); const loadServerModule = createRequire(import.meta.url); function envelope(data: T) { return { requestId: "mock-system-action-confirm", data }; } async function fulfillJson(route: Route, data: T) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") { return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null }; } function syntheticPaymentOrder() { const { encryptStringOrThrow } = loadServerModule("../../../server/src/services/crypto"); const { generatePaymentOrderXls } = loadServerModule("../../../server/src/services/paymentOrderXls"); const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") }; return generatePaymentOrderXls(config, { batch_no: "PAY-ACTION-001", payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成付款公司", bankName: "中国建设银行", accountNumberEncrypted: encryptStringOrThrow(config, "000123456789012345678"), }, items: [{ id: "synthetic-item", entity_code: "BX-ACTION-001", amount: "88.00", snapshot_json: { payee: { name: "合成收款员工", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000987654321098765432"), } }, }], }).buffer as Buffer; } const user = { identifier: "action-confirm@hyinda.com", password: "ActionConfirm1234", name: "Action Confirm", }; const me = { id: "employee-action-confirm", employeeNo: "E-action-confirm", name: user.name, email: user.identifier, phone: null, roleCodes: ["manager", "finance"], permissions: [ "expense.create", "expense.view_own", "expense.delete_own", "expense.approve", "payment.batch.manage", "risk.review.manage", ], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 80, }, }; const reimbursementId = "reimbursement-action-confirm"; const paymentBatchId = "payment-batch-action-confirm"; const reimbursementAttachmentId = "reimbursement-attachment-action-confirm"; const paymentReceiptId = "payment-receipt-action-confirm"; function reimbursementListItem() { return { id: reimbursementId, formNumber: "BX-ACTION-001", status: "draft", reimbursementDate: "2026-05-15", totalAmount: "88.00", description: "R7E 高风险确认测试", 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-15T08:00:00.000Z", updatedAt: "2026-05-15T08:10:00.000Z", }; } function reimbursementDetail(status: "draft" | "reviewing" | "approved" | "paid" = "draft", isTravel = false) { const workflowTaskId = "workflow-task-action-confirm"; return { ...reimbursementListItem(), status, submitterId: me.id, ownerId: me.id, departmentId: "department-action-confirm", department: { id: "department-action-confirm", name: "合成部门" }, submittedAt: null, travelSummary: isTravel ? { isTravel: true, purpose: "拜访客户讨论需求", location: "西安", startDate: "2026-05-14", endDate: "2026-05-15" } : { isTravel: false, purpose: null, location: null, startDate: null, endDate: null }, projectId: null, linkedTravelRequestId: null, linkedTravelRequest: 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: "reimbursement-item-action-confirm", expenseTypeId: "expense-type-action-confirm", expenseDate: "2026-05-15", amount: "88.00", description: "合成报销动作回归", expenseType: isTravel ? { id: "expense-type-action-confirm", code: "travel_train", name: "火车票", category: "travel", enabled: true } : { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true }, }, ], invoices: [ { id: "invoice-action-confirm", status: "bound", invoiceCode: "SYNTHETIC", invoiceNumber: "ACTION001", invoiceDate: "2026-05-15", sellerName: "合成供应方", buyerName: "合成购买方", documentCategory: "vat_invoice", documentSubtype: null, totalAmount: "88.00", originalName: "合成动作发票.pdf", mimeType: "application/pdf", fileSize: 1024, linkedItemId: "reimbursement-item-action-confirm", }, ], attachments: [ { id: reimbursementAttachmentId, originalName: "合成报销敏感附件.pdf", mimeType: "application/pdf", fileSize: 1024, fileSha256: "synthetic-attachment-hash", governanceStatus: "approved", sensitivityLevel: "confidential", attachmentType: "supporting_document", downloadPolicy: "reason_required", reviewedBy: null, reviewedAt: null, reviewNote: null, archivedBy: null, archivedAt: null, archiveNote: null, uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name }, createdAt: "2026-05-15T08:05:00.000Z", }, ], budgetHint: null, pendingWorkflow: status === "reviewing" ? { instanceId: "workflow-instance-action-confirm", stepId: "workflow-step-action-confirm", task: { id: workflowTaskId, step_name: "负责人复核", assignee_id: "another-manager", assignee_role: "manager", }, } : null, workflowProgress: null, processingProgress: { stages: [] }, approvalHistory: [], riskFlags: [], }; } type SyntheticPaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled"; function paymentProcessingProgress(status: SyntheticPaymentBatchStatus, hasReceipt: boolean, requiresBankReceipt = true) { const reviewState = status === "draft" ? "current" : status === "cancelled" ? "exception" : "completed"; const paymentState = status === "executed" ? "completed" : status === "failed" ? "exception" : status === "cancelled" ? "terminated" : status === "reviewed" ? "current" : "pending"; return { stages: [ { key: "payment_batch_created", label: "批次创建", state: "completed", actorName: me.name, occurredAt: "2026-05-15T08:00:00.000Z", result: "已创建", comment: null, detail: null, targetHref: null }, { key: "payment_batch_review", label: "付款复核", state: reviewState, actorName: status === "draft" ? null : me.name, occurredAt: status === "draft" ? null : "2026-05-15T08:10:00.000Z", result: status === "draft" ? "待复核" : status === "cancelled" ? "批次已取消" : "通过", comment: status === "cancelled" ? "合成取消原因" : null, detail: status === "draft" ? "待非制单人复核付款" : null, targetHref: null }, { key: "payment_and_receipt", label: "付款完成", state: paymentState, actorName: ["executed", "failed"].includes(status) ? me.name : null, occurredAt: ["executed", "failed"].includes(status) ? "2026-05-15T08:30:00.000Z" : null, result: status === "executed" ? "已付款" : status === "failed" ? "付款失败" : status === "cancelled" ? "已终止" : "待付款", comment: null, detail: status === "reviewed" ? (!requiresBankReceipt ? "等待财务登记付款完成,非审批环节" : hasReceipt ? "银行回单已上传,等待财务登记付款完成,非审批环节" : "等待财务线下付款并上传回单,非审批环节") : null, targetHref: null }, ], }; } function paymentBatch( status: SyntheticPaymentBatchStatus = "reviewed", highRisk = false, hasReceipt = true, paymentChannel: "bank" | "cash" = "bank", canExportPaymentOrder = false, paymentOrderExportCount = 0, entityType: "reimbursement" | "travel_order" = "reimbursement", ) { return { id: paymentBatchId, batchNo: "PAY-ACTION-001", entityType, name: "R7E 高风险付款确认", status, defaultPaymentDate: "2026-05-15", defaultPaymentChannel: paymentChannel, voucherPrefix: "R7E", totalAmount: "88.00", highRisk, paymentAccount: canExportPaymentOrder ? { accountName: "合成付款公司", bankName: "中国建设银行", bankInstitutionCode: "CCB", accountNumberMasked: "0001********5678", } : null, paymentOrderExportCount, canExportPaymentOrder, paymentOrderExportBlockers: [], reviewRequirement: { thresholdAmount: "1000.00", requiredReviewCount: 1, completedReviewCount: status === "draft" ? 0 : 1, nextStep: status === "draft" ? "first_review" : "ready_for_execute", makerCheckerRequired: false, reviewerPermission: "payment.batch.review", }, reviewComment: null, secondaryReviewComment: null, executionSummaryJson: null, createdAt: "2026-05-15T08:00:00.000Z", createdBy: me.id, reviewedAt: status === "draft" ? null : "2026-05-15T08:10:00.000Z", reviewedBy: status === "draft" ? null : me.id, secondaryReviewedAt: null, secondaryReviewedBy: null, executedAt: null, executedBy: null, cancelledAt: null, cancelledBy: null, cancelledReason: null, processingProgress: paymentProcessingProgress(status, hasReceipt, paymentChannel === "bank"), items: [ { id: "payment-batch-item-action-confirm", entityId: reimbursementId, entityCode: "BX-ACTION-001", entityTitle: "R7E 高风险确认测试", amount: "88.00", currencyCode: "CNY", paymentVoucherNoOverride: null, paymentDateOverride: null, paymentChannelOverride: null, payeeName: "合成收款员工", bankName: "合成银行", bankAccountMasked: "6222********5678", status: status === "executed" ? "executed" : status === "failed" ? "failed" : status === "cancelled" ? "cancelled" : "pending", failureCode: null, failureMessage: null, createdAt: "2026-05-15T08:00:00.000Z", openRiskFlags: [], expenseItems: [ { id: "expense-item-action-confirm", expenseTypeName: "办公用品", expenseDate: "2026-05-14", description: "项目讨论资料", amount: "88.00", }, ], expenseItemsTotal: "88.00", expenseItemsAmountMatches: true, }, ], }; } async function mockSystemActionApis( page: Page, options: { batchStatus?: SyntheticPaymentBatchStatus; hasReceipt?: boolean; receiptQueryError?: boolean; receiptOcrState?: "ocr_failed" | "queued" | "system_failed" | "key_failed" | "snapshot_failed"; receiptManualReview?: boolean; receiptReady?: boolean; highRisk?: boolean; paymentChannel?: "bank" | "cash"; canExportPaymentOrder?: boolean; paymentOrderExportCount?: number; paymentOrderFile?: Buffer; paymentOrderError?: boolean; paymentEntityType?: "reimbursement" | "travel_order"; reimbursementStatus?: "draft" | "reviewing" | "approved" | "paid"; reimbursementProgress?: () => ProcessingProgress; travelReimbursement?: boolean; meOverride?: Partial; } = {}, ) { const currentMe = { ...me, ...options.meOverride }; const batchStatus = options.batchStatus ?? "reviewed"; let hasReceipt = options.hasReceipt ?? true; let receiptOcrState = options.receiptOcrState; let paymentOrderExportCount = options.paymentOrderExportCount ?? 0; let currentReimbursementStatus = options.reimbursementStatus ?? "draft"; const calls = { deleteReimbursement: 0, deleteReimbursementAttachment: 0, exportReimbursements: 0, executePaymentBatch: 0, paymentOrderReasons: [] as string[], paymentBatchDetail: 0, previewPaymentReceipt: 0, uploadPaymentReceipt: 0, voidPaymentReceipt: 0, retryReceiptOcr: 0, manualReceiptSaves: [] as Array>, manualReceiptReviews: [] as Array>, attachmentDeleteReasons: [] as string[], receiptVoidReasons: [] as string[], reimbursementUpdates: [] as Array>, submitReimbursement: 0, withdrawReimbursement: 0, remindWorkflow: 0, withdrawComments: [] as string[], }; 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: true, entryEnabled: true, oauthEnabled: true, corpId: "wx-test-corp", agentId: "1000002", loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal", bindEnabled: true, defaultReturnTo: "/portal", status: "ready", 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-system-action-confirm-token", expiresIn: 900, mustChangePassword: false, })), }); return; } if (path === "/api/auth/logout") { await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" }, body: JSON.stringify(envelope({})), }); return; } if (path === "/api/me") { await fulfillJson(route, currentMe); return; } if (path === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-action-confirm", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", enabledGroups: ["main"], quickActions: [], }, items: [ navigationItem("portal", "/portal", "首页", "home"), navigationItem("reimbursements", "/reimbursements", "报销", "receipt"), navigationItem("payment-batches", "/payment-batches", "付款", "credit_card"), ], }); return; } if (path === "/api/reimbursements" && request.method() === "GET") { await fulfillJson(route, { page: 1, pageSize: 20, total: 1, scope: "own", items: [reimbursementListItem()] }); return; } if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") { await fulfillJson(route, { ...reimbursementDetail(currentReimbursementStatus, options.travelReimbursement), ...(options.reimbursementProgress ? { processingProgress: options.reimbursementProgress() } : {}), }); return; } if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") { const body = request.postDataJSON() as Record; calls.reimbursementUpdates.push(body); await fulfillJson(route, { ...reimbursementDetail(currentReimbursementStatus, options.travelReimbursement), reimbursementDate: body.reimbursementDate ?? "2026-05-14", }); return; } if (path === `/api/reimbursements/${reimbursementId}/precheck` && request.method() === "POST") { await fulfillJson(route, { entityType: "reimbursement", entityId: reimbursementId, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["submit"], }); return; } if (path === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") { calls.submitReimbursement += 1; currentReimbursementStatus = "reviewing"; await fulfillJson(route, { reimbursementId, status: "reviewing" }); return; } if (path === `/api/reimbursements/${reimbursementId}/withdraw` && request.method() === "POST") { calls.withdrawReimbursement += 1; const body = request.postDataJSON() as { comment?: string }; calls.withdrawComments.push(body.comment ?? ""); currentReimbursementStatus = "draft"; await fulfillJson(route, { reimbursementId, status: "draft" }); return; } if (path === "/api/workflow/tasks/workflow-task-action-confirm/remind" && request.method() === "POST") { calls.remindWorkflow += 1; await fulfillJson(route, { taskId: "workflow-task-action-confirm", remindedAt: "2026-05-15T09:00:00.000Z" }); return; } if ( path === `/api/reimbursements/${reimbursementId}/attachments/${reimbursementAttachmentId}` && request.method() === "DELETE" ) { calls.deleteReimbursementAttachment += 1; const body = request.postDataJSON() as { reason?: string }; calls.attachmentDeleteReasons.push(body.reason ?? ""); await fulfillJson(route, { attachmentId: reimbursementAttachmentId, outcome: "deleted" }); return; } if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") { calls.deleteReimbursement += 1; await fulfillJson(route, { id: reimbursementId, deleted: true }); return; } if (path === "/api/exports/reimbursements") { calls.exportReimbursements += 1; await route.fulfill({ status: 200, contentType: url.searchParams.get("format") === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv", headers: { "content-disposition": "attachment; filename=\"reimbursements.csv\"" }, body: "formNumber,totalAmount\nBX-ACTION-001,88.00\n", }); 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/payment-batches" && request.method() === "GET") { const listItem = paymentBatch( batchStatus, options.highRisk, hasReceipt, options.paymentChannel, options.canExportPaymentOrder, paymentOrderExportCount, options.paymentEntityType, ); await fulfillJson(route, { items: [{ ...listItem, items: listItem.items.map((item) => ({ ...item, payeeName: null, bankName: null, bankAccountMasked: null, })), }], }); return; } if (path === `/api/payment-batches/${paymentBatchId}` && request.method() === "GET") { calls.paymentBatchDetail += 1; await fulfillJson(route, { item: paymentBatch( batchStatus, options.highRisk, hasReceipt, options.paymentChannel, options.canExportPaymentOrder, paymentOrderExportCount, options.paymentEntityType, ), }); return; } if (path === `/api/payment-batches/${paymentBatchId}/payment-order` && request.method() === "POST") { calls.paymentOrderReasons.push(request.postDataJSON().reason); if (options.paymentOrderError) { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "SYSTEM_001", message: "银行制单生成失败" } }) }); return; } if (!options.paymentOrderFile) throw new Error("合成制单文件未配置"); paymentOrderExportCount += 1; await route.fulfill({ status: 200, contentType: "application/vnd.ms-excel", headers: { "content-disposition": `attachment; filename=payment-order-PAY-ACTION-001.xls; filename*=UTF-8''${encodeURIComponent("报销制单_PAY-ACTION-001.xls")}` }, body: options.paymentOrderFile }); return; } if (path === `/api/payment-batches/${paymentBatchId}/receipt-readiness` && request.method() === "GET") { await fulfillJson(route, { item: { ready: options.receiptReady ?? true } }); return; } const verificationPath = `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/manual-verification`; if (path === verificationPath && request.method() === "GET") { const facts = { pageNumber: 1, amount: "88.00", currency: "CNY", payerAccountMasked: "0000****2222", payeeAccountMasked: "0000****4444", reference: "SYN-MANUAL-UI", transactionStatus: "success" }; await fulfillJson(route, { item: { version: 3, state: options.receiptManualReview ? "pending_review" : "needs_review", canReview: Boolean(options.receiptManualReview), pagesExpected: 1, pages: [facts], proposal: options.receiptManualReview ? { id: "synthetic-proposal", status: "pending_review", proposedBy: "another-finance", reviewedBy: null, reason: "合成核验说明", changedFields: [{ pageNumber: 1, fields: ["amount"] }], pages: [{ ...facts, itemId: "payment-batch-item-action-confirm" }] } : null } }); return; } if (path === verificationPath && request.method() === "POST") { calls.manualReceiptSaves.push(request.postDataJSON()); await fulfillJson(route, { item: { id: "synthetic-proposal", version: 4, status: "pending_review" } }); return; } if (path === `${verificationPath}/synthetic-proposal/review` && request.method() === "POST") { calls.manualReceiptReviews.push(request.postDataJSON()); await fulfillJson(route, { item: { id: "synthetic-proposal", version: 3, status: "approved" } }); return; } if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/ocr-retry` && request.method() === "POST") { expect(request.postDataJSON()).toEqual({ expectedVersion: 3 }); calls.retryReceiptOcr += 1; receiptOcrState = "queued"; await fulfillJson(route, { item: { id: paymentReceiptId, ocrState: "queued", ocrVersion: 4 } }); return; } if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") { if (options.receiptQueryError) { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ requestId: "mock-system-action-confirm", error: { code: "SYS_001", message: "回单状态暂时不可用" }, }), }); return; } await fulfillJson(route, { items: hasReceipt ? [ { id: paymentReceiptId, originalName: "合成银行回单.pdf", ocrState: receiptOcrState, ocrVersion: receiptOcrState === "queued" ? 4 : 3, ocrRetryAvailable: receiptOcrState === "ocr_failed", mimeType: "application/pdf", fileSize: 2048, fileSha256: "synthetic-receipt-hash", governanceStatus: "approved", sensitivityLevel: "confidential", attachmentType: "payment_receipt", downloadPolicy: "reason_required", reviewedBy: null, reviewedAt: null, reviewNote: null, archivedBy: null, archivedAt: null, archiveNote: null, uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name }, createdAt: "2026-05-15T08:20:00.000Z", }, ] : [], }); return; } if ( path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/file` && request.method() === "GET" && url.searchParams.get("mode") === "preview" ) { calls.previewPaymentReceipt += 1; await route.fulfill({ status: 200, contentType: "application/pdf", headers: { "content-disposition": "inline; filename=synthetic-receipt.pdf" }, body: Buffer.from("%PDF-1.4\nsynthetic preview\n%%EOF\n", "utf8"), }); return; } if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "POST") { calls.uploadPaymentReceipt += 1; hasReceipt = true; await fulfillJson(route, { items: [{ id: paymentReceiptId }] }); return; } if ( path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/void` && request.method() === "POST" ) { calls.voidPaymentReceipt += 1; const body = request.postDataJSON() as { reason?: string }; calls.receiptVoidReasons.push(body.reason ?? ""); await fulfillJson(route, { attachmentId: paymentReceiptId, outcome: "voided", voidedAt: "2026-05-15T08:30:00.000Z" }); return; } if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") { calls.executePaymentBatch += 1; await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk, hasReceipt, options.paymentChannel) }); return; } await fulfillJson(route, {}); }); return calls; } test.describe("报销付款交接一致性", () => { test.skip(process.env.E2E_MODE === "live", "仅合成数据,不访问公网"); function progress(status: SyntheticPaymentBatchStatus | "none" | "unknown", canRead = true, exported = 1): ProcessingProgress { if (status === "unknown") return { stages: [] }; const { buildPaymentProcessingSemantics } = loadServerModule("../../../server/src/services/paymentProcessingProgress"); const semantics = buildPaymentProcessingSemantics({ status, requiresBankReceipt: true, paymentOrderExportCount: exported }); const stages = Object.values(semantics) as ProcessingProgress["stages"]; for (const stage of stages) Object.assign(stage, { actorName: null, targetHref: null }); if (status === "none") { Object.assign(stages[0], { state: "current", result: "待组批", detail: "待财务组建付款批次" }); } else if (canRead) { stages[status === "draft" ? 1 : 2].targetHref = `/payment-batches?id=${paymentBatchId}`; } return { stages }; } for (const mobile of [false, true]) { test(`${mobile ? "手机" : "桌面"}已复核制单后返回报销不再组批且进度跟随操作`, async ({ page }) => { await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 }); await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") }); await login(page, user); await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`); const actions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel"); await expect(actions).toContainText("已生成银行制单,等待付款完成并上传回单"); await expect(page.getByRole("link", { name: "创建批次", exact: true })).toHaveCount(0); const link = page.getByRole("link", { name: "查看所在付款批次", exact: true }); await expect(link).toHaveCount(1); const linkBounds = await link.boundingBox(); expect(Math.round(linkBounds!.height * 100) / 100).toBeGreaterThanOrEqual(44); if (!mobile) expect(linkBounds!.width).toBeLessThan(180); await link.click(); await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible(); await page.goBack(); await expect(actions).toContainText("已生成银行制单"); const timeline = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"); await expect(timeline).toHaveCount(1); const panel = actions.locator(".."); expect(await panel.evaluate((element) => getComputedStyle(element).position)).not.toBe("sticky"); const actionBounds = await actions.boundingBox(); const progressBounds = await timeline.boundingBox(); expect(progressBounds!.y).toBeGreaterThan(actionBounds!.y + actionBounds!.height); if (!mobile) expect(Math.abs(progressBounds!.x - actionBounds!.x)).toBeLessThan(35); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true); }); } test("账号查看图标紧随脱敏值且确认前不请求完整账号", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }); await mockSystemActionApis(page, { canExportPaymentOrder: true, meOverride: { roleCodes: ["finance"], permissions: [...me.permissions, "employee.bank.read"] }, }); let reveals = 0; page.on("request", (request) => { if (new URL(request.url()).pathname.endsWith("/payee-bank/reveal")) reveals += 1; }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const reveal = page.getByRole("button", { name: "查看完整账号", exact: true }); await expect(reveal).toBeVisible(); await expect(reveal).toHaveText(""); await expect(reveal).toHaveAttribute("title", "查看完整账号"); const icon = await reveal.boundingBox(); const account = await reveal.locator("..").locator("span").boundingBox(); expect(icon!.width).toBeGreaterThanOrEqual(44); expect(icon!.height).toBeGreaterThanOrEqual(44); expect(icon!.x).toBeGreaterThan(account!.x); await reveal.focus(); await page.keyboard.press("Enter"); const dialog = page.getByRole("dialog"); await expect(dialog).toContainText("本次查看会记录"); await expect(dialog.getByRole("textbox", { name: "查看收款账号原因" })).toBeVisible(); expect(reveals).toBe(0); await dialog.getByRole("button", { name: "返回", exact: true }).click(); expect(reveals).toBe(0); }); for (const width of [390, 1280, 1440]) { test(`票据紧凑展示${width}多张长名称及预览不丢失`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 960 }); await mockSystemActionApis(page, { reimbursementStatus: "approved" }); const detail = reimbursementDetail("approved"); const sellerName = "合成开票方长名称".repeat(8); detail.invoices = Array.from({ length: 5 }, (_, index) => ({ ...detail.invoices[0], id: `invoice-layout-${index}`, invoiceNumber: `SYNTHETIC-LAYOUT-000000000000000000000${index}`, sellerName, })); detail.attachments = []; await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, detail)); let previews = 0; await page.route("**/api/invoice-pool/invoice-layout-0/signed-url", async (route) => { previews += 1; await fulfillJson(route, { signedUrl: "about:blank", originalName: "合成预览.pdf", fileSize: 1024 }); }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); const table = page.getByTestId("reimbursement-invoices-table"); await expect(table).toBeVisible(); await expect(table.getByTestId("approval-invoice-evidence")).toHaveCount(5); await expect(table.getByText(sellerName, { exact: true })).toHaveCount(5); if (width >= 1024) await expect(table.getByRole("columnheader", { name: "金额" })).toBeVisible(); else await expect(table.locator("thead")).toBeHidden(); await expect(page.getByText("未上传补充附件", { exact: true })).toBeVisible(); await table.getByRole("button", { name: `查看发票:${detail.invoices[0].invoiceNumber}`, exact: true }).click(); await expect.poll(() => previews).toBe(1); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true); await table.screenshot({ path: testInfo.outputPath("invoice-table.png") }); }); } for (const width of [1280, 1440, 1920]) { test(`桌面${width}付款与报销侧栏等宽且更多紧随主按钮`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 960 }); await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") }); await login(page, user); const widths: number[] = []; const boundaries: { left: number; right: number }[] = []; for (const [url, panelId] of [ [`/reimbursements/${reimbursementId}`, "reimbursement-action-panel"], [`/payment-batches?id=${paymentBatchId}`, "payment-batch-action-panel"], ]) { await page.goto(url); const panel = page.getByTestId(panelId); await expect(panel).toBeVisible(); await page.evaluate(() => document.fonts.ready); const grid = page.locator(".detail-workspace-grid--payment"); const gridBounds = (await grid.boundingBox())!; boundaries.push({ left: gridBounds.x, right: gridBounds.x + gridBounds.width }); if (panelId === "reimbursement-action-panel") { const heroBounds = (await page.getByTestId("reimbursement-desktop-workspace").locator(".workbench-hero").boundingBox())!; expect(Math.abs(gridBounds.x - heroBounds.x)).toBeLessThan(2); expect(Math.abs(gridBounds.x + gridBounds.width - heroBounds.x - heroBounds.width)).toBeLessThan(2); } widths.push((await panel.locator("../..").boundingBox())!.width); const row = panel.getByTestId("detail-inline-actions"); const primary = await row.locator("a, button").first().boundingBox(); const more = await row.getByRole("button", { name: "更多", exact: true }).boundingBox(); expect(Math.abs(primary!.y + primary!.height / 2 - more!.y - more!.height / 2)).toBeLessThan(2); expect(more!.x).toBeGreaterThan(primary!.x + primary!.width); await row.getByRole("button", { name: "更多", exact: true }).click(); await expect(page.getByRole("menu")).toBeVisible(); await page.keyboard.press("Escape"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true); await page.screenshot({ path: testInfo.outputPath(`${panelId}.png`), fullPage: true }); } expect(widths).toEqual([320, 320]); expect(Math.abs(boundaries[0].left - boundaries[1].left)).toBeLessThan(2); expect(Math.abs(boundaries[0].right - boundaries[1].right)).toBeLessThan(2); }); test(`桌面${width}创建批次按钮短文案按内容宽度且只导航不组批`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 960 }); await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("none") }); let batchWrites = 0; page.on("request", (request) => { if (new URL(request.url()).pathname === "/api/payment-batches" && request.method() === "POST") batchWrites += 1; }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); const button = page.getByRole("link", { name: "创建批次", exact: true }); await expect(button).toBeVisible(); const actions = page.getByTestId("reimbursement-action-panel"); const buttonBounds = await button.boundingBox(); const actionBounds = await actions.boundingBox(); expect(buttonBounds!.width).toBeGreaterThanOrEqual(44); expect(buttonBounds!.width).toBeLessThan(160); expect(buttonBounds!.width).toBeLessThan(actionBounds!.width * 0.7); expect(buttonBounds!.height).toBeGreaterThanOrEqual(44); expect(Math.abs(buttonBounds!.x - actionBounds!.x)).toBeLessThan(2); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true); await actions.locator("..").screenshot({ path: testInfo.outputPath("compact-payment-action.png") }); await button.click(); await expect(page).toHaveURL(/\/payment-batches\?view=pending&q=/); expect(batchWrites).toBe(0); }); } for (const [status, expected] of [ ["none", "待财务组建付款批次"], ["draft", "待非制单人复核付款"], ["reviewed", "已生成银行制单"], ["failed", "付款登记失败"], ["cancelled", "付款安排已取消"], ["unknown", "付款进度暂不可用"], ] as const) { test(`${status}阶段不因无批次读取权限而误判待组批`, async ({ page }) => { await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress(status, false), meOverride: { roleCodes: ["employee"], permissions: ["expense.view_own"] }, }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); await expect(page.getByTestId("reimbursement-action-panel")).toContainText(expected); await expect(page.getByRole("link", { name: /付款批次/ })).toHaveCount(0); await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0); }); } test("重新聚焦后不保留旧的组批动作", async ({ page }) => { let current: SyntheticPaymentBatchStatus | "none" = "none"; await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress(current) }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toBeVisible(); current = "reviewed"; await page.evaluate(() => { Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); document.dispatchEvent(new Event("visibilitychange", { bubbles: true })); Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); document.dispatchEvent(new Event("visibilitychange", { bubbles: true })); }); await expect(page.getByTestId("reimbursement-action-panel")).toContainText("已生成银行制单"); await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0); await page.reload(); await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1); }); test("已付款只保留结果入口,进度未知不让财务猜测组批", async ({ page }) => { await mockSystemActionApis(page, { reimbursementStatus: "paid", reimbursementProgress: () => progress("executed") }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款已登记完成"); await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1); await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0); await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, { ...reimbursementDetail("approved"), processingProgress: { stages: [] } })); await page.reload(); await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款进度暂不可用"); await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0); }); }); test.describe("R7E 全站高风险操作二次确认", () => { test("报销删除和敏感导出必须确认后才调用接口", async ({ page }) => { const calls = await mockSystemActionApis(page); page.on("dialog", (dialog) => { throw new Error(`不应出现浏览器原生弹窗:${dialog.message()}`); }); await login(page, user); await page.goto("/reimbursements"); await expect(page.getByTestId("reimbursements-table")).toBeVisible(); await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click(); await page.getByRole("menuitem", { name: "删除报销单" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("已发生的审批记录仍保留备查"); expect(calls.deleteReimbursement).toBe(0); await page.getByTestId("action-confirm-cancel").click(); expect(calls.deleteReimbursement).toBe(0); await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click(); await page.getByRole("menuitem", { name: "删除报销单" }).click(); await page.getByTestId("action-confirm-submit").click(); await expect.poll(() => calls.deleteReimbursement).toBe(1); await page.getByTestId("reimbursements-export").click(); await page.getByTestId("reimbursements-export-csv").click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息"); expect(calls.exportReimbursements).toBe(0); await page.getByTestId("action-confirm-submit").click(); await expect.poll(() => calls.exportReimbursements).toBe(1); }); test("报销详情迁移后保留提交与删除的状态、确认和刷新合同", async ({ page }) => { const calls = await mockSystemActionApis(page, { reimbursementStatus: "draft", meOverride: { permissions: [...me.permissions, "expense.submit"] }, }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); const actionPanel = page.getByTestId("reimbursement-action-panel"); await expect(actionPanel.getByRole("button", { name: "提交报销" })).toBeVisible(); await actionPanel.getByRole("button", { name: "提交报销" }).click(); await expect.poll(() => calls.submitReimbursement).toBe(1); await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible(); await page.unrouteAll({ behavior: "wait" }); const deleteCalls = await mockSystemActionApis(page, { reimbursementStatus: "draft" }); await page.goto(`/reimbursements/${reimbursementId}`); await page.getByTestId("reimbursement-more-toggle").click(); await page.getByTestId("reimbursement-delete-draft").click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("已发生的审批记录仍保留备查"); expect(deleteCalls.deleteReimbursement).toBe(0); await page.getByTestId("action-confirm-submit").click(); await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1); await expect(page).toHaveURL(/\/reimbursements\?status=draft/); }); test("报销详情迁移后保留催办与撤回理由及刷新合同", async ({ page }) => { const calls = await mockSystemActionApis(page, { reimbursementStatus: "reviewing" }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); const actionPanel = page.getByTestId("reimbursement-action-panel"); await actionPanel.getByRole("button", { name: "催办审批" }).click(); await expect.poll(() => calls.remindWorkflow).toBe(1); await page.getByTestId("reimbursement-withdraw-comment").fill("合成撤回原因"); await page.getByTestId("reimbursement-withdraw").click(); await expect.poll(() => calls.withdrawReimbursement).toBe(1); expect(calls.withdrawComments).toEqual(["合成撤回原因"]); await expect(page.getByText("草稿", { exact: true }).first()).toBeVisible(); }); test("报销详情迁移后已支付状态仍可进入打印页", async ({ page }) => { await mockSystemActionApis(page, { reimbursementStatus: "paid" }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); const printLink = page.getByTestId("reimbursement-action-panel").getByRole("link", { name: "打印 / PDF" }); await expect(printLink).toBeVisible(); await printLink.click(); await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}/print$`)); }); test("差旅报销在 PC 和移动详情只展示出差事由", async ({ page }) => { const calls = await mockSystemActionApis(page, { travelReimbursement: true }); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1); await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible(); await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0); await page.getByRole("button", { name: "编辑报销日期" }).click(); await page.getByTestId("reimbursement-header-editor").locator('input[type="date"]').fill("2026-05-13"); await page.getByTestId("reimbursement-header-editor").getByRole("button", { name: "保存" }).click(); await expect.poll(() => calls.reimbursementUpdates).toHaveLength(1); expect(calls.reimbursementUpdates[0]).toEqual({ reimbursementDate: "2026-05-13" }); await page.goto(`/mobile/reimbursements/${reimbursementId}`); await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1); await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible(); await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0); }); test("付款批次执行必须确认后才调用接口", async ({ page }) => { const calls = await mockSystemActionApis(page); page.on("dialog", (dialog) => { throw new Error(`不应出现浏览器原生弹窗:${dialog.message()}`); }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible(); await page.getByRole("button", { name: "登记付款完成" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); expect(calls.executePaymentBatch).toBe(0); await page.getByTestId("action-confirm-cancel").click(); expect(calls.executePaymentBatch).toBe(0); await page.getByRole("button", { name: "登记付款完成" }).click(); await page.getByTestId("action-confirm-submit").click(); await expect.poll(() => calls.executePaymentBatch).toBe(1); }); test("银行回单上传前后只显示一个付款主动作", async ({ page }) => { const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const actionPanel = page.getByTestId("payment-batch-action-panel"); await expect(actionPanel.getByTestId("payment-batch-action-downloadOrder")).toBeVisible(); await actionPanel.getByText("更多", { exact: true }).click(); await expect(page.getByRole("menuitem", { name: "上传银行回单" })).toBeVisible(); await expect(page.getByTestId("payment-batch-mobile-actions")).toBeHidden(); await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); const desktopFileChooser = page.waitForEvent("filechooser"); await page.getByRole("menuitem", { name: "上传银行回单" }).click(); await (await desktopFileChooser).setFiles({ name: "合成银行回单.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\nsynthetic receipt\n%%EOF\n", "utf8"), }); await expect.poll(() => calls.uploadPaymentReceipt).toBe(1); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible(); await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); }); test("移动端固定操作区复用同一动作模型并在上传后原地切换", async ({ page }) => { const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true }); await page.setViewportSize({ width: 390, height: 844 }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const mobileActions = page.getByTestId("payment-batch-mobile-actions"); await expect(mobileActions).toBeVisible(); await expect(page.getByTestId("payment-batch-action-panel")).toBeHidden(); await expect(mobileActions.getByTestId("payment-batch-action-downloadOrder")).toBeVisible(); await mobileActions.getByRole("button", { name: "更多" }).click(); await expect(page.getByRole("menuitem", { name: "上传银行回单" })).toBeVisible(); const mobileFileChooser = page.waitForEvent("filechooser"); await page.getByRole("menuitem", { name: "上传银行回单" }).click(); await (await mobileFileChooser).setFiles({ name: "移动端合成银行回单.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\nmobile synthetic receipt\n%%EOF\n", "utf8"), }); await expect.poll(() => calls.uploadPaymentReceipt).toBe(1); await expect(mobileActions.getByRole("button", { name: "登记付款完成" })).toBeVisible(); await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); }); for (const mobile of [false, true]) { test(`财务${mobile ? "移动端" : "桌面端"}先制单再上传回单且重下载不登记付款`, async ({ page }, testInfo) => { test.skip(process.env.E2E_MODE === "live", "只使用合成付款数据,不访问公网"); const paymentOrderFile = syntheticPaymentOrder(); const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true, paymentOrderFile, meOverride: { roleCodes: ["finance"], permissions: [...me.permissions, "payment.order.export"] }, }); if (mobile) await page.setViewportSize({ width: 390, height: 844 }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel"); await expect(actions.getByTestId("payment-batch-action-downloadOrder")).toBeVisible(); const sideActions = page.getByTestId("payment-batch-action-panel"); const progress = page.getByTestId("payment-batch-processing-panel"); await expect(progress).toBeVisible(); expect(await progress.evaluate((element) => Boolean(element.previousElementSibling?.querySelector('[data-testid="payment-batch-action-panel"]')))).toBe(true); const actionBounds = await actions.boundingBox(); const progressBounds = await progress.boundingBox(); expect(actionBounds).not.toBeNull(); expect(progressBounds).not.toBeNull(); if (mobile) { await expect(sideActions).toBeHidden(); expect(await actions.evaluate((element) => getComputedStyle(element).position)).toBe("fixed"); expect(actionBounds!.height).toBeGreaterThanOrEqual(44); } else { expect(progressBounds!.y).toBeGreaterThanOrEqual(actionBounds!.y + actionBounds!.height); } expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await page.screenshot({ path: testInfo.outputPath("before-payment-order.png"), fullPage: true }); await actions.getByTestId("payment-batch-action-downloadOrder").click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByRole("button", { name: "确认下载" })).toBeEnabled(); await expect(dialog.getByRole("textbox")).toHaveCount(0); await dialog.getByRole("button", { name: "返回", exact: true }).click(); expect(calls.paymentOrderReasons).toEqual([]); await actions.getByTestId("payment-batch-action-downloadOrder").click(); await expect(dialog).toContainText("文件包含完整银行账号"); const firstDownload = page.waitForEvent("download"); await dialog.getByRole("button", { name: "确认下载" }).click(); const downloaded = await firstDownload; expect(downloaded.suggestedFilename()).toBe("报销制单_PAY-ACTION-001.xls"); const downloadedPath = await downloaded.path(); if (!downloadedPath) throw new Error("未收到合成制单文件"); expect(readFileSync(downloadedPath).equals(paymentOrderFile)).toBe(true); expect(paymentOrderFile.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1"); await expect(actions.getByTestId("payment-batch-action-uploadReceipt")).toBeVisible(); await expect(actions.getByTestId("payment-batch-action-downloadOrder")).toHaveCount(0); await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("已生成 1 次"); await page.screenshot({ path: testInfo.outputPath("after-payment-order.png"), fullPage: true }); await actions.getByText("更多", { exact: true }).click(); if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click(); else await page.getByRole("menuitem", { name: "重新下载制单" }).click(); await expect(dialog).toContainText("请勿重复提交"); await expect(dialog.getByRole("textbox")).toHaveCount(0); const secondDownload = page.waitForEvent("download"); await dialog.getByRole("button", { name: "确认下载" }).click(); await secondDownload; await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("已生成 2 次"); expect(calls.paymentOrderReasons).toEqual([undefined, undefined]); expect(calls.executePaymentBatch).toBe(0); expect(calls.uploadPaymentReceipt).toBe(0); }); } test("制单失败保留下载步骤且不推进付款", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用合成付款数据,不访问公网"); const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true, paymentOrderError: true }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.getByTestId("payment-batch-action-panel").getByTestId("payment-batch-action-downloadOrder").click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByRole("textbox")).toHaveCount(0); await dialog.getByRole("button", { name: "确认下载" }).click(); await expect.poll(() => calls.paymentOrderReasons.length).toBe(1); await expect(dialog.getByRole("button", { name: "确认下载" })).toBeEnabled(); await expect(dialog).toBeVisible(); await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("尚未生成"); expect(calls.executePaymentBatch).toBe(0); await dialog.getByRole("button", { name: "返回", exact: true }).click(); await expect(page.getByTestId("payment-batch-action-panel").getByTestId("payment-batch-action-downloadOrder")).toBeVisible(); }); test("付款详情按处理顺序展示并统一人民币金额格式", async ({ page }) => { await mockSystemActionApis(page, { hasReceipt: false, highRisk: true }); await page.setViewportSize({ width: 1024, height: 768 }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByTestId("payment-batch-detail-panel")).toBeVisible(); const sectionOrder = await page.locator([ '[data-testid="payment-batch-detail-panel"]', '[data-testid="payment-batch-processing-panel"]', '[data-testid="payment-batch-items-panel"]', '[data-testid="payment-batch-receipt-panel"]', ].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); expect(sectionOrder).toEqual([ "payment-batch-detail-panel", "payment-batch-items-panel", "payment-batch-receipt-panel", "payment-batch-processing-panel", ]); await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00"); await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00"); await expect(page.getByTestId("payment-batch-processing-panel")).toContainText("等待财务线下付款并上传回单"); const responsiveContract = await page.locator(".detail-workspace-grid").evaluate((workspace) => { const actionPanel = workspace.querySelector(".detail-action-panel"); return { gridColumns: getComputedStyle(workspace).gridTemplateColumns, actionPosition: actionPanel ? getComputedStyle(actionPanel).position : null, }; }); expect(responsiveContract.gridColumns.split(" ").filter(Boolean)).toHaveLength(2); expect(responsiveContract.actionPosition).toBe("static"); }); test("付款详情展示费用明细并支持回单在线查看,下载仍要求用途", async ({ page }) => { const calls = await mockSystemActionApis(page, { hasReceipt: true }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const itemsPanel = page.getByTestId("payment-batch-items-panel"); await expect(itemsPanel).toContainText("本批次报销单"); await expect(itemsPanel).toContainText("办公用品"); await expect(itemsPanel).toContainText("项目讨论资料"); await expect(itemsPanel).toContainText("明细小计 ¥88.00"); await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveAttribute("href", `/reimbursements/${reimbursementId}`); const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" }); await expect(receipt).toContainText("下载需说明用途"); await receipt.getByRole("button", { name: "在线查看" }).click(); await expect.poll(() => calls.previewPaymentReceipt).toBe(1); const preview = page.getByTestId("payment-batch-receipt-preview-dialog"); await expect(preview).toBeVisible(); await expect(preview).toContainText("本次查看已自动写入审计记录"); await expect(preview.locator("iframe")).toHaveAttribute("title", /银行回单/); await preview.getByRole("button", { name: "关闭" }).click(); await expect(preview).toBeHidden(); await receipt.getByRole("button", { name: "下载" }).click(); await expect(page.getByRole("dialog")).toContainText("填写下载用途"); }); test("历史差旅付款批次不冒充报销明细或生成错误报销入口", async ({ page }) => { await mockSystemActionApis(page, { paymentEntityType: "travel_order" }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const itemsPanel = page.getByTestId("payment-batch-items-panel"); await expect(itemsPanel).toContainText("本批次差旅订单"); await expect(itemsPanel.getByTestId("payment-batch-expense-items")).toHaveCount(0); await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveCount(0); await expect(itemsPanel).toContainText("付款信息"); }); test("付款三阶段时间线覆盖五种状态并正确终止", async ({ page }) => { const cases: Array<[SyntheticPaymentBatchStatus, string]> = [ ["draft", "待复核"], ["reviewed", "待付款"], ["executed", "已付款"], ["failed", "付款失败"], ["cancelled", "已终止"], ]; for (const [status, expected] of cases) { await page.unrouteAll({ behavior: "wait" }); await mockSystemActionApis(page, { batchStatus: status, hasReceipt: status === "executed" }); await page.context().clearCookies(); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const progress = page.getByTestId("payment-batch-processing-progress"); await expect(progress).toBeVisible(); await expect(progress).toContainText("批次创建"); await expect(progress).toContainText("付款复核"); await expect(progress).toContainText("付款完成"); await expect(progress).toContainText(expected); } }); test("非银行渠道直接等待登记完成且不引导回单或制单", async ({ page }) => { await mockSystemActionApis(page, { batchStatus: "reviewed", hasReceipt: false, paymentChannel: "cash" }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("等待财务登记付款完成"); await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("银行制单"); await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("上传回单"); await expect(page.getByTestId("payment-batch-receipt-panel")).toContainText("当前付款渠道不要求银行回单"); await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible(); }); test("付款详情按批次状态渐进展示收款依据和财务动作", async ({ page }) => { const calls = await mockSystemActionApis(page, { batchStatus: "draft", hasReceipt: true }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const draftItems = page.getByTestId("payment-batch-items-panel"); await expect.poll(() => calls.paymentBatchDetail).toBe(1); await expect(draftItems).toContainText("合成收款员工"); await expect(draftItems).toContainText("合成银行"); await expect(draftItems).toContainText("6222********5678"); await expect(draftItems).toContainText("银行转账"); await expect(draftItems.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0); await expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0); await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByRole("button", { name: "作废" })).toHaveCount(0); await page.unrouteAll({ behavior: "wait" }); await mockSystemActionApis(page, { batchStatus: "executed", hasReceipt: true }); await page.reload(); await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0); await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible(); await expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0); await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByRole("button", { name: "作废" })).toHaveCount(0); await expect(page.getByRole("button", { name: "下载" })).toBeVisible(); }); test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => { const calls = await mockSystemActionApis(page); await login(page, user); await page.goto(`/reimbursements/${reimbursementId}`); const attachment = page.getByTestId("approval-attachments").locator(":scope > div").filter({ has: page.getByRole("button", { name: "查看补充附件:合成报销敏感附件.pdf", exact: true }), }); await expect(attachment).toBeVisible(); await attachment.getByRole("button", { name: "删除附件", exact: true }).click(); const dialog = page.getByTestId("attachment-delete-confirm-dialog"); await expect(dialog).toContainText("合成报销敏感附件.pdf"); await expect(dialog).toContainText("无法在 OA 内恢复"); expect(calls.deleteReimbursementAttachment).toBe(0); await page.getByTestId("attachment-delete-confirm-submit").click(); await expect(dialog).toContainText("请填写至少 2 个字的删除理由"); expect(calls.deleteReimbursementAttachment).toBe(0); await dialog.getByRole("textbox", { name: "删除理由" }).fill("一"); await page.getByTestId("attachment-delete-confirm-submit").click(); await expect(dialog).toContainText("请填写至少 2 个字的删除理由"); expect(calls.deleteReimbursementAttachment).toBe(0); await dialog.getByRole("button", { name: "取消" }).click(); await expect(dialog).not.toBeVisible(); expect(calls.deleteReimbursementAttachment).toBe(0); await attachment.getByRole("button", { name: "删除附件", exact: true }).click(); await page.getByRole("textbox", { name: "删除理由" }).fill("附件重复上传"); await page.getByTestId("attachment-delete-confirm-submit").click(); await expect.poll(() => calls.deleteReimbursementAttachment).toBe(1); expect(calls.attachmentDeleteReasons).toEqual(["附件重复上传"]); await expect(dialog).not.toBeVisible(); }); for (const width of [1440, 390]) { for (const state of ["system_failed", "key_failed", "snapshot_failed"] as const) { test(`回单系统失败引导管理员且不重传 ${width} ${state}`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); const calls = await mockSystemActionApis(page, { receiptOcrState: state, receiptReady: false }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" }); await expect(receipt).toContainText("请联系管理员"); await expect(page.getByRole("status").filter({ hasText: "回单尚未满足付款登记条件" })).toContainText("存在系统核验故障"); await expect(page.getByText("请查看核验详情", { exact: false })).toHaveCount(0); await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0); await expect(receipt.getByRole("button", { name: "核验详情", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0); expect(calls.retryReceiptOcr).toBe(0); expect(calls.executePaymentBatch).toBe(0); }); } test(`回单未核验不能登记付款 ${width}`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptReady: false }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByText("回单尚未满足付款登记条件", { exact: false })).toBeVisible(); await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0); expect(calls.executePaymentBatch).toBe(0); }); for (const correction of [false, true]) { test(`回单人工核验只提交关联或更正且不付款 ${width} ${correction}`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.getByRole("button", { name: "核验详情", exact: true }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toContainText("0000****2222"); await dialog.getByRole("combobox", { name: "第1页对应付款明细" }).selectOption("payment-batch-item-action-confirm"); if (correction) { await dialog.getByRole("button", { name: "更正识别", exact: true }).click(); await dialog.getByRole("textbox", { name: "第1页更正小写金额", exact: true }).fill("88.00"); await expect(dialog.getByRole("button", { name: "保存核验", exact: true })).toBeDisabled(); await dialog.getByRole("textbox", { name: "更正说明(仅关联可留空)", exact: true }).fill("核对原件更正金额"); } await expect(dialog).toBeInViewport(); await dialog.getByRole("button", { name: "保存核验", exact: true }).click(); await expect.poll(() => calls.manualReceiptSaves.length).toBe(1); expect(calls.manualReceiptSaves[0]).toEqual({ expectedVersion: 3, ...(correction ? { reason: "核对原件更正金额" } : {}), pages: [{ pageNumber: 1, itemId: "payment-batch-item-action-confirm", ...(correction ? { fields: [{ Name: "小写金额", Value: "88.00" }] } : {}) }], }); expect(calls.executePaymentBatch).toBe(0); expect(calls.uploadPaymentReceipt).toBe(0); await expect(dialog).not.toBeVisible(); }); } test(`回单独立复核查看更正且不触发付款 ${width}`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptManualReview: true, meOverride: { id: "independent-receipt-reviewer" } }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.getByRole("button", { name: "核验详情", exact: true }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toContainText("人工核验结果"); await expect(dialog).toContainText("更正字段:金额"); await expect(dialog.getByRole("button", { name: "确认复核", exact: true })).toBeDisabled(); await dialog.getByRole("textbox", { name: "复核说明", exact: true }).fill("已核对合成原回单"); await dialog.getByRole("button", { name: "确认复核", exact: true }).click(); await expect.poll(() => calls.manualReceiptReviews.length).toBe(1); expect(calls.manualReceiptReviews[0]).toEqual({ expectedVersion: 3, decision: "approve", reason: "已核对合成原回单" }); expect(calls.executePaymentBatch).toBe(0); expect(calls.manualReceiptSaves).toHaveLength(0); }); test(`回单OCR失败可重试且不触发付款登记 ${width}`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" }); await expect(receipt).toContainText("识别未完成,可重试"); await receipt.getByRole("button", { name: "重新识别", exact: true }).click(); await expect(receipt).toContainText("等待识别"); await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0); expect(calls.retryReceiptOcr).toBe(1); expect(calls.executePaymentBatch).toBe(0); expect(calls.uploadPaymentReceipt).toBe(0); }); } test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => { const calls = await mockSystemActionApis(page); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" }); await expect(receipt).toBeVisible(); await receipt.getByRole("button", { name: "作废" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toContainText("合成银行回单.pdf"); await expect(dialog).toContainText("文件记录会保留并标记为已作废"); await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled(); expect(calls.voidPaymentReceipt).toBe(0); await page.getByRole("textbox", { name: "回单作废原因" }).fill("一"); await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled(); expect(calls.voidPaymentReceipt).toBe(0); await dialog.getByRole("button", { name: "返回" }).click(); expect(calls.voidPaymentReceipt).toBe(0); await receipt.getByRole("button", { name: "作废" }).click(); await page.getByRole("textbox", { name: "回单作废原因" }).fill("回单文件上传有误"); await page.getByRole("button", { name: "确认作废" }).click(); await expect.poll(() => calls.voidPaymentReceipt).toBe(1); expect(calls.receiptVoidReasons).toEqual(["回单文件上传有误"]); }); test("只有超级管理员职责且未兼任经理或负责人时不显示付款复核", async ({ page }) => { await mockSystemActionApis(page, { batchStatus: "draft", meOverride: { roleCodes: ["admin"] }, }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible(); await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0); }); test("兼任付款复核职责的制单人也不显示自审动作", async ({ page }) => { await mockSystemActionApis(page, { batchStatus: "draft", meOverride: { roleCodes: ["manager", "finance"], permissions: [...me.permissions, "payment.batch.review"], }, }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("待非制单人复核付款"); await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0); }); test("回单查询失败时不把未知状态误报为空且不显示付款动作", async ({ page }) => { await mockSystemActionApis(page, { receiptQueryError: true }); await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByText(/服务暂不可用/)).toBeVisible(); await expect(page.getByRole("button", { name: "重新加载" })).toBeVisible(); await expect(page.getByText(/暂无银行回单/)).toHaveCount(0); await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); }); });