文件
hyb-oa/client/tests/e2e/r102-reimbursement-main-flow.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

607 行
24 KiB
TypeScript

import { expect, test, type Page, type Route } from "@playwright/test";
test.use({ serviceWorkers: "block" });
function envelope(data: unknown) {
return { requestId: "mock-r102-reimbursement", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
const me = {
id: "employee-r102",
employeeNo: "E102",
name: "陈工程",
phone: "13800000000",
email: "[email protected]",
departmentId: "dept-r102",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 50,
},
wecomBinding: null,
};
const reimbursementId = "11111111-1111-4111-8111-111111111111";
const itemId = "22222222-2222-4222-8222-222222222222";
const invoiceId = "33333333-3333-4333-8333-333333333333";
const formNumber = "BX-20260506-0001";
function navigationItem(key: string, href: string, label: string, icon: string) {
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
}
function reimbursementListItem() {
return {
id: reimbursementId,
formNumber,
status: "draft",
reimbursementDate: "2026-05-06",
totalAmount: "168.30",
description: "办公室宽带费用",
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-06T09:00:00.000Z",
updatedAt: "2026-05-06T09:10:00.000Z",
};
}
function reimbursementDetail(overrides: { reimbursementDate?: string; description?: string } = {}) {
return {
id: reimbursementId,
formNumber,
submitterId: me.id,
ownerId: me.id,
departmentId: me.departmentId,
department: { id: "dept-r102", name: "研发部" },
status: "draft",
createdAt: "2026-05-06T09:00:00.000Z",
submittedAt: null,
reimbursementDate: overrides.reimbursementDate ?? "2026-05-06",
totalAmount: "168.30",
description: overrides.description ?? "办公室宽带费用",
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
project: 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: itemId,
expenseTypeId: "expense-office-communication",
expenseDate: "2026-05-06",
amount: "168.30",
description: "办公室宽带费用",
expenseType: {
id: "expense-office-communication",
code: "office_communication",
name: "通讯费用",
category: "office",
enabled: true,
},
},
],
invoices: [],
attachments: [],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
};
}
async function mockApis(page: Page) {
let draftExists = true;
let detailReimbursementDate = "2026-05-06";
let detailDescription = "办公室宽带费用";
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: false,
entryEnabled: false,
oauthEnabled: false,
corpId: "",
agentId: "",
loginUrl: null,
bindEnabled: false,
defaultReturnTo: "/portal",
status: "disabled",
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-r102-token", expiresIn: 900, mustChangePassword: false })),
});
return;
}
if (path === "/api/me") {
await fulfillJson(route, me);
return;
}
if (path === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-r102",
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: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
],
admin: [],
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
},
recommendedRolePresets: [],
});
return;
}
if (path === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: "workspace-r102", persisted: true, profile: "small_team_internal" },
greeting: { name: me.name },
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
myApplications: [{ domain: "reimbursement", count: draftExists ? 1 : 0, href: "/reimbursements" }],
riskSummary: null,
announcements: [],
quickLinks: [],
todayPresence: null,
teamPresence: null,
weeklyReport: null,
weeklySummary: null,
todoSummary: null,
projectSummary: null,
peopleLifecycle: null,
adoptionSummary: null,
recentActivities: [],
teamStatusBoard: [],
});
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/reimbursements" && request.method() === "GET") {
const items = draftExists ? [reimbursementListItem()] : [];
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: "own", items });
return;
}
if (path === "/api/reimbursements" && request.method() === "POST") {
draftExists = true;
await fulfillJson(route, {
id: reimbursementId,
formNumber,
submitterId: me.id,
ownerId: me.id,
reimbursementDate: "2026-05-06",
status: "draft",
description: null,
projectId: null,
project: null,
totalAmount: "0",
linkedTravelRequestId: null,
linkedTravelRequest: null,
items: [],
});
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail({
reimbursementDate: detailReimbursementDate,
description: detailDescription,
}));
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") {
const body = request.postDataJSON() as { reimbursementDate?: string; description?: string | null };
detailReimbursementDate = body.reimbursementDate ?? detailReimbursementDate;
detailDescription = body.description ?? "";
await fulfillJson(route, {
id: reimbursementId,
status: "draft",
reimbursementDate: detailReimbursementDate,
description: detailDescription || null,
totalAmount: "168.30",
travelSummary: { purpose: null, location: null, startDate: null, endDate: null },
});
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") {
draftExists = false;
await fulfillJson(route, { reimbursementId, outcome: "deleted", unboundInvoiceCount: 0 });
return;
}
if (path === `/api/reimbursements/${reimbursementId}/precheck`) {
await fulfillJson(route, {
entityType: "reimbursement",
entityId: reimbursementId,
decision: "warn",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
evidenceSummary: {
invoiceCount: 0,
attachmentCount: 0,
items: [{ itemId, linkedInvoiceCount: 0, invoiceState: "unlinked" }],
},
timelinessChecks: [],
assetHint: { required: false, itemCount: 0 },
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["确认提示后可继续提交"],
});
return;
}
if (path === "/api/expense-types") {
await fulfillJson(route, { items: [] });
return;
}
if (path === "/api/projects") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (path === "/api/invoice-pool/bindable") {
await fulfillJson(route, {
items: [
{
id: invoiceId,
invoiceCode: "26112000000938423851",
invoiceNumber: "38423851",
invoiceDate: "2026-05-06",
sellerName: "北京悦海堂餐饮管理有限公司",
totalAmount: "168.30",
originalName: "发票.pdf",
createdAt: "2026-05-06T09:30:00.000Z",
},
],
});
return;
}
if (path === `/api/invoice-pool/${invoiceId}/bind`) {
await fulfillJson(route, { outcome: "bound" });
return;
}
if (path === "/api/notifications") {
await fulfillJson(route, { items: [], total: 0, page: 1, pageSize: 5, unreadCount: 0 });
return;
}
await fulfillJson(route, {});
});
}
async function login(page: Page) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("EmployeePassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
}
test.describe("R102 reimbursement main flow", () => {
test.beforeEach(async ({ page }) => {
await mockApis(page);
await login(page);
});
test("PC 列表显示业务编号并可删除草稿", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements?status=draft");
await expect(page.getByTestId("reimbursements-tab-draft")).toBeVisible();
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
await expect(page.getByText(reimbursementId)).toHaveCount(0);
await page.getByRole("button", { name: "更多" }).first().click();
await page.getByRole("menuitem", { name: "删除报销单", exact: true }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber);
await page.getByTestId("action-confirm-submit").click();
await expect(page.getByTestId("reimbursement-row")).toHaveCount(0);
await expect(page.getByTestId("reimbursements-table").getByText("还没有报销草稿")).toBeVisible();
});
test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByRole("heading", { name: `报销单 ${formNumber}` })).toBeVisible();
await expect(page.getByText("报销ID")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("approval-attachments")).toBeVisible();
await expect(page.getByText("执行支付")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("选择可添加发票,或上传新发票");
await expect(page.getByText("绑定整单")).toHaveCount(0);
await expect(page.getByText("不绑定明细")).toHaveCount(0);
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveCount(0);
const uploadInput = page.getByTestId("reimbursement-invoice-upload-file");
await expect(uploadInput).toHaveAttribute("multiple", "");
await expect(page.getByRole("button", { name: "上传发票" })).toBeVisible();
await page.getByRole("checkbox").first().check();
await expect(page.getByText("已选 1 张发票")).toBeVisible();
const bindResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === `/api/invoice-pool/${invoiceId}/bind` && response.ok();
});
await page.getByRole("button", { name: "添加到报销单", exact: true }).click();
await bindResponsePromise;
await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0);
});
test("PC 详情显示权威合计并可编辑报销日期和事由", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-expense-items-table").getByRole("columnheader")).toHaveText(["序号", "费用类型", "发生日期", "费用说明", "金额", "票据", "操作"]);
await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联");
await expect(page.getByTestId("approval-item-evidence")).toContainText("这条明细尚无对应票据");
await page.getByRole("button", { name: "编辑", exact: true }).first().click();
const editor = page.getByTestId("reimbursement-header-editor");
await editor.getByLabel("报销日期").fill("2026-05-07");
await editor.getByLabel("报销事由").fill("办公网络费用");
await editor.getByRole("button", { name: "保存", exact: true }).click();
await expect(page.getByTestId("reimbursement-header-editor")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("办公网络费用");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月7日");
});
test("移动报销卡片显示业务编号", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const listRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return request.method() === "GET" && url.pathname === "/api/reimbursements";
});
await page.goto("/mobile/reimbursements?scope=all");
const requestedUrl = new URL((await listRequest).url());
expect(requestedUrl.searchParams.get("scope")).toBe("own");
await expect(page.getByRole("heading", { name: "移动报销", exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
await expect(page.getByText(formNumber)).toBeVisible();
await expect(page.getByText(reimbursementId)).toHaveCount(0);
await expect(page.locator("table:visible")).toHaveCount(0);
await page.getByTestId("mobile-reimbursement-card").getByRole("link").click();
await expect(page.getByTestId("reimbursement-approval-evidence").getByTestId("approval-item-evidence")).toHaveCount(1);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30");
});
test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => {
const adminMe = {
...me,
id: "admin-r102",
employeeNo: "A102",
name: "测试管理员",
roleCodes: ["admin"],
};
const otherReimbursementId = "44444444-4444-4444-8444-444444444444";
const requestedScopes: Array<string | null> = [];
await page.route("**/api/me", async (route) => {
await fulfillJson(route, adminMe);
});
await page.route(`**/api/reimbursements/${otherReimbursementId}`, async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await fulfillJson(route, {
...reimbursementDetail(),
id: otherReimbursementId,
formNumber: "BX-20260731-0002",
status: "draft",
ownerId: "employee-other-r102",
submitterId: "employee-other-r102",
owner: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
submitter: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
invoices: [{
id: "invoice-other-r102",
status: "bound",
invoiceCode: "SYNTHETIC",
invoiceNumber: "R102-OTHER",
invoiceDate: "2026-07-31",
sellerName: "合成供应方",
buyerName: "合成购买方",
totalAmount: "168.30",
originalName: "合成发票.pdf",
mimeType: "application/pdf",
fileSize: 1024,
linkedItemId: null,
}],
});
});
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
const request = route.request();
if (request.method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(request.url());
const scope = url.searchParams.get("scope");
requestedScopes.push(scope);
const ownItem = {
...reimbursementListItem(),
status: "reviewing",
owner: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
submitter: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
};
const otherItem = {
...ownItem,
id: otherReimbursementId,
formNumber: "BX-20260731-0002",
owner: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
submitter: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
};
const items = scope === "all" ? [ownItem, otherItem] : [ownItem];
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: scope ?? "own", items });
});
await page.reload();
await expect(page).toHaveURL(/\/portal/);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${otherReimbursementId}`);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-withdraw")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-withdraw-comment")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0);
await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0);
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("R102-OTHER");
await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联");
await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0);
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/mobile/reimbursements?status=reviewing");
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveAttribute("data-state", "active");
await expect(page.getByText("其他员工")).toHaveCount(0);
expect(requestedScopes.at(-1)).toBe("own");
const ownBounds = await page.getByTestId("mobile-reimbursements-scope-own").boundingBox();
const allBounds = await page.getByTestId("mobile-reimbursements-scope-all").boundingBox();
expect(ownBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(allBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
const allResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.ok() && url.pathname === "/api/reimbursements" && url.searchParams.get("scope") === "all";
});
await page.getByTestId("mobile-reimbursements-scope-all").click();
await allResponse;
await expect(page).toHaveURL(/scope=all/);
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveAttribute("data-state", "active");
await expect(page.getByText("其他员工")).toBeVisible();
expect(requestedScopes.at(-1)).toBe("all");
await expect(page.getByTestId("mobile-reimbursement-delete")).toHaveCount(0);
await page.getByText("其他员工").click();
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /新增|编辑|删除/ })).toHaveCount(0);
await expect(page.getByTestId("approval-unlinked-invoices")).toBeVisible();
await expect(page.getByTestId("approval-item-evidence")).toContainText("票据:未关联");
await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0);
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
expect(routeState?.from).toContain("status=reviewing");
expect(routeState?.from).toContain("scope=all");
await page.goBack();
await page.setViewportSize({ width: 667, height: 375 });
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toBeVisible();
const horizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(horizontalOverflow).toBeLessThanOrEqual(2);
});
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑", exact: true }).click();
const addItem = page.getByTestId("reimbursement-item-add");
await addItem.click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("报销表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`));
await expect(page.getByTestId("reimbursement-item-editor")).toContainText("编辑明细");
await addItem.click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(page.getByTestId("reimbursement-item-editor")).toContainText("新增明细");
});
});