文件
hyb-oa/client/tests/e2e/system-action-confirmation.spec.ts
T

1013 行
42 KiB
TypeScript

import type { Page, Route } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { login } from "./helpers/auth";
test.use({ serviceWorkers: "block" });
function envelope<T>(data: T) {
return { requestId: "mock-system-action-confirm", data };
}
async function fulfillJson<T>(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 };
}
const user = {
identifier: "[email protected]",
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" | "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: 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;
highRisk?: boolean;
paymentChannel?: "bank" | "cash";
canExportPaymentOrder?: boolean;
paymentOrderExportCount?: number;
paymentEntityType?: "reimbursement" | "travel_order";
reimbursementStatus?: "draft" | "reviewing" | "paid";
travelReimbursement?: boolean;
meOverride?: Partial<typeof me>;
} = {},
) {
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true;
let currentReimbursementStatus = options.reimbursementStatus ?? "draft";
const calls = {
deleteReimbursement: 0,
deleteReimbursementAttachment: 0,
exportReimbursements: 0,
executePaymentBatch: 0,
paymentBatchDetail: 0,
previewPaymentReceipt: 0,
uploadPaymentReceipt: 0,
voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
reimbursementUpdates: [] as Array<Record<string, unknown>>,
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));
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") {
const body = request.postDataJSON() as Record<string, unknown>;
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,
options.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,
options.paymentOrderExportCount,
options.paymentEntityType,
),
});
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",
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("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.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await actionPanel.getByText("更多", { exact: true }).click();
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({
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.getByRole("button", { name: "上传银行回单" })).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({
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);
});
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-processing-panel",
"payment-batch-items-panel",
"payment-batch-receipt-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("sticky");
});
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.locator(".detail-evidence-card").filter({ hasText: "合成报销敏感附件.pdf" });
await expect(attachment).toBeVisible();
await attachment.getByRole("button", { name: "删除" }).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("button", { name: "取消" }).click();
expect(calls.deleteReimbursementAttachment).toBe(0);
await attachment.getByRole("button", { name: "删除" }).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(["附件重复上传"]);
});
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);
});
});