import { test, expect } from "./helpers/fixtures"; import { ADMIN, apiGetData, createAuthenticatedApiClient, EMPLOYEE, FINANCE, FINANCE_MGR, login, logout, MANAGER, } from "./helpers/auth"; import { ensureFeatureFlagOn } from "./helpers/featureFlags"; import { openNavLink } from "./helpers/navigation"; import { TEST_IDS } from "./helpers/testIds"; test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ page, reimbursementDetail, workflowDetail, }) => { test.setTimeout(300_000); const adminApi = await createAuthenticatedApiClient(ADMIN); const employeeApi = await createAuthenticatedApiClient(EMPLOYEE); const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR); try { await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow"); await ensureFeatureFlagOn(adminApi.api, "erp_sync", "playwright reimbursement payment batch flow"); await login(page, EMPLOYEE); await (await openNavLink(page, "我的报销")).click(); await expect(page).toHaveURL(/\/reimbursements/); const createResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok(); }); await page.getByTestId("reimbursements-create").click(); await page.getByRole("menuitem", { name: "空白报销" }).click(); const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } }; const reimbursementId = createPayload.data?.id; if (!reimbursementId) { throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`); } await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); const marker = `R78_BATCH_${Date.now()}_${Math.random().toString(16).slice(2)}`; const invoiceId = await reimbursementDetail.uploadInvoicePdf( `r78-${Date.now()}.pdf`, Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"), ); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); await reimbursementDetail.submitReimbursement(); const reimbursementAfterSubmit = await apiGetData<{ formNumber: string | null; status: string; totalAmount: string | null; }>( employeeApi.api, `/api/reimbursements/${reimbursementId}`, ); expect(reimbursementAfterSubmit.status).toBe("reviewing"); await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible(); const reimbursementDisplayCode = reimbursementAfterSubmit.formNumber ?? reimbursementId; const secondApprovalUser = Number(reimbursementAfterSubmit.totalAmount ?? 0) > 500 ? FINANCE_MGR : FINANCE; await logout(page, EMPLOYEE.name); await login(page, MANAGER); await (await openNavLink(page, "待我审批")).click(); await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first(); await expect(managerTaskRow).toBeVisible(); await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click(); await workflowDetail.assertLoaded(); await workflowDetail.approve(); await expect(page.getByText("已完成", { exact: true }).first()).toBeVisible(); await expect(page.getByText(`通过 · ${MANAGER.name}`, { exact: true })).toBeVisible(); await logout(page, MANAGER.name); await login(page, secondApprovalUser); await (await openNavLink(page, "待我审批")).click(); await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first(); await expect(financeTaskRow).toBeVisible(); await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click(); await workflowDetail.assertLoaded(); await workflowDetail.approve(); await expect(page.getByText("已完成", { exact: true }).first()).toBeVisible(); await expect(page.getByText(`通过 · ${secondApprovalUser.name}`, { exact: true })).toBeVisible(); await logout(page, secondApprovalUser.name); await login(page, FINANCE); await page.goto("/payment-batches"); await expect(page).toHaveURL(/\/payment-batches/); const createCard = page.getByTestId("payment-batch-create-panel"); await expect(createCard).toBeVisible(); const createBatchResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); }); await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first(); await expect(candidateRow).toBeVisible(); await candidateRow.getByRole("checkbox").check(); await createCard.getByTestId("payment-batch-default-channel").selectOption("bank"); await createCard.getByRole("button", { name: "创建并提交复核" }).click(); const createBatchResponse = await createBatchResponsePromise; expect(createBatchResponse.request().postDataJSON()).toMatchObject({ entityType: "reimbursement", entityIds: [reimbursementId], defaultPaymentChannel: "bank", }); const createBatchPayload = (await createBatchResponse.json()) as { data?: { item?: { id?: string; name?: string } }; }; const batchId = createBatchPayload.data?.item?.id; const batchName = createBatchPayload.data?.item?.name; if (!batchId || !batchName) { throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); } await page.goto(`/payment-batches?id=${batchId}`); const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); await expect(batchDetailCard).toBeVisible(); await expect(batchDetailCard).toContainText("待付款复核"); await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理"); await expect(batchDetailCard).toContainText("0/1"); const draftItemsPanel = page.getByTestId("payment-batch-items-panel"); await expect(draftItemsPanel).toContainText(EMPLOYEE.name); await expect(draftItemsPanel).toContainText("开户银行"); await expect(draftItemsPanel).toContainText("银行账户"); await expect(draftItemsPanel).toContainText("银行转账"); await expect(draftItemsPanel.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0); const draftReceiptPanel = page.getByTestId("payment-batch-receipt-panel"); await expect(draftReceiptPanel).toContainText("付款复核通过后,由财务在线下完成付款并上传银行回单"); await expect(draftReceiptPanel.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0); await expect(draftReceiptPanel.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await logout(page, FINANCE.name); await login(page, MANAGER); await page.goto(`/payment-batches?id=${batchId}`); const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); await expect(managerBatchDetail).toBeVisible(); await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name); await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "复核通过" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核"); await page.getByTestId("action-confirm-submit").click(); await expect(managerBatchDetail).toContainText("1/1"); await logout(page, MANAGER.name); await login(page, FINANCE); await page.goto(`/payment-batches?id=${batchId}`); const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); await expect(executedBatchDetailCard).toBeVisible(); await expect(executedBatchDetailCard).toContainText("1/1"); const receiptCard = page.getByTestId("payment-batch-receipt-panel"); await expect(receiptCard).toBeVisible(); await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible(); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "上传银行回单" })).toBeVisible(); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toHaveCount(0); const receiptUploadResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` && response.ok(); }); await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({ name: "r78-bank-receipt.pdf", mimeType: "application/pdf", buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"), }); await receiptUploadResponsePromise; await expect(receiptCard).toContainText("r78-bank-receipt.pdf"); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible(); const executeBatchResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/payment-batches/${batchId}/execute` && response.ok(); }); await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); await page.getByTestId("action-confirm-submit").click(); await executeBatchResponsePromise; await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 }); await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0); await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible(); await expect(receiptCard.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0); await expect(receiptCard.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(receiptCard.getByRole("button", { name: "作废" })).toHaveCount(0); await expect(receiptCard.getByRole("button", { name: "下载" })).toBeVisible(); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); const reimbursementAfterPayment = await apiGetData<{ status: string }>( employeeApi.api, `/api/reimbursements/${reimbursementId}`, ); expect(reimbursementAfterPayment.status).toBe("paid"); await expect(page.getByText("已支付", { exact: true }).first()).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款"); await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0); await expect(page.getByText("执行支付")).toHaveCount(0); const paymentAudit = await apiGetData<{ items: Array<{ action: string; targetId: string | null; extra: Record }>; }>( adminApi.api, "/api/audit-logs?action=REIMBURSEMENT_PAYMENT_MARKED&page=1&pageSize=20", ); expect( paymentAudit.items.some((item) => item.targetId === reimbursementId && item.extra?.idempotent === false), "付款动作应写入报销付款审计", ).toBe(true); const receiptAudit = await apiGetData<{ items: Array<{ action: string; targetId: string | null; extra: Record }>; }>( adminApi.api, "/api/audit-logs?action=PAYMENT_BATCH_RECEIPT_UPLOADED&page=1&pageSize=20", ); expect( receiptAudit.items.some((item) => item.targetId === batchId && Array.isArray(item.extra?.attachmentIds)), "银行回单上传应写入付款批次审计", ).toBe(true); await page.goto("/ap-operations"); await page.getByRole("tab", { name: /财务台账/ }).click(); await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursementDisplayCode); await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款"); await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible(); await expect(page.getByText(batchName)).toHaveCount(0); await logout(page, FINANCE.name); const invoiceDetail = await apiGetData<{ status: string; archivedAt: string | null; linkedReimbursementId: string | null; }>( employeeApi.api, `/api/invoice-pool/${invoiceId}`, ); expect(invoiceDetail.status).toBe("archived"); expect(invoiceDetail.archivedAt).toBeTruthy(); expect(invoiceDetail.linkedReimbursementId).toBe(reimbursementId); const erpPreview = await apiGetData<{ exportState: { lastOutboxId: string | null; lastOutboxStatus: string | null; }; }>( financeMgrApi.api, `/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`, ); expect(erpPreview.exportState.lastOutboxId).toBeTruthy(); expect(erpPreview.exportState.lastOutboxStatus).toBe("pending"); } finally { await Promise.all([adminApi.dispose(), employeeApi.dispose(), financeMgrApi.dispose()]); } });