实现 B-2a 报销审批内容工作台(reimbursement-approval-content-workbench)
这个提交包含在:
@@ -158,6 +158,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await waitForWorkflowTaskCache(page, task.id);
|
||||
|
||||
await page.context().setOffline(true);
|
||||
await page.getByTestId("mobile-approvals-more").click();
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明");
|
||||
await page.getByTestId("mobile-approvals-reject").click();
|
||||
@@ -179,3 +180,180 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await Promise.all([created.client.dispose(), managerApi.dispose()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail closed", async ({ page }) => {
|
||||
const taskId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const now = new Date().toISOString();
|
||||
const detail = {
|
||||
task: {
|
||||
id: taskId,
|
||||
instanceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
stepId: "finance",
|
||||
stepName: "财务审批",
|
||||
assigneeId: "manager",
|
||||
assigneeRole: "manager",
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comment: null,
|
||||
createdAt: now,
|
||||
completedAt: null,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
entityType: "reimbursement",
|
||||
currentStatus: "pending",
|
||||
currentStepId: "finance",
|
||||
contextData: {
|
||||
standardViolations: [{ itemId: "sensitive-item", amount: "999.00" }],
|
||||
budgetCheck: { budgetId: "sensitive-budget", budgetCode: "B-1", controlMode: "warn", usagePercent: 90, exceededThreshold: true, exceededBudget: false },
|
||||
budgetCheckStatus: "available",
|
||||
parseStatus: "ready",
|
||||
},
|
||||
startedAt: now,
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: {
|
||||
id: reimbursementId,
|
||||
formNumber: "BX-CACHE-001",
|
||||
status: "reviewing",
|
||||
reimbursementDate: "2026-09-01",
|
||||
totalAmount: "999.00",
|
||||
description: "缓存边界合成报销",
|
||||
owner: { id: "owner", employeeNo: "E001", name: "合成申请人" },
|
||||
submitter: { id: "owner", employeeNo: "E001", name: "合成申请人" },
|
||||
items: [{ id: "must-not-persist" }],
|
||||
invoices: [{ id: "must-not-persist", amountEvidenceStatus: "manual_review" }],
|
||||
attachments: [{ id: "must-not-persist", materialUrl: "https://invalid.example/material" }],
|
||||
sharedProjection: { itemEvidenceModels: ["must-not-persist"] },
|
||||
},
|
||||
leaveRequest: null,
|
||||
attendanceCorrection: null,
|
||||
purchaseRequest: null,
|
||||
travelRequest: null,
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
history: [],
|
||||
};
|
||||
const reimbursementDetail = {
|
||||
id: reimbursementId,
|
||||
items: [{
|
||||
id: "item-1",
|
||||
expenseTypeId: "type-1",
|
||||
expenseDate: "2026-09-01",
|
||||
amount: "999.00",
|
||||
description: "合成明细",
|
||||
expenseType: { id: "type-1", code: "travel_taxi", name: "交通费", category: "travel", enabled: true, requiresInvoice: true },
|
||||
}],
|
||||
invoices: [{
|
||||
id: "invoice-1",
|
||||
status: "bound",
|
||||
invoiceCode: null,
|
||||
invoiceNumber: "FP-CACHE-1",
|
||||
invoiceDate: "2026-09-01",
|
||||
sellerName: "合成供应商",
|
||||
buyerName: null,
|
||||
documentCategory: "invoice",
|
||||
documentSubtype: null,
|
||||
totalAmount: null,
|
||||
originalName: "synthetic.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 100,
|
||||
linkedItemId: "item-1",
|
||||
amountEvidenceStatus: "manual_review",
|
||||
}],
|
||||
attachments: [{
|
||||
id: "attachment-1",
|
||||
originalName: "support.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 100,
|
||||
fileSha256: "must-not-persist",
|
||||
createdAt: now,
|
||||
}],
|
||||
};
|
||||
|
||||
const fulfillData = (route: import("@playwright/test").Route, data: unknown, headers?: Record<string, string>) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers,
|
||||
body: JSON.stringify({ requestId: "cache-boundary", data }),
|
||||
});
|
||||
await page.route((url) => [
|
||||
"/api/auth/sso/providers", "/api/auth/wecom/config", "/api/auth/login", "/api/auth/refresh",
|
||||
"/api/me", "/api/system/navigation", "/api/notifications", "/api/portal/home",
|
||||
"/api/workflow/tasks", "/api/workflow/tasks/summary",
|
||||
].includes(url.pathname), async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/api/auth/sso/providers") return fulfillData(route, []);
|
||||
if (url.pathname === "/api/auth/wecom/config") return fulfillData(route, {
|
||||
enabled: false, entryEnabled: false, oauthEnabled: false, corpId: null, agentId: null,
|
||||
loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [],
|
||||
});
|
||||
if (url.pathname === "/api/auth/login" || url.pathname === "/api/auth/refresh") {
|
||||
return fulfillData(route, { accessToken: "cache-boundary-token", expiresIn: 900, mustChangePassword: false }, { "set-cookie": "has_session=1; Path=/; SameSite=Lax" });
|
||||
}
|
||||
if (url.pathname === "/api/me") return fulfillData(route, {
|
||||
id: "manager", employeeNo: "M001", name: "合成审批人", phone: null, email: "[email protected]",
|
||||
departmentId: "department", directManagerId: null, status: "active", notificationPushEnabled: true,
|
||||
roleCodes: ["manager"], bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null },
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 70 },
|
||||
});
|
||||
if (url.pathname === "/api/system/navigation") return fulfillData(route, {
|
||||
workspace: { id: "workspace", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "mobile", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null },
|
||||
sections: { main: [{ key: "workflow", href: "/workflow/tasks", label: "待我审批", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null }], admin: [], backup: [] },
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
if (url.pathname === "/api/notifications") return fulfillData(route, { page: 1, pageSize: 5, total: 0, unreadOnly: false, unreadCount: 0, items: [] });
|
||||
if (url.pathname === "/api/portal/home") return fulfillData(route, { hero: { title: "工作台", subtitle: "合成测试" }, metrics: [], todos: [], notifications: [] });
|
||||
if (url.pathname === "/api/workflow/tasks/summary") return fulfillData(route, { scope: "mine", pendingTotal: 1, overdueTotal: 0, earliestPendingAt: now, averagePendingAgeHours: 1, byEntityType: [], byStep: [], byAssigneeRole: [] });
|
||||
return fulfillData(route, { page: 1, pageSize: 100, total: 0, items: [] });
|
||||
});
|
||||
|
||||
await page.route(`**/api/workflow/tasks/${taskId}`, (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ requestId: "cache-boundary", data: detail }),
|
||||
}));
|
||||
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ requestId: "cache-boundary", data: reimbursementDetail }),
|
||||
}));
|
||||
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${taskId}`);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
|
||||
await waitForWorkflowTaskCache(page, taskId);
|
||||
|
||||
const rawCache = await page.evaluate(async (currentTaskId) => {
|
||||
const request = window.indexedDB.open("hyb-oa-mobile-workflow", 1);
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
try {
|
||||
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
|
||||
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const query = store.get(currentTaskId);
|
||||
query.onsuccess = () => resolve(query.result);
|
||||
query.onerror = () => reject(query.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}, taskId);
|
||||
const serialized = JSON.stringify(rawCache);
|
||||
for (const forbiddenField of [
|
||||
"items", "invoices", "attachments", "amountEvidenceStatus", "materialUrl",
|
||||
"fileSha256", "sharedProjection", "itemEvidenceModels", "standardViolations",
|
||||
"budgetCheck", "contextData", "sensitive-item", "sensitive-budget", "must-not-persist",
|
||||
]) {
|
||||
expect(serialized).not.toContain(forbiddenField);
|
||||
}
|
||||
|
||||
await page.context().setOffline(true);
|
||||
await expect(page.getByText("请联网加载完整明细和票据后再处理该报销审批")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
|
||||
await page.getByTestId("mobile-approvals-more").click();
|
||||
await expect(page.getByTestId("mobile-approvals-return")).toBeDisabled();
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -110,7 +110,11 @@ function navigationItem(key: string, href: string, label: string, icon: string,
|
||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
async function mockWorkflowConfirmApis(page: Page) {
|
||||
async function mockWorkflowConfirmApis(page: Page, options: {
|
||||
taskDetail?: typeof pendingTaskDetail | Record<string, unknown>;
|
||||
reimbursementDetail?: Record<string, unknown>;
|
||||
reimbursementDetailFailure?: boolean;
|
||||
} = {}) {
|
||||
const actionCalls = {
|
||||
approve: 0,
|
||||
reject: 0,
|
||||
@@ -248,6 +252,19 @@ async function mockWorkflowConfirmApis(page: Page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\/api\/reimbursements\/[^/]+$/.test(path)) {
|
||||
if (options.reimbursementDetailFailure) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ requestId: "mock-workflow-confirm", error: { code: "SYS_001", message: "合成加载失败", details: null } }),
|
||||
});
|
||||
} else {
|
||||
await fulfillJson(route, options.reimbursementDetail ?? {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = path.match(/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return)$/);
|
||||
if (actionMatch) {
|
||||
const action = actionMatch[1] as keyof typeof actionCalls;
|
||||
@@ -257,7 +274,7 @@ async function mockWorkflowConfirmApis(page: Page) {
|
||||
}
|
||||
|
||||
if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) {
|
||||
await fulfillJson(route, pendingTaskDetail);
|
||||
await fulfillJson(route, options.taskDetail ?? pendingTaskDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,4 +358,101 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect.poll(() => actionCalls.return).toBe(1);
|
||||
});
|
||||
|
||||
test("PC 报销审批同屏展示证据、引用退回意见且不暴露维护动作", async ({ page }) => {
|
||||
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const taskDetail = {
|
||||
...pendingTaskDetail,
|
||||
workflowInstance: {
|
||||
...pendingTaskDetail.workflowInstance,
|
||||
entityType: "reimbursement",
|
||||
contextData: {
|
||||
standardViolations: [{
|
||||
itemId: "item-1", expenseTypeId: "type-1", expenseTypeName: "交通费", amount: "180.00",
|
||||
standardId: "standard-1", standardName: "交通费标准", maxAmount: "120.00", perUnit: "item",
|
||||
controlMode: "warn", scopeType: "global", scopeKey: null, exceeded: true, exceededAmount: "60.00",
|
||||
}],
|
||||
budgetCheck: null,
|
||||
budgetCheckStatus: "missing",
|
||||
parseStatus: "ready",
|
||||
},
|
||||
},
|
||||
reimbursement: {
|
||||
id: reimbursementId,
|
||||
formNumber: "BX-APPROVAL-001",
|
||||
status: "reviewing",
|
||||
reimbursementDate: "2026-09-01",
|
||||
totalAmount: "180.00",
|
||||
description: "客户拜访交通费",
|
||||
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
|
||||
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
|
||||
},
|
||||
leaveRequest: null,
|
||||
};
|
||||
const reimbursementDetail = {
|
||||
id: reimbursementId,
|
||||
items: [{
|
||||
id: "item-1", expenseTypeId: "type-1", expenseDate: "2026-09-01", amount: "180.00",
|
||||
description: "客户拜访打车", assetPriorApprovalBasis: null,
|
||||
expenseType: { id: "type-1", code: "travel_taxi", name: "交通费", category: "travel", enabled: true, requiresInvoice: true },
|
||||
}],
|
||||
invoices: [
|
||||
{
|
||||
id: "invoice-1", status: "bound", invoiceCode: null, invoiceNumber: "FP-001", invoiceDate: "2026-09-01",
|
||||
sellerName: "合成供应商", buyerName: null, documentCategory: "invoice", documentSubtype: null,
|
||||
totalAmount: "120.00", originalName: "invoice.pdf", mimeType: "application/pdf", fileSize: 100,
|
||||
linkedItemId: "item-1", amountEvidenceStatus: "verified",
|
||||
},
|
||||
{
|
||||
id: "invoice-2", status: "bound", invoiceCode: null, invoiceNumber: "FP-002", invoiceDate: "2026-09-01",
|
||||
sellerName: "合成供应商", buyerName: null, documentCategory: "invoice", documentSubtype: null,
|
||||
totalAmount: null, originalName: "unlinked.pdf", mimeType: "application/pdf", fileSize: 100,
|
||||
linkedItemId: null, amountEvidenceStatus: "manual_review",
|
||||
},
|
||||
],
|
||||
attachments: [{ id: "attachment-1", originalName: "说明.pdf", mimeType: "application/pdf", fileSize: 100, createdAt: pendingTaskCreatedAt }],
|
||||
};
|
||||
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetail });
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("客户拜访打车");
|
||||
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("FP-002");
|
||||
await expect(page.getByTestId("reimbursement-approval-checks")).toContainText("该次提交未记录预算快照");
|
||||
await expect(page.getByText("当前申报金额高于已读取的票面金额合计")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "编辑明细" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "删除明细" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "选择明细" })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("approval-reference-item").click();
|
||||
await expect(page.getByTestId("workflow-task-return-comment")).toHaveValue(/【明细:交通费 · 2026-09-01 · ¥180\.00】/);
|
||||
});
|
||||
|
||||
test("PC 报销内容加载失败时四类审批入口 fail closed", async ({ page }) => {
|
||||
const taskDetail = {
|
||||
...pendingTaskDetail,
|
||||
workflowInstance: {
|
||||
...pendingTaskDetail.workflowInstance,
|
||||
entityType: "reimbursement",
|
||||
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
|
||||
},
|
||||
reimbursement: {
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", formNumber: "BX-FAIL-001", status: "reviewing",
|
||||
reimbursementDate: "2026-09-01", totalAmount: "100.00", description: "合成失败场景",
|
||||
owner: { id: "owner", employeeNo: "E001", name: "申请人" }, submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
|
||||
},
|
||||
leaveRequest: null,
|
||||
};
|
||||
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetailFailure: true });
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
await expect(page.getByRole("alert")).toContainText("服务暂不可用");
|
||||
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
|
||||
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
|
||||
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
|
||||
await page.locator("summary", { hasText: "更多" }).click();
|
||||
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户