报销付款主链路手工录入回单字段时,显式提交合成正式回单的原件依据和已核对声明,不再依赖受限本地解析一定成功。保留拒绝自审、独立复核、最终付款和下游收口断言。 记录安全的凭证类别、来源及失败业务原因,避免只留下通用 VALID_001。首轮完整候选的 371 项浏览器通过及 1 项失败原样保留;因原记录缺少具体原因,不将解析超时追认为已确认根因。 验证:回单服务端定向 93 项通过;受管隔离环境的报销付款主链路通过并完成核销、下游收口及自有资源清理;任务、流程、文档、敏感和差异检查通过。 仅修改测试 helper、规约和任务记录,应用资金规则、解析限时、数据库及正式门禁范围未改。后续对本提交重新运行完整 verify-candidate。 独立代码复核及真实还款验收待完成;未推送、未部署、未访问生产或写入真实业务数据。
270 行
19 KiB
TypeScript
270 行
19 KiB
TypeScript
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: "[email protected]", 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;
|
|
pages: Array<{document?: {kind: "bank_receipt" | "non_final" | "unknown"; source: "pdf_text" | "image" | "unreadable" | "manual"} | null}>} };
|
|
await expect.poll(async () => (await apiGetData<Detail>(api, base)).item.state, {timeout: 60_000}).toBe("ocr_failed");
|
|
const { item: detail } = await apiGetData<Detail>(api, base);
|
|
expect(detail.pagesExpected).toBe(1);
|
|
console.info("[reimbursement-payment-flow]", JSON.stringify({stage: "receipt-manual-source",
|
|
state: detail.state, pagesExpected: detail.pagesExpected,
|
|
documentKind: detail.pages[0]?.document?.kind ?? null, documentSource: detail.pages[0]?.document?.source ?? null}));
|
|
const response = await api.post(base, {data: {
|
|
expectedVersion: detail.version, reason: "合成回单逐项核对,不代表真实银行付款", pages: [{pageNumber: 1, itemId,
|
|
// This fixture owns a formal success receipt and manually enters its facts.
|
|
// A bounded local parser may remain unknown; record the actual manual basis
|
|
// rather than depend on a successful automatic document classification.
|
|
paymentProof: {basis: "explicit_success", originalChecked: true, note: "合成正式回单明确显示交易成功,已核对完整原件与付款字段"},
|
|
fields: Object.entries({小写金额: "10.00", 币别: "CNY", 付款人账号: payer, 收款人账号: payee, 凭证号: `SYNTHETIC-${batchId}`, 单据状态: "交易成功"}).map(([Name, Value]) => ({Name, Value}))}],
|
|
}});
|
|
const result = await response.json();
|
|
const reason = result.error?.details?.reasonCode;
|
|
const safeReason = typeof reason === "string" && /^payment_batch\.[a-z_]{1,100}$/.test(reason) ? reason : "unavailable";
|
|
expect(response.ok(), `synthetic manual receipt HTTP ${response.status()} reason=${safeReason}`).toBe(true);
|
|
const proposal = result.data.item as {id: string; version: number; status: string};
|
|
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");
|
|
}
|
|
}
|