fix: 优先引导下载银行制单(09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-09 09:05:17 +08:00
父节点 e8cad812ad
当前提交 b9185308a6
共修改 10 个文件,包含 526 行新增和 17 行删除
+10 -6
查看文件
@@ -294,19 +294,22 @@ export function PaymentBatchesPage() {
!["executed", "cancelled"].includes(selectedBatch.status);
const paymentActionModel = useMemo(() => {
const canDownloadOrder =
selectedBatch?.status === "reviewed" &&
selectedBatch.canExportPaymentOrder &&
canExportPaymentOrder;
const primary = canReviewSelectedBatch
? "review"
: needsBankReceipt
? "uploadReceipt"
? canDownloadOrder && selectedBatch.paymentOrderExportCount === 0
? "downloadOrder"
: "uploadReceipt"
: canRegisterPaymentComplete
? "execute"
: null;
const more: PaymentActionKind[] = [];
if (
selectedBatch?.status === "reviewed" &&
selectedBatch.canExportPaymentOrder &&
canExportPaymentOrder
) {
if (primary === "downloadOrder" && canUploadReceipt) more.push("uploadReceipt");
if (canDownloadOrder && primary !== "downloadOrder") {
more.push(selectedBatch.paymentOrderExportCount > 0 ? "redownloadOrder" : "downloadOrder");
}
if (canCancelSelectedBatch) more.push("cancel");
@@ -316,6 +319,7 @@ export function PaymentBatchesPage() {
canExportPaymentOrder,
canRegisterPaymentComplete,
canReviewSelectedBatch,
canUploadReceipt,
needsBankReceipt,
selectedBatch,
]);
@@ -1,9 +1,12 @@
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";
test.use({ serviceWorkers: "block" });
const loadServerModule = createRequire(import.meta.url);
function envelope<T>(data: T) {
return { requestId: "mock-system-action-confirm", data };
@@ -21,6 +24,26 @@ function navigationItem(key: string, href: string, label: string, icon: string,
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: "[email protected]",
password: "ActionConfirm1234",
@@ -209,7 +232,9 @@ function paymentBatch(
voucherPrefix: "R7E",
totalAmount: "88.00",
highRisk,
paymentAccount: null,
paymentAccount: canExportPaymentOrder ? {
accountName: "合成付款公司", bankName: "中国建设银行", bankInstitutionCode: "CCB", accountNumberMasked: "0001********5678",
} : null,
paymentOrderExportCount,
canExportPaymentOrder,
paymentOrderExportBlockers: [],
@@ -281,6 +306,8 @@ async function mockSystemActionApis(
paymentChannel?: "bank" | "cash";
canExportPaymentOrder?: boolean;
paymentOrderExportCount?: number;
paymentOrderFile?: Buffer;
paymentOrderError?: boolean;
paymentEntityType?: "reimbursement" | "travel_order";
reimbursementStatus?: "draft" | "reviewing" | "paid";
travelReimbursement?: boolean;
@@ -290,12 +317,14 @@ async function mockSystemActionApis(
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true;
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,
@@ -489,7 +518,7 @@ async function mockSystemActionApis(
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
paymentOrderExportCount,
options.paymentEntityType,
);
await fulfillJson(route, {
@@ -515,13 +544,25 @@ async function mockSystemActionApis(
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
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" }, body: options.paymentOrderFile });
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") {
if (options.receiptQueryError) {
await route.fulfill({
@@ -739,14 +780,16 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const actionPanel = page.getByTestId("payment-batch-action-panel");
await expect(actionPanel.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await expect(actionPanel.getByTestId("payment-batch-action-downloadOrder")).toBeVisible();
await actionPanel.getByText("更多", { exact: true }).click();
await expect(actionPanel.getByRole("button", { name: "下载银行制单" })).toBeVisible();
await expect(actionPanel.getByRole("button", { 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);
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({
const desktopFileChooser = page.waitForEvent("filechooser");
await actionPanel.getByRole("button", { name: "上传银行回单" }).click();
await (await desktopFileChooser).setFiles({
name: "合成银行回单.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nsynthetic receipt\n%%EOF\n", "utf8"),
@@ -766,12 +809,12 @@ test.describe("R7E 全站高风险操作二次确认", () => {
const mobileActions = page.getByTestId("payment-batch-mobile-actions");
await expect(mobileActions).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel")).toBeHidden();
await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await expect(mobileActions.getByTestId("payment-batch-action-downloadOrder")).toBeVisible();
await mobileActions.getByRole("button", { name: "更多" }).click();
await expect(page.getByRole("menuitem", { name: "下载银行制单" })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({
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"),
@@ -782,6 +825,72 @@ test.describe("R7E 全站高风险操作二次确认", () => {
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();
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: "确认下载" })).toBeDisabled();
await dialog.getByRole("button", { name: "返回", exact: true }).click();
expect(calls.paymentOrderReasons).toEqual([]);
await actions.getByTestId("payment-batch-action-downloadOrder").click();
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成网银制单验证");
const firstDownload = page.waitForEvent("download");
await dialog.getByRole("button", { name: "确认下载" }).click();
const downloaded = await firstDownload;
expect(downloaded.suggestedFilename()).toBe("payment-order-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 actions.getByTestId("payment-batch-action-redownloadOrder").click();
await expect(dialog).toContainText("请勿重复提交");
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成文件重新下载验证");
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(["合成网银制单验证", "合成文件重新下载验证"]);
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 dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成制单失败验证");
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 });