文件
hyb-oa/client/tests/e2e/payment-reviewer-permissions.spec.ts
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

769 行
36 KiB
TypeScript

import { expect, test, type Page, type Route } from "@playwright/test";
test.use({ serviceWorkers: "block" });
type SyntheticActor = {
id: string;
employeeNo: string;
name: string;
email: string;
roleCodes: string[];
};
type SyntheticContract = {
id: string;
contractNo: string;
projectId: string | null;
contractType: "purchase" | "sales" | "service" | "framework" | "other";
counterpartyName: string;
summary: string;
amount: string | null;
currency: string;
signedDate: string | null;
effectiveDate: string | null;
expiryDate: string | null;
confidentialityLevel: "public" | "internal" | "confidential" | "restricted";
archiveLocation: string;
offlineArchiveNo: string | null;
ownerEmployeeId: string;
status: "draft" | "active" | "expired" | "archived";
persistedStatus: "draft" | "active" | "archived";
createdAt: string | null;
updatedAt: string | null;
owner: { id: string; employeeNo: string; name: string } | null;
project: { id: string; projectNo: string; name: string } | null;
};
type MockScenario = {
readiness?: { ready: boolean; blockers: Array<{ reasonCode: string }> };
createFailure?: string;
paymentCandidates?: Array<{
entityId: string;
entityType: "reimbursement";
businessNo: string;
employee: { id: string; employeeNo: string; name: string };
amount: string;
currencyCode: "CNY";
businessDate: string | null;
summary: string | null;
readiness?: { ready: boolean; blockers: Array<{ reasonCode: string }> };
}>;
paymentCandidateTotal?: number;
onCandidateQuery?: (query: URLSearchParams) => void;
contracts?: SyntheticContract[];
onCreatePaymentBatch?: (payload: unknown) => void;
};
const REVIEWER_ACTOR: SyntheticActor = {
id: "employee-payment-reviewer",
employeeNo: "SYN-REVIEWER",
name: "合成付款复核人",
email: "[email protected]",
roleCodes: ["manager"],
};
const FINANCE_ACTOR: SyntheticActor = {
id: "employee-payment-finance",
employeeNo: "SYN-FINANCE",
name: "合成财务人员",
email: "[email protected]",
roleCodes: ["finance"],
};
const CONTRACT_READER_ACTOR: SyntheticActor = {
id: "employee-contract-reader",
employeeNo: "SYN-CONTRACT-READER",
name: "合成合同只读人员",
email: "[email protected]",
roleCodes: ["employee"],
};
const NO_INVOICE_UPLOAD_ACTOR: SyntheticActor = {
id: "employee-no-invoice-upload",
employeeNo: "SYN-NO-INVOICE-UPLOAD",
name: "合成无票据上传人员",
email: "[email protected]",
roleCodes: ["hr"],
};
const PAYMENT_CANDIDATE_ENTITY_ID = "11111111-1111-4111-8111-111111111111";
const CREATED_PAYMENT_BATCH_ID = "22222222-2222-4222-8222-222222222222";
const PAYMENT_CANDIDATE = {
entityId: PAYMENT_CANDIDATE_ENTITY_ID,
entityType: "reimbursement" as const,
businessNo: "BX-SYN-20260718-01",
employee: {
id: "employee-payment-candidate",
employeeNo: "SYN-PAYEE",
name: "合成付款员工",
},
amount: "1280.50",
currencyCode: "CNY" as const,
businessDate: "2026-07-18",
summary: "合成已审批报销",
};
const CONTRACT_ITEM: SyntheticContract = {
id: "contract-synthetic-readonly",
contractNo: "HT-SYN-READ-001",
projectId: "project-synthetic-contract",
contractType: "service",
counterpartyName: "合成合作方",
summary: "合成只读合同",
amount: "8000.00",
currency: "CNY",
signedDate: "2026-07-01",
effectiveDate: "2026-07-01",
expiryDate: "2027-06-30",
confidentialityLevel: "internal",
archiveLocation: "受控线下归档",
offlineArchiveNo: "SYN-ARCHIVE-001",
ownerEmployeeId: CONTRACT_READER_ACTOR.id,
status: "active",
persistedStatus: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
owner: {
id: CONTRACT_READER_ACTOR.id,
employeeNo: CONTRACT_READER_ACTOR.employeeNo,
name: CONTRACT_READER_ACTOR.name,
},
project: {
id: "project-synthetic-contract",
projectNo: "XM-SYN-001",
name: "合成合同项目",
},
};
function envelope(data: unknown) {
return { requestId: "mock-payment-reviewer-permissions", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function capturePageErrors(page: Page) {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
return errors;
}
function expectNoPageErrors(errors: string[]) {
expect(errors, "页面不应产生未捕获异常").toEqual([]);
}
async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const requestUrl = new URL(request.url());
const pathName = requestUrl.pathname;
if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: false },
emailLoginCode: { enabled: false },
passwordReset: { enabled: false },
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: false,
entryEnabled: false,
oauthEnabled: false,
corpId: "",
agentId: "",
loginUrl: null,
bindEnabled: false,
defaultReturnTo: "/portal",
status: "disabled",
statusReasons: [],
});
return;
}
if (pathName === "/api/auth/login" || pathName === "/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-token-${actor.employeeNo}`,
expiresIn: 900,
mustChangePassword: false,
})),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, {
...actor,
phone: null,
departmentId: `department-${actor.employeeNo}`,
directManagerId: null,
status: "active",
notificationPushEnabled: true,
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 40,
},
wecomBinding: null,
});
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: `workspace-${actor.employeeNo}`,
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: { main: [], admin: [], backup: [] },
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 0,
unreadOnly: false,
unreadCount: 0,
items: [],
});
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: `workspace-${actor.employeeNo}`, persisted: true, profile: "small_team_internal" },
greeting: { name: actor.name },
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
myApplications: [],
riskSummary: null,
announcements: [],
quickLinks: [],
recentActivities: [],
teamStatusBoard: [],
});
return;
}
if (pathName === "/api/payment-batches/candidates") {
const items = (scenario.paymentCandidates ?? []).map((item) => ({ ...item, readiness: item.readiness ?? { ready: true, blockers: [] } }));
scenario.onCandidateQuery?.(requestUrl.searchParams);
await fulfillJson(route, {
items,
page: Number(requestUrl.searchParams.get("page") ?? "1"),
pageSize: Number(requestUrl.searchParams.get("pageSize") ?? "20"),
total: scenario.paymentCandidateTotal ?? items.length,
});
return;
}
if (pathName === "/api/payment-batches/readiness") {
await fulfillJson(route, scenario.readiness ?? { ready: true, blockers: [] });
return;
}
if (pathName === "/api/payment-batches" && request.method() === "POST") {
scenario.onCreatePaymentBatch?.(request.postDataJSON());
if (scenario.createFailure) {
await route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ requestId: "synthetic-readiness-race", error: { code: "VALID_001", message: "internal payload", details: { reasonCode: scenario.createFailure, private: "/private/hidden" } } }) });
return;
}
await fulfillJson(route, { item: { id: CREATED_PAYMENT_BATCH_ID } });
return;
}
if (pathName === "/api/payment-batches") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/risk-flags") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/ap-operations") {
await fulfillJson(route, {
summary: {
pendingPaymentCount: 0,
partialMatchCount: 0,
exceptionCount: 0,
budgetOverspendCount: 0,
},
pendingPaymentBatches: [],
failedPaymentBatchItems: [],
exceptionRiskFlags: [],
budgetOverspendRisks: [],
partialMatchItems: [],
});
return;
}
if (pathName === "/api/ap-operations/reimbursement-ledger") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry/expiring") {
await fulfillJson(route, { days: 30, total: 0, items: [] });
return;
}
if (pathName === `/api/contracts-registry/${CONTRACT_ITEM.id}` && request.method() === "GET") {
await fulfillJson(route, { item: { ...CONTRACT_ITEM, terminatedAt: null, terminatedReason: null,
terminatedBy: null, terminator: null, documentRefs: [], history: [] } });
return;
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: scenario.contracts ?? [] });
return;
}
if (pathName === "/api/projects") {
await fulfillJson(route, { items: [] });
return;
}
await fulfillJson(route, { message: "unhandled synthetic endpoint" }, 404);
});
}
async function login(page: Page, actor: SyntheticActor) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill(actor.email);
await page.getByTestId("login-password").fill("SyntheticPassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/(?:portal|mobile)$/);
}
for (const role of ["manager", "ceo"]) {
test(`只有付款复核职责的 ${role} 不能直连应付账款且不请求台账`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, { ...REVIEWER_ACTOR, roleCodes: [role] });
await login(page, REVIEWER_ACTOR);
let apRequests = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/ap-operations")) apRequests += 1;
});
await page.goto("/ap-operations");
await expect(page).toHaveURL(/\/403$/);
await expect(page.getByRole("link", { name: "应付账款", exact: true })).toHaveCount(0);
expect(apRequests).toBe(0);
await expect(page.getByTestId("ap-export")).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
}
for (const roleCodes of [["finance"], ["finance_mgr"], ["manager", "finance"], ["ceo", "finance"]]) {
test(`AP 专用读取权限允许 ${roleCodes.join("+")} 进入台账且不提升导出权限`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const actor = { ...FINANCE_ACTOR, roleCodes };
await mockSyntheticApis(page, actor);
await login(page, actor);
await page.goto("/ap-operations");
await expect(page).toHaveURL(/\/ap-operations$/);
await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible();
await page.getByRole("tab", { name: /^财务台账/ }).click();
await expect(page.getByTestId("finance-ledger-table")).toBeVisible();
await expect(page.getByTestId("ap-export")).toBeVisible();
});
}
test("只有付款复核职责的经理看不到新建批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
let candidateQueryCount = 0;
await mockSyntheticApis(page, REVIEWER_ACTOR, {
onCandidateQuery: () => { candidateQueryCount += 1; },
});
await login(page, REVIEWER_ACTOR);
await page.goto("/payment-batches?view=pending");
await expect(page).toHaveURL(/\/payment-batches\?view=batches$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /待组批/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /付款批次/ })).toBeVisible();
expect(candidateQueryCount).toBe(0);
expectNoPageErrors(pageErrors);
});
async function expectNoContractWriteActions(page: Page) {
for (const name of ["新增合同", "编辑", "续约", "归档"]) {
const controls = page.getByRole("button", { name, exact: true })
.or(page.getByRole("link", { name, exact: true }))
.or(page.getByRole("menuitem", { name, exact: true }));
expect(await controls.count(), `readonly contract leaked action: ${name}`).toBe(0);
}
}
test("合同只读动作断言能发现新增编辑续约归档泄露", async ({ page }) => {
for (const name of ["新增合同", "编辑", "续约", "归档"]) {
await page.setContent(`<main><button>${name}</button></main>`);
await expect(expectNoContractWriteActions(page)).rejects.toThrow(/readonly contract leaked action/);
}
});
test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] });
await login(page, CONTRACT_READER_ACTOR);
await page.goto("/contracts-registry");
await expect(page).toHaveURL(/\/contracts-registry$/);
await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
await expectNoContractWriteActions(page);
const contractRow = page
.getByTestId("contracts-registry-table")
.getByRole("row")
.filter({ hasText: CONTRACT_ITEM.contractNo });
await expect(contractRow).toBeVisible();
await contractRow.getByRole("link", { name: "查看详情", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/contracts-registry/${CONTRACT_ITEM.id}$`));
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
await expectNoContractWriteActions(page);
await expect(page.getByRole("link", { name: "返回合同列表", exact: true })).toBeVisible();
expectNoPageErrors(pageErrors);
});
test("合同管理身份在同一生效合同上可见新增编辑续约归档", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
const actor = { ...CONTRACT_READER_ACTOR, roleCodes: ["admin"] };
await mockSyntheticApis(page, actor, { contracts: [CONTRACT_ITEM] });
await login(page, actor);
await page.goto("/contracts-registry");
await expect(page.getByRole("link", { name: "新增合同", exact: true })).toBeVisible();
const row = page.getByTestId("contracts-registry-table").getByRole("row").filter({ hasText: CONTRACT_ITEM.contractNo });
await row.getByRole("link", { name: "查看详情", exact: true }).click();
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
for (const name of ["编辑", "续约", "归档"]) {
await expect(page.getByRole("button", { name, exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name, exact: true }).first()).toBeEnabled();
}
expectNoPageErrors(pageErrors);
});
for (const action of ["新增合同", "编辑", "续约", "归档"]) {
test(`合同真实只读页面反例能拦截泄露动作:${action}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] });
await login(page, CONTRACT_READER_ACTOR);
await page.goto("/contracts-registry");
await expect(page.getByTestId("contracts-registry-table")).toContainText(CONTRACT_ITEM.contractNo);
if (action !== "新增合同") {
const row = page.getByTestId("contracts-registry-table").getByRole("row").filter({ hasText: CONTRACT_ITEM.contractNo });
await row.getByRole("link", { name: "查看详情", exact: true }).click();
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
}
await expectNoContractWriteActions(page);
await page.evaluate(name => {
const control = document.createElement(name === "新增合同" ? "a" : "button");
if (control instanceof HTMLAnchorElement) control.href = "/contracts-registry/new";
control.textContent = name;
control.dataset.qaCounterexample = "contract-action";
document.querySelector("main")!.append(control);
}, action);
await expect(expectNoContractWriteActions(page)).rejects.toThrow(`readonly contract leaked action: ${action}`);
await page.locator('[data-qa-counterexample="contract-action"]').evaluate(node => node.remove());
await expectNoContractWriteActions(page);
});
}
test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, NO_INVOICE_UPLOAD_ACTOR);
await login(page, NO_INVOICE_UPLOAD_ACTOR);
await page.goto("/invoice-pool");
await expect(page).toHaveURL(/\/403$/);
await expect(page.getByRole("heading", { level: 1, name: "无权限访问", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "上传票据", exact: true })).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
for (const [reasonCode, message] of [
["payment_batch.legal_entity_required", "报销人尚未维护所属公司,请联系管理员补充后再安排付款。"],
["payment_batch.payee_bank_required", "收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。"],
["payment_batch.payee_account_required", "收款人尚未维护银行卡,请联系管理员补充真实收款资料。"],
["payment_batch.payee_bank_unsupported", "当前银行付款仅支持建行收款账户,请停止本次银行付款并联系财务确认处理方式,不要修改真实开户行。"],
["payment_batch.active_batch_exists", "所选单据已进入其他付款安排,请刷新列表并查看已有批次。"],
["payment_batch.reviewer_required", "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。"],
]) {
test(`付款候选就近解释并禁用 ${reasonCode}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode }] } }] });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const row = page.getByTestId("payment-batch-candidate-row");
await expect(row.getByText(message, { exact: true })).toBeVisible();
await expect(row.getByRole("checkbox")).toBeDisabled();
await expect(row.getByRole("checkbox")).toHaveAccessibleDescription(message);
await expect(row.getByRole("link")).toHaveCount(0);
});
}
test("跨公司组合原因紧邻禁用主动作并可被读屏读取", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
let creates = 0;
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.multiple_legal_entities" }] }, onCreatePaymentBatch: () => { creates += 1; } });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const checkbox = page.getByTestId("payment-batch-candidate-row").getByRole("checkbox");
await checkbox.focus();
await page.keyboard.press("Space");
await expect(checkbox).toBeChecked();
const button = page.getByRole("button", { name: "创建并提交复核", exact: true });
await expect(button).toBeDisabled();
await expect(button).toHaveAccessibleDescription("所选单据属于不同公司,请按公司分别创建付款安排。");
await expect(page.locator("#payment-creation-reason")).toHaveAttribute("role", "alert");
expect(creates).toBe(0);
});
test("可先换渠道再选择,已选项失效后仍可取消", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
const scenario: MockScenario = { paymentCandidates: [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] } }] };
await mockSyntheticApis(page, FINANCE_ACTOR, scenario);
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const checkbox = page.getByTestId("payment-batch-candidate-row").getByRole("checkbox");
await expect(checkbox).toBeDisabled();
const channel = page.getByTestId("payment-batch-default-channel");
await expect(channel).toBeVisible();
scenario.paymentCandidates = [{ ...PAYMENT_CANDIDATE, readiness: { ready: true, blockers: [] } }];
await channel.selectOption("cash");
await expect(checkbox).toBeEnabled();
await checkbox.check();
scenario.paymentCandidates = [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] } }];
scenario.readiness = { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] };
await channel.selectOption("bank");
await expect(page.getByTestId("payment-batch-candidate-row")).toContainText("收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。");
await expect(checkbox).toBeChecked();
await expect(checkbox).toBeEnabled();
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
await expect(page.getByRole("button", { name: "创建并提交复核", exact: true })).toHaveCount(0);
});
for (const [reasonCode, message] of [
["payment_batch.active_batch_exists", "所选单据已进入其他付款安排,请刷新列表并查看已有批次。"],
["unknown.synthetic", "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。 排障编号:synthetic-readiness-race"],
]) {
test(`创建并发失败留在动作旁 ${reasonCode}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], createFailure: reasonCode });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
await page.getByTestId("payment-batch-candidate-row").getByRole("checkbox").check();
if (reasonCode === "unknown.synthetic") await page.setViewportSize({ width: 375, height: 812 });
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page.locator("#payment-creation-reason")).toHaveText(message);
await expect(page.getByRole("button", { name: "创建并提交复核", exact: true })).toHaveAccessibleDescription(message);
await expect(page.getByText("internal payload", { exact: false })).toHaveCount(0);
await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0);
});
}
test("完整银行候选没有解释噪音并可提交复核", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
let created: unknown = null;
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], onCreatePaymentBatch: (payload) => { created = payload; } });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const row = page.getByTestId("payment-batch-candidate-row");
await expect(row.locator('[id^="payment-candidate-reason-"]')).toHaveCount(0);
await row.getByRole("checkbox").check();
const button = page.getByRole("button", { name: "创建并提交复核", exact: true });
await expect(button).toBeEnabled();
await expect(page.locator("#payment-creation-reason")).toHaveCount(0);
await button.click();
await expect(page).toHaveURL(new RegExp(`id=${CREATED_PAYMENT_BATCH_ID}`));
expect(created).toEqual({ entityType: "reimbursement", entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], defaultPaymentChannel: "bank" });
});
test("付款失败摘要与历史风险不向 PC 或移动视口暴露技术文案", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
await mockSyntheticApis(page, FINANCE_ACTOR);
const payload: Record<string, unknown> = { batchId: CREATED_PAYMENT_BATCH_ID, itemId: "failed-item", reasonCode: "payment_batch.source_status_invalid" };
const detail = {
id: CREATED_PAYMENT_BATCH_ID, batchNo: "PB-SYN-FAIL", name: "合成失败批次", status: "failed",
entityType: "reimbursement", totalAmount: "1280.50", highRisk: false,
defaultPaymentDate: null, defaultPaymentChannel: "cash", createdBy: FINANCE_ACTOR.id,
paymentAccount: null, paymentOrderExportCount: 0, canExportPaymentOrder: false,
paymentOrderExportBlockers: [], processingProgress: { stages: [] },
reviewRequirement: { completedReviewCount: 1, requiredReviewCount: 1, nextStep: "ready_for_execute" },
items: [{
id: "failed-item", entityId: PAYMENT_CANDIDATE_ENTITY_ID, entityCode: PAYMENT_CANDIDATE.businessNo,
entityTitle: "合成失败明细", amount: "1280.50", currencyCode: "CNY", status: "failed",
paymentChannelOverride: null, paymentDateOverride: null, paymentVoucherNoOverride: null,
failureCode: "VALID_001", failureMessage: "参数校验失败 /private/hidden", expenseItems: [], expenseItemsTotal: "0.00",
openRiskFlags: [{ id: "failure-risk", riskCode: "payment_batch_execute_failed", title: "付款登记失败", level: "high", detail: "参数校验失败 /private/hidden", source: "payment_batch", status: "open", payloadJson: payload }],
}],
};
await page.route(`**/api/payment-batches/${CREATED_PAYMENT_BATCH_ID}`, (route) => fulfillJson(route, { item: detail }));
await page.route(`**/api/payment-batches/${CREATED_PAYMENT_BATCH_ID}/receipt-attachments`, (route) => fulfillJson(route, { items: [] }));
await login(page, FINANCE_ACTOR);
await page.goto(`/payment-batches?id=${CREATED_PAYMENT_BATCH_ID}`);
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("失败原因:来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。");
await expect(page.getByText("参数校验失败", { exact: false })).toHaveCount(0);
await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0);
delete payload.reasonCode;
await page.reload();
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("失败原因:操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。");
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.getByTestId("payment-batch-items-panel")).toBeVisible();
await expect(page.getByText("参数校验失败", { exact: false })).toHaveCount(0);
await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0);
});
test("财务从业务候选选择报销并使用所选实体创建付款批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
let createPayload: unknown = null;
const candidateQueries: string[] = [];
await mockSyntheticApis(page, FINANCE_ACTOR, {
paymentCandidates: [PAYMENT_CANDIDATE],
paymentCandidateTotal: 21,
onCandidateQuery: (query) => candidateQueries.push(query.toString()),
onCreatePaymentBatch: (payload) => {
createPayload = payload;
},
});
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches\?view=pending$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "待组批 21", exact: true })).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("payment-batch-candidates")).toHaveCount(1);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
await page.getByRole("button", { name: "下一页", exact: true }).click();
await expect(page).toHaveURL(/view=pending&page=2/);
await expect.poll(() => candidateQueries.some((query) => query.includes("page=2") && query.includes("pageSize=20"))).toBe(true);
await page.getByRole("button", { name: "上一页", exact: true }).click();
const candidateRow = page.getByTestId("payment-batch-candidate-row");
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo);
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name);
await expect(candidateRow).toContainText("¥1,280.50");
await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0);
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
await page.getByTestId("payment-batch-candidate-search").fill(PAYMENT_CANDIDATE.businessNo);
await expect(page).toHaveURL(new RegExp(`view=pending&q=${PAYMENT_CANDIDATE.businessNo}`));
await page.setViewportSize({ width: 375, height: 812 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
await candidateRow.getByRole("checkbox").check();
await page.getByTestId("payment-batch-default-channel").selectOption("cash");
await expect(page.getByText("按现金提交负责人复核", { exact: false })).toBeVisible();
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?view=batches&id=${CREATED_PAYMENT_BATCH_ID}$`));
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByText("已提交付款复核,等待负责人处理", { exact: true })).toBeVisible();
expect(createPayload).toEqual({
entityType: "reimbursement",
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
defaultPaymentChannel: "cash",
});
expectNoPageErrors(pageErrors);
});
test("编号兼容:付款候选完整搜索、新旧批次和混合报销明细保持编号与实体对应", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成编号,不访问线上");
const codes = ["BX-202609-0002", "BX-202609-0010", "BX-202609-10000", "BX-20260917-0198"];
const candidates = codes.map((businessNo, index) => ({
...PAYMENT_CANDIDATE, entityId: `11111111-1111-4111-8111-${String(index + 1).padStart(12, "0")}`, businessNo,
}));
const queries: string[] = [];
let created: unknown;
await mockSyntheticApis(page, FINANCE_ACTOR, {
paymentCandidates: candidates, onCandidateQuery: query => queries.push(query.get("q") ?? ""),
onCreatePaymentBatch: payload => { created = payload; },
});
const base = {
name: "合成编号兼容批次", status: "draft", entityType: "reimbursement", totalAmount: "5122.00", highRisk: false,
defaultPaymentDate: null, defaultPaymentChannel: "cash", createdBy: FINANCE_ACTOR.id,
paymentAccount: null, paymentOrderExportCount: 0, canExportPaymentOrder: false,
paymentOrderExportBlockers: [], processingProgress: { stages: [] },
reviewRequirement: { completedReviewCount: 0, requiredReviewCount: 1, nextStep: "first_review" },
};
const batches = [
{ ...base, id: "33333333-3333-4333-8333-333333333333", batchNo: "PB-20260917-001", sources: candidates.slice(-1) },
{ ...base, id: CREATED_PAYMENT_BATCH_ID, batchNo: "PB-2026-0001", sources: candidates },
];
await page.route(url => url.pathname === "/api/payment-batches", async route => {
if (route.request().method() !== "GET") return route.fallback();
await fulfillJson(route, { items: batches });
});
for (const batch of batches) {
await page.route(`**/api/payment-batches/${batch.id}`, route => fulfillJson(route, { item: {
...batch, items: batch.sources.map((source, index) => ({
id: `${batch.id}-item-${index}`, entityId: source.entityId, entityCode: source.businessNo,
entityTitle: "合成报销", amount: source.amount, currencyCode: "CNY", status: "pending",
paymentChannelOverride: null, paymentDateOverride: null, paymentVoucherNoOverride: null,
failureCode: null, failureMessage: null, expenseItems: [], expenseItemsTotal: "0.00", openRiskFlags: [],
})),
} }));
await page.route(`**/api/payment-batches/${batch.id}/receipt-attachments`, route => fulfillJson(route, { items: [] }));
}
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const search = page.getByTestId("payment-batch-candidate-search");
for (const source of [candidates[0], candidates[3]]) {
await search.fill(source.businessNo);
await expect.poll(() => queries.at(-1)).toBe(source.businessNo);
await expect(page.getByTestId("payment-batch-candidate-row").filter({ hasText: source.businessNo })).toBeVisible();
}
await search.fill("");
for (const source of candidates) {
await page.getByTestId("payment-batch-candidate-row").filter({ hasText: source.businessNo }).getByRole("checkbox").check();
}
await page.getByTestId("payment-batch-default-channel").selectOption("cash");
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`id=${CREATED_PAYMENT_BATCH_ID}`));
expect(created).toEqual({ entityType: "reimbursement", entityIds: candidates.map(source => source.entityId), defaultPaymentChannel: "cash" });
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 });
for (const batch of batches) {
await page.goto("/payment-batches?view=batches");
const list = page.getByTestId(width === 390 ? "payment-batches-mobile-list" : "payment-batches-table");
for (const item of batches) await expect(list).toContainText(item.batchNo);
await page.goto(`/payment-batches?view=batches&id=${batch.id}`);
await expect(page.getByText(batch.batchNo, { exact: true }).first()).toBeVisible();
const cards = page.getByTestId("payment-batch-items-panel").locator(".detail-evidence-card");
await expect(cards).toHaveCount(batch.sources.length);
// The API owns natural sorting; the page preserves its order and ID links.
for (const [index, source] of batch.sources.entries()) {
await expect(cards.nth(index)).toContainText(source.businessNo);
await expect(cards.nth(index).getByRole("link", { name: "查看报销单" })).toHaveAttribute("href", `/reimbursements/${source.entityId}`);
}
}
}
});