import { createRequire } from "node:module"; import { expect, type APIRequestContext, type Page } from "@playwright/test"; import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, E2E_BASE_URL, apiGetData, apiPostData, createAuthenticatedApiClient, login, logout } from "./auth"; import { ReimbursementDetailPageObject } from "./pageObjects/ReimbursementDetailPage"; const HR = { identifier: "hr@hyinda.com", password: "HrPassword1234", name: "HR" }; // Public fictitious accounts; exclusively written to the run-owned local database. const payer = "000011112222"; const payee = "000033334444"; const requireServer = createRequire(new URL("../../../../server/package.json", import.meta.url)); async function syntheticPdf(label: string) { const pdf = await requireServer("pdf-lib").PDFDocument.create(); pdf.addPage().drawText(label); return Buffer.from(await pdf.save()); } async function prepareBankFacts(api: APIRequestContext, employeeId: string) { expect(new URL(E2E_BASE_URL).hostname).toBe("127.0.0.1"); expect(process.env.QA_ENVIRONMENT_CONTEXT, "financial fixture needs the managed disposable environment").toBeTruthy(); const banks = await apiGetData<{items: Array<{id: string; code: string}>}>(api, "/api/bank-institutions"); const bank = banks.items.find(item => item.code === "CCB"); expect(bank).toBeTruthy(); const entities = await apiGetData<{items: Array<{id: string}>}>(api, "/api/master-data/legal-entities?enabled=true"); expect(entities.items).toHaveLength(1); const legalEntityId = entities.items[0].id; const created = await api.post("/api/payment-accounts", { data: { legalEntityId, bankInstitutionId: bank!.id, accountName: "合成企业付款账户", accountNumber: payer } }); expect([201, 409]).toContain(created.status()); const accounts = await apiGetData<{items: Array<{legalEntity: {id: string}; enabled: boolean; accountName: string; accountNumberMasked: string}>}>(api, "/api/payment-accounts"); const active = accounts.items.filter(item => item.enabled && item.legalEntity.id === legalEntityId); expect(active).toHaveLength(1); expect(active[0].accountName).toBe("合成企业付款账户"); expect(active[0].accountNumberMasked).toContain("2222"); const hr = await createAuthenticatedApiClient(HR); try { const response = await hr.api.patch(`/api/users/${employeeId}/sensitive`, {data: {bankInstitutionId: bank!.id, bankAccount: payee}}); expect(response.ok(), `synthetic bank setup HTTP ${response.status()}`).toBe(true); } finally { await hr.dispose(); } } async function manualReceipt(api: APIRequestContext, reviewer: APIRequestContext, batchId: string, itemId: string, attachmentId: string) { const base = `/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification`; type Detail = { item: {version: number; state: string; pagesExpected: number} }; await expect.poll(async () => (await apiGetData(api, base)).item.state, {timeout: 60_000}).toBe("ocr_failed"); const { item: detail } = await apiGetData(api, base); expect(detail.pagesExpected).toBe(1); const { item: proposal } = await apiPostData<{item: {id: string; version: number; status: string}}>(api, base, { expectedVersion: detail.version, reason: "合成回单逐项核对,不代表真实银行付款", pages: [{pageNumber: 1, itemId, fields: Object.entries({小写金额: "10.00", 币别: "CNY", 付款人账号: payer, 收款人账号: payee, 凭证号: `SYNTHETIC-${batchId}`, 单据状态: "交易成功"}).map(([Name, Value]) => ({Name, Value}))}], }); expect(proposal.status).toBe("pending_review"); const reviewPath = `${base}/${proposal.id}/review`; const body = {expectedVersion: proposal.version, decision: "approve", reason: "合成独立复核"}; expect((await api.post(reviewPath, {data: body})).status()).toBe(403); await apiPostData(reviewer, reviewPath, body); } export async function reimbursementPaymentFlow(page: Page) { const started = Date.now(); const checkpoint = (stage: string) => console.info("[reimbursement-payment-flow]", JSON.stringify({ stage, elapsedMs: Date.now() - started })); checkpoint("api-auth:start"); const detail = new ReimbursementDetailPageObject(page); const clients = await Promise.all([ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER].map(user => createAuthenticatedApiClient(user))); const [admin, employee, finance, financeMgr, manager] = clients; checkpoint("api-auth:complete"); try { checkpoint("bank-fixture:start"); await prepareBankFacts(financeMgr.api, employee.me.id); checkpoint("employee-ui:start"); await login(page, EMPLOYEE, { reuseLoginPage: true }); await page.goto("/reimbursements"); const creating = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/reimbursements"); await page.getByTestId("reimbursements-create").click(); await page.getByRole("menuitem", {name: "空白报销"}).click(); const created = await creating; expect(created.ok()).toBe(true); const reimbursementId = (await created.json()).data.id as string; await detail.assertLoaded(); await detail.addItem(); checkpoint("invoice-upload:start"); const invoicePdf = await syntheticPdf(`Synthetic invoice ${reimbursementId}`); const invoiceId = await detail.uploadInvoicePdf(`synthetic-${reimbursementId}.pdf`, invoicePdf); await expect(page.locator(`#reimbursement-invoice-${invoiceId}`)).toBeVisible(); { checkpoint("invoice-view:start"); const signedResponse = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/invoice-pool/${invoiceId}/signed-url`); const popupReady = page.waitForEvent("popup"); await page.locator(`#reimbursement-invoice-${invoiceId}`).getByRole("button", {name: /^查看发票/}).click(); const popup = await popupReady; checkpoint("invoice-view:popup"); // Headless PDF navigation can remain about:blank. Verify the UI-created // signed URL serves the exact valid PDF; native viewer rendering is separate. const signed = await signedResponse; expect(signed.ok()).toBe(true); const url = (await signed.json()).data.signedUrl as string; expect(new URL(url).origin).toBe(new URL(E2E_BASE_URL).origin); expect(new URL(url).pathname).toBe(`/api/invoice-pool/${invoiceId}/file`); const file = await page.request.get(url); expect(file.ok()).toBe(true); expect(file.headers()["content-type"]).toContain("application/pdf"); expect(file.headers()["content-disposition"]).toContain("inline"); expect(await file.body()).toEqual(invoicePdf); await popup.close(); } checkpoint("reimbursement-submit:start"); const submitted = await detail.submitReimbursement(); const reimbursement = await apiGetData<{formNumber: string; status: string}>(employee.api, `/api/reimbursements/${reimbursementId}`); expect(reimbursement.status).toBe("reviewing"); await expect(detail.workflowProgress).toContainText(MANAGER.name); await expect(detail.workflowProgress).toContainText("财务复核"); await logout(page, EMPLOYEE.name); let taskId: string | null = submitted.currentTaskId; for (const [user, client, role] of [[MANAGER, manager, "manager"], [FINANCE, finance, "finance"]] as const) { checkpoint(`${role}-approval:start`); expect(taskId).toBeTruthy(); const task = await apiGetData<{task: {stepId: string; assigneeId: string | null; assigneeRole: string | null}}>(client.api, `/api/workflow/tasks/${taskId}`); expect(task.task.stepId).toBe(role === "manager" ? "approval_1_manager" : "approval_2_finance"); if (role === "manager") expect(task.task.assigneeId).toBe(manager.me.id); else expect(task.task.assigneeRole).toBe("finance"); await login(page, user, { reuseLoginPage: true }); await page.goto(`/workflow/tasks/${taskId}`); await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible(); const approving = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/workflow/tasks/${taskId}/approve`); await page.getByTestId("workflow-task-approve").click(); await expect(page.getByTestId("workflow-action-confirm-dialog")).toBeVisible(); await page.getByTestId("workflow-action-confirm-submit").click(); const approved = await approving; expect(approved.ok()).toBe(true); const result = (await approved.json()).data; expect(result.reimbursementStatus).toBe(role === "manager" ? "reviewing" : "approved"); taskId = result.nextTaskId; if (role === "finance") { expect(taskId).toBeNull(); // No reload: covers the reported finance progress refresh regression. const financeStage = page.getByTestId("reimbursement-approval-processing-progress").locator("li").filter({hasText: "财务复核"}); await Promise.all([ expect(financeStage).toContainText("通过"), expect(financeStage).toContainText(FINANCE.name), expect(financeStage).not.toContainText("处理中"), expect(financeStage).toHaveAttribute("data-state", "completed"), ]); } // The same finance actor next creates the batch. Keep that real session // until the next role change instead of immediately logging it in again. if (role !== "finance") await logout(page, user.name); checkpoint(`${role}-approval:complete`); } await expect.poll(async () => { const response = await employee.api.get(`/api/reimbursements/${reimbursementId}`); const payload = await response.json(); if (!response.ok()) { const code = /^[A-Z][A-Z0-9_]{0,40}$/.test(payload.error?.code ?? "") ? payload.error.code : "UNKNOWN"; const audit = await admin.api.get("/api/audit-logs?pageSize=100"); const history = audit.ok() ? (await audit.json()).data?.items ?? [] : []; const allowed = new Set(["REFRESH_REUSE_DETECTED", "AUTH_PASSWORD_CHANGED", "AUTH_PASSWORD_RESET", "SYSTEM_USER_ROLES_SET", "SYSTEM_USER_DISABLED"]); console.info("[reimbursement-payment-flow]", JSON.stringify({ stage: "employee-after-approval:error", status: response.status(), code, auditStatus: audit.status(), authEvents: history.filter((item: { targetId: string; action: string; createdAt: string }) => item.targetId === employee.me.id && allowed.has(item.action) && Date.parse(item.createdAt) >= started) .map((item: { action: string; createdAt: string }) => ({ action: item.action, elapsedMs: Date.parse(item.createdAt) - started })), })); throw new Error(`Employee approval read HTTP ${response.status()} ${code}`); } return payload.data.status; }).toBe("approved"); checkpoint("payment-batch-create:start"); await page.goto("/payment-batches"); const createPanel = page.getByTestId("payment-batch-create-panel"); await createPanel.getByTestId("payment-batch-candidate-search").fill(reimbursement.formNumber); const candidate = createPanel.getByTestId("payment-batch-candidate-row").filter({hasText: reimbursement.formNumber}); await expect(candidate).toBeVisible(); await candidate.getByRole("checkbox").check(); await createPanel.getByTestId("payment-batch-default-channel").selectOption("bank"); const creatingBatch = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/payment-batches"); await createPanel.getByRole("button", {name: "创建并提交复核"}).click(); const createdBatch = await creatingBatch; expect(createdBatch.ok()).toBe(true); expect(createdBatch.request().postDataJSON()).toMatchObject({entityType: "reimbursement", entityIds: [reimbursementId], defaultPaymentChannel: "bank"}); const batch = (await createdBatch.json()).data.item as {id: string; name: string; items: Array<{id: string}>}; await page.goto(`/payment-batches?id=${batch.id}`); await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("0/1"); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "复核通过"})).toHaveCount(0); expect((await finance.api.post(`/api/payment-batches/${batch.id}/review`, {data: {}})).status()).toBe(403); await logout(page, FINANCE.name); checkpoint("payment-batch-review:start"); await login(page, MANAGER, { reuseLoginPage: true }); await page.goto(`/payment-batches?id=${batch.id}`); 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(page.getByTestId("payment-batch-detail-panel")).toContainText("1/1"); await logout(page, MANAGER.name); await login(page, FINANCE, { reuseLoginPage: true }); await page.goto(`/payment-batches?id=${batch.id}`); expect((await finance.api.post(`/api/payment-batches/${batch.id}/execute`, {data: {}})).status()).toBe(409); checkpoint("receipt-upload:start"); const receiptPdf = await requireServer('../scripts/qa/synthetic-bank-receipt.cjs').syntheticBankReceiptPdf({ tag: `Synthetic receipt ${batch.id}`, reference: `SYNTHETIC-${batch.id}`, payerAccount: payer, payeeAccount: payee, }); const uploading = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/receipt-attachments`); await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({name: "synthetic-receipt.pdf", mimeType: "application/pdf", buffer: receiptPdf}); const upload = await uploading; expect(upload.ok()).toBe(true); const attachmentId = (await upload.json()).data.items[0].id as string; const batchDetail = await apiGetData<{item: {items: Array<{id: string}>}}>(finance.api, `/api/payment-batches/${batch.id}`); checkpoint("receipt-manual-review:start"); await manualReceipt(finance.api, manager.api, batch.id, batchDetail.item.items[0].id, attachmentId); checkpoint("payment-execution:start"); await page.reload(); const executing = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/execute`); await page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "登记付款完成"}).click(); await page.getByTestId("action-confirm-submit").click(); expect((await executing).ok()).toBe(true); await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("已付款"); const receipts = page.getByTestId("payment-batch-receipt-panel"); await Promise.all([ expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0), expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0), expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible(), expect(receipts.getByRole("button", {name: "上传银行回单", exact: true})).toHaveCount(0), expect(receipts.getByRole("button", {name: "作废", exact: true})).toHaveCount(0), expect(receipts.getByRole("button", {name: "下载", exact: true})).toBeVisible(), ]); checkpoint("downstream-audit:start"); await Promise.all([["REIMBURSEMENT_PAYMENT_MARKED", reimbursementId], ["PAYMENT_BATCH_RECEIPT_UPLOADED", batch.id], ["PAYMENT_RECEIPT_MANUAL_REVIEWED", batch.id]].map(async ([action, targetId]) => { const audit = await apiGetData<{items: Array<{targetId: string; extra: {idempotent?: boolean; attachmentIds?: string[]}}>}>(admin.api, `/api/audit-logs?action=${action}&pageSize=50`); const event = audit.items.find(item => item.targetId === targetId); expect(event, `${action} audit required`).toBeTruthy(); if (action === "REIMBURSEMENT_PAYMENT_MARKED") expect(event!.extra.idempotent).toBe(false); if (action === "PAYMENT_BATCH_RECEIPT_UPLOADED") expect(event!.extra.attachmentIds).toContain(attachmentId); })); await page.goto("/ap-operations"); await page.getByRole("tab", {name: /财务台账/}).click(); await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursement.formNumber); await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款"); await page.goto(`/reimbursements/${reimbursementId}`); await detail.assertLoaded(); expect((await apiGetData<{status: string}>(employee.api, `/api/reimbursements/${reimbursementId}`)).status).toBe("paid"); await expect(detail.summary).toContainText("已支付"); await expect(detail.workflowProgress).toContainText("已付款"); await expect(page.getByTestId("reimbursement-pay")).toHaveCount(0); const invoice = await apiGetData<{status: string; archivedAt: string; linkedReimbursementId: string}>(employee.api, `/api/invoice-pool/${invoiceId}`); expect(invoice).toMatchObject({status: "archived", linkedReimbursementId: reimbursementId}); expect(invoice.archivedAt).toBeTruthy(); expect((await financeMgr.api.get(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`)).status()).toBe(404); await logout(page, FINANCE.name); await page.goto("/dashboard"); await expect(page).toHaveURL(/\/login/); } finally { checkpoint("api-dispose:start"); await Promise.all(clients.map(client => client.dispose())); checkpoint("api-dispose:complete"); } }