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

1232 行
56 KiB
TypeScript

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";
import type { ProcessingProgress } from "../../src/types/processingProgress";
test.use({ serviceWorkers: "block" });
const loadServerModule = createRequire(import.meta.url);
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 };
}
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",
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" | "approved" | "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: canExportPaymentOrder ? {
accountName: "合成付款公司", bankName: "中国建设银行", bankInstitutionCode: "CCB", accountNumberMasked: "0001********5678",
} : 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;
paymentOrderFile?: Buffer;
paymentOrderError?: boolean;
paymentEntityType?: "reimbursement" | "travel_order";
reimbursementStatus?: "draft" | "reviewing" | "approved" | "paid";
reimbursementProgress?: () => ProcessingProgress;
travelReimbursement?: boolean;
meOverride?: Partial<typeof me>;
} = {},
) {
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,
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),
...(options.reimbursementProgress ? { processingProgress: options.reimbursementProgress() } : {}),
});
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,
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,
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; filename*=UTF-8''${encodeURIComponent("报销制单_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({
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("报销付款交接一致性", () => {
test.skip(process.env.E2E_MODE === "live", "仅合成数据,不访问公网");
function progress(status: SyntheticPaymentBatchStatus | "none" | "unknown", canRead = true, exported = 1): ProcessingProgress {
if (status === "unknown") return { stages: [] };
const { buildPaymentProcessingSemantics } = loadServerModule("../../../server/src/services/paymentProcessingProgress");
const semantics = buildPaymentProcessingSemantics({ status, requiresBankReceipt: true, paymentOrderExportCount: exported });
const stages = Object.values(semantics) as ProcessingProgress["stages"];
for (const stage of stages) Object.assign(stage, { actorName: null, targetHref: null });
if (status === "none") {
Object.assign(stages[0], { state: "current", result: "待组批", detail: "待财务组建付款批次" });
} else if (canRead) {
stages[status === "draft" ? 1 : 2].targetHref = `/payment-batches?id=${paymentBatchId}`;
}
return { stages };
}
for (const mobile of [false, true]) {
test(`${mobile ? "手机" : "桌面"}已复核制单后返回报销不再组批且进度跟随操作`, async ({ page }) => {
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") });
await login(page, user);
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
const actions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
await expect(actions).toContainText("已生成银行制单,等待付款完成并上传回单");
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0);
const link = page.getByRole("link", { name: "查看所在付款批次", exact: true });
await expect(link).toHaveCount(1);
await link.click();
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
await page.goBack();
await expect(actions).toContainText("已生成银行制单");
const timeline = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress");
await expect(timeline).toHaveCount(1);
const panel = actions.locator("..");
expect(await panel.evaluate((element) => getComputedStyle(element).position)).not.toBe("sticky");
const actionBounds = await actions.boundingBox();
const progressBounds = await timeline.boundingBox();
expect(progressBounds!.y).toBeGreaterThan(actionBounds!.y + actionBounds!.height);
if (!mobile) expect(Math.abs(progressBounds!.x - actionBounds!.x)).toBeLessThan(35);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
});
}
for (const [status, expected] of [
["none", "待财务组建付款批次"], ["draft", "待非制单人复核付款"],
["reviewed", "已生成银行制单"], ["failed", "付款登记失败"],
["cancelled", "付款安排已取消"], ["unknown", "付款进度暂不可用"],
] as const) {
test(`${status}阶段不因无批次读取权限而误判待组批`, async ({ page }) => {
await mockSystemActionApis(page, {
reimbursementStatus: "approved", reimbursementProgress: () => progress(status, false),
meOverride: { roleCodes: ["employee"], permissions: ["expense.view_own"] },
});
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-action-panel")).toContainText(expected);
await expect(page.getByRole("link", { name: /付款批次/ })).toHaveCount(0);
});
}
test("重新聚焦后不保留旧的组批动作", async ({ page }) => {
let current: SyntheticPaymentBatchStatus | "none" = "none";
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress(current) });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toBeVisible();
current = "reviewed";
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
});
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("已生成银行制单");
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
await page.reload();
await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1);
});
test("已付款只保留结果入口,进度未知不让财务猜测组批", async ({ page }) => {
await mockSystemActionApis(page, { reimbursementStatus: "paid", reimbursementProgress: () => progress("executed") });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款已登记完成");
await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, { ...reimbursementDetail("approved"), processingProgress: { stages: [] } }));
await page.reload();
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款进度暂不可用");
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
});
});
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.getByTestId("payment-batch-action-downloadOrder")).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);
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"),
});
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.getByTestId("payment-batch-action-downloadOrder")).toBeVisible();
await mobileActions.getByRole("button", { name: "更多" }).click();
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"),
});
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(mobileActions.getByRole("button", { name: "登记付款完成" })).toBeVisible();
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();
const sideActions = page.getByTestId("payment-batch-action-panel");
const progress = page.getByTestId("payment-batch-processing-panel");
await expect(progress).toBeVisible();
expect(await progress.evaluate((element) => Boolean(element.previousElementSibling?.querySelector('[data-testid="payment-batch-action-panel"]')))).toBe(true);
const actionBounds = await sideActions.boundingBox();
const progressBounds = await progress.boundingBox();
expect(actionBounds).not.toBeNull();
expect(progressBounds).not.toBeNull();
expect(progressBounds!.y).toBeGreaterThanOrEqual(actionBounds!.y + actionBounds!.height);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
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: "确认下载" })).toBeEnabled();
await expect(dialog.getByRole("textbox")).toHaveCount(0);
await dialog.getByRole("button", { name: "返回", exact: true }).click();
expect(calls.paymentOrderReasons).toEqual([]);
await actions.getByTestId("payment-batch-action-downloadOrder").click();
await expect(dialog).toContainText("文件包含完整银行账号");
const firstDownload = page.waitForEvent("download");
await dialog.getByRole("button", { name: "确认下载" }).click();
const downloaded = await firstDownload;
expect(downloaded.suggestedFilename()).toBe("报销制单_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 expect(dialog.getByRole("textbox")).toHaveCount(0);
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([undefined, undefined]);
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 expect(dialog.getByRole("textbox")).toHaveCount(0);
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 });
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-items-panel",
"payment-batch-receipt-panel",
"payment-batch-processing-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("static");
});
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);
});
});