627 行
28 KiB
TypeScript
627 行
28 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") {
|
|
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);
|
|
});
|
|
|
|
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);
|
|
|
|
const contractRow = page
|
|
.getByTestId("contracts-registry-table")
|
|
.getByRole("row")
|
|
.filter({ hasText: CONTRACT_ITEM.contractNo });
|
|
await expect(contractRow).toBeVisible();
|
|
await contractRow.getByRole("button", { name: "更多", exact: true }).click();
|
|
await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible();
|
|
await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0);
|
|
await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0);
|
|
await expect(page).toHaveURL(/\/contracts-registry$/);
|
|
expectNoPageErrors(pageErrors);
|
|
});
|
|
|
|
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);
|
|
});
|