区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。 正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。 本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。 本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。 限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
1521 行
74 KiB
TypeScript
1521 行
74 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;
|
|
receiptOcrState?: "ocr_failed" | "queued" | "system_failed" | "key_failed" | "snapshot_failed";
|
|
receiptManualReview?: boolean;
|
|
receiptReady?: 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 receiptOcrState = options.receiptOcrState;
|
|
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,
|
|
retryReceiptOcr: 0,
|
|
manualReceiptSaves: [] as Array<Record<string, unknown>>,
|
|
manualReceiptReviews: [] as Array<Record<string, unknown>>,
|
|
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-readiness` && request.method() === "GET") {
|
|
await fulfillJson(route, { item: { ready: options.receiptReady ?? true } });
|
|
return;
|
|
}
|
|
const verificationPath = `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/manual-verification`;
|
|
if (path === verificationPath && request.method() === "GET") {
|
|
const facts = { pageNumber: 1, amount: "88.00", currency: "CNY", payerAccountMasked: "0000****2222",
|
|
payeeAccountMasked: "0000****4444", reference: "SYN-MANUAL-UI", transactionStatus: "success",
|
|
document: { version: 1, kind: "bank_receipt", source: "pdf_text", direction: "debit", explicitSuccess: true, issues: [] } };
|
|
await fulfillJson(route, { item: { version: 3, state: options.receiptManualReview ? "pending_review" : "needs_review",
|
|
canReview: Boolean(options.receiptManualReview), documentInspectionRequired: false, pagesExpected: 1, pages: [facts],
|
|
proposal: options.receiptManualReview ? { id: "synthetic-proposal", status: "pending_review",
|
|
proposedBy: "another-finance", reviewedBy: null, reason: "合成核验说明",
|
|
changedFields: [{ pageNumber: 1, fields: ["amount"] }],
|
|
pages: [{ ...facts, itemId: "payment-batch-item-action-confirm" }] } : null } });
|
|
return;
|
|
}
|
|
if (path === verificationPath && request.method() === "POST") {
|
|
calls.manualReceiptSaves.push(request.postDataJSON());
|
|
await fulfillJson(route, { item: { id: "synthetic-proposal", version: 4, status: "pending_review" } });
|
|
return;
|
|
}
|
|
if (path === `${verificationPath}/synthetic-proposal/review` && request.method() === "POST") {
|
|
calls.manualReceiptReviews.push(request.postDataJSON());
|
|
await fulfillJson(route, { item: { id: "synthetic-proposal", version: 3, status: "approved" } });
|
|
return;
|
|
}
|
|
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/ocr-retry` && request.method() === "POST") {
|
|
expect(request.postDataJSON()).toEqual({ expectedVersion: 3 });
|
|
calls.retryReceiptOcr += 1;
|
|
receiptOcrState = "queued";
|
|
await fulfillJson(route, { item: { id: paymentReceiptId, ocrState: "queued", ocrVersion: 4 } });
|
|
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",
|
|
ocrState: receiptOcrState,
|
|
ocrVersion: receiptOcrState === "queued" ? 4 : 3,
|
|
ocrRetryAvailable: receiptOcrState === "ocr_failed",
|
|
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.getByRole("link", { name: "创建批次", exact: true })).toHaveCount(0);
|
|
const link = page.getByRole("link", { name: "查看所在付款批次", exact: true });
|
|
await expect(link).toHaveCount(1);
|
|
const linkBounds = await link.boundingBox();
|
|
expect(Math.round(linkBounds!.height * 100) / 100).toBeGreaterThanOrEqual(44);
|
|
if (!mobile) expect(linkBounds!.width).toBeLessThan(180);
|
|
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);
|
|
});
|
|
}
|
|
|
|
test("账号查看图标紧随脱敏值且确认前不请求完整账号", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
await mockSystemActionApis(page, {
|
|
canExportPaymentOrder: true,
|
|
meOverride: { roleCodes: ["finance"], permissions: [...me.permissions, "employee.bank.read"] },
|
|
});
|
|
let reveals = 0;
|
|
page.on("request", (request) => { if (new URL(request.url()).pathname.endsWith("/payee-bank/reveal")) reveals += 1; });
|
|
await login(page, user);
|
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
|
const reveal = page.getByRole("button", { name: "查看完整账号", exact: true });
|
|
await expect(reveal).toBeVisible();
|
|
await expect(reveal).toHaveText("");
|
|
await expect(reveal).toHaveAttribute("title", "查看完整账号");
|
|
const icon = await reveal.boundingBox();
|
|
const account = await reveal.locator("..").locator("span").boundingBox();
|
|
expect(icon!.width).toBeGreaterThanOrEqual(44);
|
|
expect(icon!.height).toBeGreaterThanOrEqual(44);
|
|
expect(icon!.x).toBeGreaterThan(account!.x);
|
|
await reveal.focus();
|
|
await page.keyboard.press("Enter");
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toContainText("本次查看会记录");
|
|
await expect(dialog.getByRole("textbox", { name: "查看收款账号原因" })).toBeVisible();
|
|
expect(reveals).toBe(0);
|
|
await dialog.getByRole("button", { name: "返回", exact: true }).click();
|
|
expect(reveals).toBe(0);
|
|
});
|
|
|
|
for (const width of [390, 1280, 1440]) {
|
|
test(`票据紧凑展示${width}多张长名称及预览不丢失`, async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width, height: 960 });
|
|
await mockSystemActionApis(page, { reimbursementStatus: "approved" });
|
|
const detail = reimbursementDetail("approved");
|
|
const sellerName = "合成开票方长名称".repeat(8);
|
|
detail.invoices = Array.from({ length: 5 }, (_, index) => ({
|
|
...detail.invoices[0],
|
|
id: `invoice-layout-${index}`,
|
|
invoiceNumber: `SYNTHETIC-LAYOUT-000000000000000000000${index}`,
|
|
sellerName,
|
|
}));
|
|
detail.attachments = [];
|
|
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, detail));
|
|
let previews = 0;
|
|
await page.route("**/api/invoice-pool/invoice-layout-0/signed-url", async (route) => {
|
|
previews += 1;
|
|
await fulfillJson(route, { signedUrl: "about:blank", originalName: "合成预览.pdf", fileSize: 1024 });
|
|
});
|
|
await login(page, user);
|
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
|
const table = page.getByTestId("reimbursement-invoices-table");
|
|
await expect(table).toBeVisible();
|
|
await expect(table.getByTestId("approval-invoice-evidence")).toHaveCount(5);
|
|
await expect(table.getByText(sellerName, { exact: true })).toHaveCount(5);
|
|
if (width >= 1024) await expect(table.getByRole("columnheader", { name: "金额" })).toBeVisible();
|
|
else await expect(table.locator("thead")).toBeHidden();
|
|
await expect(page.getByText("未上传补充附件", { exact: true })).toBeVisible();
|
|
await table.getByRole("button", { name: `查看发票:${detail.invoices[0].invoiceNumber}`, exact: true }).click();
|
|
await expect.poll(() => previews).toBe(1);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
|
|
await table.screenshot({ path: testInfo.outputPath("invoice-table.png") });
|
|
});
|
|
}
|
|
|
|
for (const width of [1280, 1440, 1920]) {
|
|
test(`桌面${width}付款与报销侧栏等宽且更多紧随主按钮`, async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width, height: 960 });
|
|
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") });
|
|
await login(page, user);
|
|
const widths: number[] = [];
|
|
const boundaries: { left: number; right: number }[] = [];
|
|
for (const [url, panelId] of [
|
|
[`/reimbursements/${reimbursementId}`, "reimbursement-action-panel"],
|
|
[`/payment-batches?id=${paymentBatchId}`, "payment-batch-action-panel"],
|
|
]) {
|
|
await page.goto(url);
|
|
const panel = page.getByTestId(panelId);
|
|
await expect(panel).toBeVisible();
|
|
await page.evaluate(() => document.fonts.ready);
|
|
const grid = page.locator(".detail-workspace-grid--payment");
|
|
const gridBounds = (await grid.boundingBox())!;
|
|
boundaries.push({ left: gridBounds.x, right: gridBounds.x + gridBounds.width });
|
|
if (panelId === "reimbursement-action-panel") {
|
|
const heroBounds = (await page.getByTestId("reimbursement-desktop-workspace").locator(".workbench-hero").boundingBox())!;
|
|
expect(Math.abs(gridBounds.x - heroBounds.x)).toBeLessThan(2);
|
|
expect(Math.abs(gridBounds.x + gridBounds.width - heroBounds.x - heroBounds.width)).toBeLessThan(2);
|
|
}
|
|
widths.push((await panel.locator("../..").boundingBox())!.width);
|
|
const row = panel.getByTestId("detail-inline-actions");
|
|
const measuredActions = row.locator("a, button").first()
|
|
.or(row.getByRole("button", { name: "更多", exact: true }));
|
|
await expect(measuredActions).toHaveCount(2);
|
|
// Both positions must come from the same animation frame.
|
|
const [primary, more] = await measuredActions.evaluateAll((elements) => elements.map((element) => {
|
|
const { x, y, width, height } = element.getBoundingClientRect();
|
|
return { x, y, width, height };
|
|
}));
|
|
expect(Math.abs(primary!.y + primary!.height / 2 - more!.y - more!.height / 2)).toBeLessThan(2);
|
|
expect(more!.x).toBeGreaterThan(primary!.x + primary!.width);
|
|
await row.getByRole("button", { name: "更多", exact: true }).click();
|
|
await expect(page.getByRole("menu")).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
|
|
await page.screenshot({ path: testInfo.outputPath(`${panelId}.png`), fullPage: true });
|
|
}
|
|
expect(widths).toEqual([320, 320]);
|
|
expect(Math.abs(boundaries[0].left - boundaries[1].left)).toBeLessThan(2);
|
|
expect(Math.abs(boundaries[0].right - boundaries[1].right)).toBeLessThan(2);
|
|
});
|
|
|
|
test(`桌面${width}创建批次按钮短文案按内容宽度且只导航不组批`, async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width, height: 960 });
|
|
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("none") });
|
|
let batchWrites = 0;
|
|
page.on("request", (request) => {
|
|
if (new URL(request.url()).pathname === "/api/payment-batches" && request.method() === "POST") batchWrites += 1;
|
|
});
|
|
await login(page, user);
|
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
|
const button = page.getByRole("link", { name: "创建批次", exact: true });
|
|
await expect(button).toBeVisible();
|
|
const actions = page.getByTestId("reimbursement-action-panel");
|
|
const buttonBounds = await button.boundingBox();
|
|
const actionBounds = await actions.boundingBox();
|
|
expect(buttonBounds!.width).toBeGreaterThanOrEqual(44);
|
|
expect(buttonBounds!.width).toBeLessThan(160);
|
|
expect(buttonBounds!.width).toBeLessThan(actionBounds!.width * 0.7);
|
|
expect(buttonBounds!.height).toBeGreaterThanOrEqual(44);
|
|
expect(Math.abs(buttonBounds!.x - actionBounds!.x)).toBeLessThan(2);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
|
|
await actions.locator("..").screenshot({ path: testInfo.outputPath("compact-payment-action.png") });
|
|
await button.click();
|
|
await expect(page).toHaveURL(/\/payment-batches\?view=pending&q=/);
|
|
expect(batchWrites).toBe(0);
|
|
});
|
|
}
|
|
|
|
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);
|
|
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).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(page.getByRole("menuitem", { 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 page.getByRole("menuitem", { 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 actions.boundingBox();
|
|
const progressBounds = await progress.boundingBox();
|
|
expect(actionBounds).not.toBeNull();
|
|
expect(progressBounds).not.toBeNull();
|
|
if (mobile) {
|
|
await expect(sideActions).toBeHidden();
|
|
expect(await actions.evaluate((element) => getComputedStyle(element).position)).toBe("fixed");
|
|
expect(actionBounds!.height).toBeGreaterThanOrEqual(44);
|
|
} else {
|
|
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 page.getByRole("menuitem", { name: "重新下载制单" }).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.getByTestId("approval-attachments").locator(":scope > div").filter({
|
|
has: page.getByRole("button", { name: "查看补充附件:合成报销敏感附件.pdf", exact: true }),
|
|
});
|
|
await expect(attachment).toBeVisible();
|
|
await attachment.getByRole("button", { name: "删除附件", exact: true }).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("textbox", { name: "删除理由" }).fill("一");
|
|
await page.getByTestId("attachment-delete-confirm-submit").click();
|
|
await expect(dialog).toContainText("请填写至少 2 个字的删除理由");
|
|
expect(calls.deleteReimbursementAttachment).toBe(0);
|
|
await dialog.getByRole("button", { name: "取消" }).click();
|
|
await expect(dialog).not.toBeVisible();
|
|
expect(calls.deleteReimbursementAttachment).toBe(0);
|
|
|
|
await attachment.getByRole("button", { name: "删除附件", exact: true }).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(["附件重复上传"]);
|
|
await expect(dialog).not.toBeVisible();
|
|
});
|
|
|
|
for (const width of [1440, 390]) {
|
|
for (const state of ["system_failed", "key_failed", "snapshot_failed"] as const) {
|
|
test(`回单系统失败引导管理员且不重传 ${width} ${state}`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const calls = await mockSystemActionApis(page, { receiptOcrState: state, receiptReady: false });
|
|
await login(page, user);
|
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
|
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
|
|
await expect(receipt).toContainText("请联系管理员");
|
|
await expect(page.getByRole("status").filter({ hasText: "回单尚未满足付款登记条件" })).toContainText("存在系统核验故障");
|
|
await expect(page.getByText("请查看核验详情", { exact: false })).toHaveCount(0);
|
|
await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0);
|
|
await expect(receipt.getByRole("button", { name: "核验详情", exact: true })).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0);
|
|
expect(calls.retryReceiptOcr).toBe(0);
|
|
expect(calls.executePaymentBatch).toBe(0);
|
|
});
|
|
}
|
|
test(`回单未核验不能登记付款 ${width}`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptReady: false });
|
|
await login(page, user);
|
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
|
await expect(page.getByText("回单尚未满足付款登记条件", { exact: false })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0);
|
|
expect(calls.executePaymentBatch).toBe(0);
|
|
});
|
|
for (const correction of [false, true]) {
|
|
test(`回单人工核验只提交关联或更正且不付款 ${width} ${correction}`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" });
|
|
await login(page, user);
|
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
|
await page.getByRole("button", { name: "核验详情", exact: true }).click();
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toContainText("0000****2222");
|
|
await dialog.getByRole("combobox", { name: "第1页对应付款明细" }).selectOption("payment-batch-item-action-confirm");
|
|
if (correction) {
|
|
await dialog.getByRole("button", { name: "更正识别", exact: true }).click();
|
|
await dialog.getByRole("textbox", { name: "第1页更正小写金额", exact: true }).fill("88.00");
|
|
await expect(dialog.getByRole("button", { name: "保存核验", exact: true })).toBeDisabled();
|
|
await dialog.getByRole("textbox", { name: "更正说明(仅关联可留空)", exact: true }).fill("核对原件更正金额");
|
|
}
|
|
await expect(dialog).toBeInViewport();
|
|
await dialog.getByRole("button", { name: "保存核验", exact: true }).click();
|
|
await expect.poll(() => calls.manualReceiptSaves.length).toBe(1);
|
|
expect(calls.manualReceiptSaves[0]).toEqual({
|
|
expectedVersion: 3, ...(correction ? { reason: "核对原件更正金额" } : {}),
|
|
pages: [{ pageNumber: 1, itemId: "payment-batch-item-action-confirm",
|
|
...(correction ? { fields: [{ Name: "小写金额", Value: "88.00" }] } : {}) }],
|
|
});
|
|
expect(calls.executePaymentBatch).toBe(0);
|
|
expect(calls.uploadPaymentReceipt).toBe(0);
|
|
await expect(dialog).not.toBeVisible();
|
|
});
|
|
}
|
|
test(`回单独立复核查看更正且不触发付款 ${width}`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptManualReview: true,
|
|
meOverride: { id: "independent-receipt-reviewer" } });
|
|
await login(page, user);
|
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
|
await page.getByRole("button", { name: "核验详情", exact: true }).click();
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toContainText("人工核验结果");
|
|
await expect(dialog).toContainText("更正字段:金额");
|
|
await expect(dialog.getByRole("button", { name: "确认复核", exact: true })).toBeDisabled();
|
|
await dialog.getByRole("textbox", { name: "复核说明", exact: true }).fill("已核对合成原回单");
|
|
await dialog.getByRole("button", { name: "确认复核", exact: true }).click();
|
|
await expect.poll(() => calls.manualReceiptReviews.length).toBe(1);
|
|
expect(calls.manualReceiptReviews[0]).toEqual({ expectedVersion: 3, decision: "approve", reason: "已核对合成原回单" });
|
|
expect(calls.executePaymentBatch).toBe(0);
|
|
expect(calls.manualReceiptSaves).toHaveLength(0);
|
|
});
|
|
test(`回单OCR失败可重试且不触发付款登记 ${width}`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" });
|
|
await login(page, user);
|
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
|
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
|
|
await expect(receipt).toContainText("识别未完成,可重试");
|
|
await receipt.getByRole("button", { name: "重新识别", exact: true }).click();
|
|
await expect(receipt).toContainText("等待识别");
|
|
await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0);
|
|
expect(calls.retryReceiptOcr).toBe(1);
|
|
expect(calls.executePaymentBatch).toBe(0);
|
|
expect(calls.uploadPaymentReceipt).toBe(0);
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|