1323 行
57 KiB
TypeScript
1323 行
57 KiB
TypeScript
import { mkdirSync } from "node:fs";
|
||
import path from "node:path";
|
||
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
||
|
||
test.use({ serviceWorkers: "block" });
|
||
|
||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r111-invoice-reimbursement");
|
||
const WECOM_UA =
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||
|
||
const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
||
const ownerId = "employee-r111";
|
||
const pendingInvoiceId = "22222222-2222-4222-8222-222222222222";
|
||
const unlinkedInvoiceId = "66666666-6666-4666-8666-666666666666";
|
||
const attachmentId = "33333333-3333-4333-8333-333333333333";
|
||
const availableInvoiceId = "44444444-4444-4444-8444-444444444444";
|
||
const failedInvoiceId = "55555555-5555-4555-8555-555555555555";
|
||
|
||
function envelope(data: unknown) {
|
||
return { requestId: "mock-r111-invoice-reimbursement", data };
|
||
}
|
||
|
||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||
await route.fulfill({
|
||
status,
|
||
contentType: "application/json",
|
||
body: JSON.stringify(envelope(data)),
|
||
});
|
||
}
|
||
|
||
function screenshotPath(fileName: string) {
|
||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||
return path.join(SCREENSHOT_DIR, fileName.endsWith(".png") ? fileName : `${fileName}.png`);
|
||
}
|
||
|
||
function buildMe() {
|
||
return {
|
||
id: ownerId,
|
||
employeeNo: "E111",
|
||
name: "测试员工",
|
||
phone: "13800000000",
|
||
email: "[email protected]",
|
||
departmentId: "dept-r111",
|
||
directManagerId: null,
|
||
status: "active",
|
||
notificationPushEnabled: true,
|
||
roleCodes: ["employee"],
|
||
profileCompleteness: {
|
||
hasEmergencyContact: true,
|
||
hasBankAccount: true,
|
||
hasAvatar: false,
|
||
hasIdNumber: false,
|
||
score: 60,
|
||
},
|
||
wecomBinding: { corpId: "ww-test", userId: "r111-user", boundAt: "2026-05-08T08:00:00.000Z" },
|
||
};
|
||
}
|
||
|
||
function buildExpenseTypes() {
|
||
return {
|
||
items: [
|
||
{
|
||
id: "expense-type-r111",
|
||
code: "business_client_meal",
|
||
name: "业务招待",
|
||
category: "expense",
|
||
description: null,
|
||
requiresInvoice: true,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: "expense-type-office-supplies-r111",
|
||
code: "office_supplies",
|
||
name: "办公用品",
|
||
category: "office",
|
||
description: "按本条明细金额判断,多件同类分开记。",
|
||
requiresInvoice: true,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: "expense-type-assets-r111",
|
||
code: "assets_office_electronics",
|
||
name: "办公电子设备",
|
||
category: "assets",
|
||
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。该门槛不等同于会计固定资产确认标准。",
|
||
requiresInvoice: true,
|
||
enabled: true,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function buildInvoice() {
|
||
return {
|
||
id: pendingInvoiceId,
|
||
status: "bound",
|
||
invoiceCode: null as string | null,
|
||
invoiceNumber: "12345678",
|
||
invoiceDate: "2026-05-08",
|
||
sellerName: "北京悦海堂餐饮管理有限公司" as string | null,
|
||
buyerName: null as string | null,
|
||
documentCategory: "full_vat_invoice" as string | null,
|
||
documentSubtype: "VatElectronicInvoiceFull" as string | null,
|
||
totalAmount: "1683.00",
|
||
originalName: "北京悦海堂发票.pdf",
|
||
mimeType: "application/pdf",
|
||
fileSize: 4096,
|
||
linkedItemId: "item-r111" as string | null,
|
||
amountEvidenceStatus: "verified" as "verified" | "manual_review" | "ocr_failed",
|
||
};
|
||
}
|
||
|
||
function buildUnlinkedInvoice() {
|
||
return {
|
||
...buildInvoice(),
|
||
id: unlinkedInvoiceId,
|
||
invoiceNumber: "12345679",
|
||
sellerName: null,
|
||
buyerName: "测试购买方",
|
||
documentCategory: "electronic_train_ticket",
|
||
documentSubtype: "ElectronicTrainTicketFull",
|
||
originalName: "未关联发票.pdf",
|
||
linkedItemId: null,
|
||
};
|
||
}
|
||
|
||
function buildAttachment() {
|
||
return {
|
||
id: attachmentId,
|
||
originalName: "付款截图.png",
|
||
mimeType: "image/png",
|
||
fileSize: 2048,
|
||
fileSha256: "sha256-attachment-r111",
|
||
uploadedBy: { id: ownerId, name: "测试员工", employeeNo: "E111" },
|
||
createdAt: "2026-05-08T08:10:00.000Z",
|
||
};
|
||
}
|
||
|
||
function buildAvailableInvoice() {
|
||
return {
|
||
id: availableInvoiceId,
|
||
invoiceCode: "044002600111",
|
||
invoiceNumber: "87654321",
|
||
invoiceDate: "2026-05-08",
|
||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||
totalAmount: "1683.00",
|
||
originalName: "已有发票.pdf",
|
||
createdAt: "2026-05-08T08:00:00.000Z",
|
||
};
|
||
}
|
||
|
||
function buildFailedInvoice() {
|
||
return {
|
||
id: failedInvoiceId,
|
||
status: "ocr_failed",
|
||
ocrType: "vat",
|
||
ocrProvider: "tencent",
|
||
ocrAttempts: 1,
|
||
ocrLastError: "识别服务未返回完整发票信息",
|
||
ocrCompletedAt: "2026-05-08T08:05:00.000Z",
|
||
invoiceCode: null,
|
||
invoiceNumber: null,
|
||
invoiceDate: null,
|
||
amount: null,
|
||
taxAmount: null,
|
||
totalAmount: null,
|
||
linkedReimbursementId: null,
|
||
linkedItemId: null,
|
||
linkedTravelOrderId: null,
|
||
originalName: "识别失败发票.pdf",
|
||
mimeType: "application/pdf",
|
||
fileSize: 4096,
|
||
verification: null,
|
||
owner: { id: ownerId, employeeNo: "E111", name: "测试员工" },
|
||
uploadedBy: { id: ownerId, employeeNo: "E111", name: "测试员工" },
|
||
createdAt: "2026-05-08T08:01:00.000Z",
|
||
updatedAt: "2026-05-08T08:05:00.000Z",
|
||
};
|
||
}
|
||
|
||
function buildReimbursementState() {
|
||
return {
|
||
invoices: [] as ReturnType<typeof buildInvoice>[],
|
||
attachments: [] as ReturnType<typeof buildAttachment>[],
|
||
availableInvoices: [buildAvailableInvoice()],
|
||
failedInvoices: [buildFailedInvoice()],
|
||
statusPollCount: 0,
|
||
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
||
itemSaveRequests: [] as Array<Record<string, unknown>>,
|
||
submitRequestCount: 0,
|
||
submitSuccessCount: 0,
|
||
submitGuardDetails: null as null | Record<string, unknown>,
|
||
invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>,
|
||
invoiceItemLinkError: null as null | Record<string, unknown>,
|
||
clearSubmitGuardAfterInvoiceLink: false,
|
||
invoiceGuardEnabled: false,
|
||
invoiceAmountManualReviewRequired: false,
|
||
precheckDecision: "pass" as "pass" | "warn" | "manual_review",
|
||
precheckStandardChecks: [] as Array<Record<string, unknown>>,
|
||
detailItems: null as null | Array<Record<string, unknown>>,
|
||
};
|
||
}
|
||
|
||
function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementState>) {
|
||
return {
|
||
id: reimbursementId,
|
||
formNumber: "BX20260508001",
|
||
submitterId: ownerId,
|
||
ownerId,
|
||
departmentId: "dept-r111",
|
||
department: { id: "dept-r111", name: "研发部" },
|
||
status: "draft",
|
||
createdAt: "2026-05-08T08:00:00.000Z",
|
||
submittedAt: null,
|
||
reimbursementDate: "2026-05-08",
|
||
totalAmount: "1683.00",
|
||
description: "客户招待报销",
|
||
travelSummary: {
|
||
isTravel: true,
|
||
purpose: "合成差旅概要",
|
||
location: "测试地点",
|
||
startDate: "2026-05-07",
|
||
endDate: "2026-05-08",
|
||
},
|
||
projectId: null,
|
||
linkedTravelRequestId: null,
|
||
linkedTravelRequest: null,
|
||
project: null,
|
||
owner: { id: ownerId, employee_no: "E111", name: "测试员工" },
|
||
submitter: { id: ownerId, employee_no: "E111", name: "测试员工" },
|
||
invoices: state.invoices,
|
||
attachments: state.attachments,
|
||
budgetHint: null,
|
||
pendingWorkflow: null,
|
||
workflowProgress: null,
|
||
processingProgress: {
|
||
stages: [
|
||
{ key: "submitted", label: "申请提交", state: "current", actorName: "测试员工", occurredAt: null, result: "待提交", comment: null, detail: "待申请人提交", targetHref: null },
|
||
{ key: "manager_approval", label: "主管审批", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
|
||
{ key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
|
||
{ key: "payment_batch_review", label: "付款复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务安排付款", targetHref: null },
|
||
{ key: "payment_and_receipt", label: "付款完成", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款复核后由财务登记,非审批环节", targetHref: null },
|
||
],
|
||
},
|
||
approvalHistory: [],
|
||
riskFlags: [],
|
||
invoiceGuardEnabled: state.invoiceGuardEnabled,
|
||
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
|
||
items: state.detailItems ?? [
|
||
{
|
||
id: "item-r111",
|
||
expenseTypeId: "expense-type-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "1683.00",
|
||
description: "客户招待",
|
||
expenseType: {
|
||
id: "expense-type-r111",
|
||
code: "business_client_meal",
|
||
name: "业务招待",
|
||
category: "expense",
|
||
enabled: true,
|
||
},
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||
}
|
||
|
||
async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementState>) {
|
||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||
const request = route.request();
|
||
const url = new URL(request.url());
|
||
const pathName = url.pathname;
|
||
|
||
if (pathName === "/api/auth/sso/providers") {
|
||
await fulfillJson(route, []);
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/auth/wecom/config") {
|
||
await fulfillJson(route, {
|
||
enabled: true,
|
||
entryEnabled: true,
|
||
oauthEnabled: true,
|
||
corpId: "ww-test",
|
||
agentId: "1000002",
|
||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||
bindEnabled: true,
|
||
defaultReturnTo: "/mobile",
|
||
status: "ready",
|
||
statusReasons: [],
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/auth/wecom/js-sdk-signature" && request.method() === "POST") {
|
||
await fulfillJson(route, {
|
||
corpId: "ww-test",
|
||
timestamp: 1785484800,
|
||
nonceStr: "synthetic-r111-nonce",
|
||
signature: "1111111111111111111111111111111111111111",
|
||
});
|
||
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-r111-token", expiresIn: 900, mustChangePassword: false })),
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/me") {
|
||
await fulfillJson(route, buildMe());
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/system/navigation") {
|
||
await fulfillJson(route, {
|
||
workspace: {
|
||
id: "workspace-r111",
|
||
persisted: true,
|
||
profile: "small_team_internal",
|
||
orgMode: "single_company",
|
||
primaryEntryMode: "wecom",
|
||
notificationStrategy: "in_app",
|
||
devicePriority: "mobile",
|
||
moduleVisibility: {},
|
||
createdAt: null,
|
||
updatedAt: null,
|
||
updatedBy: null,
|
||
},
|
||
sections: {
|
||
main: [
|
||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
||
],
|
||
admin: [],
|
||
backup: [],
|
||
},
|
||
recommendedRolePresets: [],
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/notifications") {
|
||
await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] });
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/portal/home") {
|
||
await fulfillJson(route, {
|
||
workspace: { id: "workspace-r111", persisted: true, profile: "small_team_internal" },
|
||
greeting: { name: "测试员工" },
|
||
cards: [],
|
||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||
myApplications: [],
|
||
riskSummary: null,
|
||
announcements: [],
|
||
quickLinks: [{ key: "expense", title: "新建报销", href: "/reimbursements" }],
|
||
todayPresence: null,
|
||
teamPresence: null,
|
||
weeklyReport: null,
|
||
weeklySummary: null,
|
||
todoSummary: null,
|
||
projectSummary: null,
|
||
peopleLifecycle: null,
|
||
adoptionSummary: null,
|
||
recentActivities: [],
|
||
teamStatusBoard: [],
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/expense-types") {
|
||
await fulfillJson(route, buildExpenseTypes());
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/projects") {
|
||
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||
await fulfillJson(route, buildReimbursementDetail(state));
|
||
return;
|
||
}
|
||
|
||
if (
|
||
(pathName === `/api/reimbursements/${reimbursementId}/items` && request.method() === "POST")
|
||
|| (pathName.startsWith(`/api/reimbursements/${reimbursementId}/items/`) && request.method() === "PATCH")
|
||
) {
|
||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||
state.itemSaveRequests.push(payload);
|
||
await fulfillJson(route, {
|
||
item: {
|
||
id: "asset-item-r111",
|
||
expenseTypeId: payload.expenseTypeId ?? "expense-type-assets-r111",
|
||
expenseDate: payload.expenseDate ?? "2026-05-08",
|
||
amount: payload.amount ?? "2000.00",
|
||
description: payload.description ?? null,
|
||
assetPriorApprovalBasis: null,
|
||
},
|
||
totalAmount: "2000.00",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||
await fulfillJson(route, {
|
||
entityType: "reimbursement",
|
||
entityId: reimbursementId,
|
||
decision: state.precheckDecision,
|
||
matchedBudgetControlRule: null,
|
||
standardChecks: state.precheckStandardChecks,
|
||
budget: null,
|
||
riskFlags: [],
|
||
warnings: state.precheckDecision === "pass" ? [] : [{
|
||
code: `synthetic_${state.precheckDecision}`,
|
||
title: state.precheckDecision === "warn" ? "费用标准提醒" : "人工复核提醒",
|
||
detail: "合成提交确认路径",
|
||
}],
|
||
blockingReasons: [],
|
||
nextActions: [state.precheckDecision === "pass" ? "可直接提交" : "确认后提交"],
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
|
||
state.submitRequestCount += 1;
|
||
if (state.submitGuardDetails) {
|
||
await route.fulfill({
|
||
status: 400,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
requestId: "mock-r111-submit-guard",
|
||
error: {
|
||
code: "VALID_001",
|
||
message: "参数校验失败",
|
||
details: { ...state.submitGuardDetails, reasonCode: "reimbursement.items_invoice_item_guard_failed" },
|
||
},
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
state.submitSuccessCount += 1;
|
||
await fulfillJson(route, {
|
||
reimbursementId,
|
||
status: "reviewing",
|
||
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
|
||
manualReviewItemCount: state.invoiceAmountManualReviewRequired ? 1 : 0,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (
|
||
pathName.startsWith(`/api/reimbursements/${reimbursementId}/invoices/`)
|
||
&& pathName.endsWith("/item-link")
|
||
&& request.method() === "PATCH"
|
||
) {
|
||
const invoiceId = pathName.split("/").at(-2) ?? "";
|
||
const payload = request.postDataJSON() as { linkedItemId?: string };
|
||
state.invoiceItemLinkRequests.push({ invoiceId, linkedItemId: payload.linkedItemId });
|
||
if (state.invoiceItemLinkError) {
|
||
await route.fulfill({
|
||
status: 409,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
requestId: "mock-r111-item-link",
|
||
error: {
|
||
code: "VALID_001",
|
||
message: "参数校验失败",
|
||
details: { ...state.invoiceItemLinkError, reasonCode: "reimbursement.invoice_invalid_unlinked_item_state" },
|
||
},
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
state.invoices = state.invoices.map((invoice) => (
|
||
invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice
|
||
));
|
||
if (state.clearSubmitGuardAfterInvoiceLink) {
|
||
state.submitGuardDetails = null;
|
||
}
|
||
await fulfillJson(route, {
|
||
reimbursementId,
|
||
invoiceId,
|
||
linkedItemId: payload.linkedItemId,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/reimbursements/${reimbursementId}/attachments` && request.method() === "POST") {
|
||
state.attachments = [buildAttachment()];
|
||
await fulfillJson(route, { items: state.attachments });
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/reimbursements/${reimbursementId}/materials/preview-bundle` && request.method() === "POST") {
|
||
await fulfillJson(route, {
|
||
expiresAt: "2026-07-31T10:02:00.000Z",
|
||
signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777?previewToken=merged-pdf`,
|
||
originalName: "reimbursement-materials-preview.pdf",
|
||
mimeType: "application/pdf",
|
||
fileSize: 8192,
|
||
pageCount: state.invoices.length + state.attachments.length,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName.includes(`/api/reimbursements/${reimbursementId}/materials/preview/`) && request.method() === "GET") {
|
||
await route.fulfill({ status: 200, contentType: "application/pdf", body: Buffer.from("%PDF synthetic-preview") });
|
||
return;
|
||
}
|
||
|
||
if (
|
||
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`
|
||
&& request.method() === "POST"
|
||
) {
|
||
await fulfillJson(route, {
|
||
signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file?previewToken=synthetic`,
|
||
expiresAt: "2026-07-31T10:02:00.000Z",
|
||
originalName: "付款截图.png",
|
||
mimeType: "image/png",
|
||
fileSize: 2048,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (
|
||
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`
|
||
&& request.method() === "GET"
|
||
) {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "image/png",
|
||
headers: { "content-disposition": 'inline; filename="attachment.png"' },
|
||
body: Buffer.from("synthetic-r111-attachment"),
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/invoice-pool/bindable") {
|
||
await fulfillJson(route, {
|
||
items: state.availableInvoices,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/invoice-pool" && url.searchParams.get("status") === "ocr_failed") {
|
||
await fulfillJson(route, {
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: state.failedInvoices.length,
|
||
ownerScope: "own",
|
||
items: state.failedInvoices,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/invoice-pool/upload") {
|
||
state.statusPollCount = 0;
|
||
await fulfillJson(route, {
|
||
invoicePoolId: pendingInvoiceId,
|
||
status: "pending_ocr",
|
||
fileSha256: "sha256-r111",
|
||
uploadAction: "create",
|
||
unavailableReason: null,
|
||
unavailableMessage: null,
|
||
availabilityReason: "pending_ocr",
|
||
availabilityMessage: "发票正在识别,完成后会自动刷新",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === "/api/invoice-pool/status") {
|
||
state.statusPollCount += 1;
|
||
const stillPending = state.statusPollCount === 1;
|
||
await fulfillJson(route, {
|
||
items: [
|
||
{
|
||
id: pendingInvoiceId,
|
||
status: stillPending ? "pending_ocr" : "available",
|
||
reason: stillPending ? "pending_ocr" : "available",
|
||
message: stillPending ? "发票正在识别" : "发票可添加",
|
||
availabilityReason: stillPending ? "pending_ocr" : "available",
|
||
availabilityMessage: stillPending ? "发票正在识别" : "发票可添加",
|
||
invoiceCode: stillPending ? null : "044002600111",
|
||
invoiceNumber: stillPending ? null : "12345678",
|
||
invoiceDate: stillPending ? null : "2026-05-08",
|
||
sellerName: stillPending ? null : "北京悦海堂餐饮管理有限公司",
|
||
totalAmount: stillPending ? null : "1683.00",
|
||
originalName: "北京悦海堂发票.pdf",
|
||
linkedReimbursementId: null,
|
||
linkedItemId: null,
|
||
linkedTravelOrderId: null,
|
||
ocrAttempts: stillPending ? 0 : 1,
|
||
ocrLastError: null,
|
||
ocrCompletedAt: stillPending ? null : "2026-05-08T08:05:00.000Z",
|
||
updatedAt: stillPending ? "2026-05-08T08:04:00.000Z" : "2026-05-08T08:05:00.000Z",
|
||
},
|
||
],
|
||
missingIds: [],
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) {
|
||
state.bindRequests.push(request.postDataJSON());
|
||
state.invoices = [buildInvoice(), buildUnlinkedInvoice()];
|
||
await fulfillJson(route, {
|
||
id: pendingInvoiceId,
|
||
status: "bound",
|
||
linkedReimbursementId: reimbursementId,
|
||
linkedItemId: "item-r111",
|
||
linkedTravelOrderId: null,
|
||
totalAmount: "1683.00",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/signed-url` && request.method() === "POST") {
|
||
await fulfillJson(route, {
|
||
signedUrl: `https://oa.hyinda.com/api/invoice-pool/${pendingInvoiceId}/file?token=synthetic`,
|
||
expiresAt: "2026-07-31T10:02:00.000Z",
|
||
originalName: "北京悦海堂发票.pdf",
|
||
mimeType: "application/pdf",
|
||
fileSize: 4096,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/unbind`) {
|
||
state.invoices = [];
|
||
await fulfillJson(route, {
|
||
id: pendingInvoiceId,
|
||
status: "available",
|
||
linkedReimbursementId: null,
|
||
linkedItemId: null,
|
||
linkedTravelOrderId: null,
|
||
totalAmount: "1683.00",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/invoice-pool/${availableInvoiceId}/archive`) {
|
||
state.availableInvoices = [];
|
||
await fulfillJson(route, {
|
||
id: availableInvoiceId,
|
||
status: "archived",
|
||
archivedReason: "manual",
|
||
archivedAt: "2026-05-08T08:20:00.000Z",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (pathName === `/api/invoice-pool/${failedInvoiceId}/archive`) {
|
||
state.failedInvoices = [];
|
||
await fulfillJson(route, {
|
||
id: failedInvoiceId,
|
||
status: "archived",
|
||
archivedReason: "manual",
|
||
archivedAt: "2026-05-08T08:21:00.000Z",
|
||
});
|
||
return;
|
||
}
|
||
|
||
await fulfillJson(route, {});
|
||
});
|
||
}
|
||
|
||
async function login(page: Page) {
|
||
await page.goto("/login");
|
||
const accountLogin = page.getByRole("button", { name: "使用账号密码登录" });
|
||
if (await accountLogin.isVisible().catch(() => false)) {
|
||
await accountLogin.click();
|
||
}
|
||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||
await page.getByTestId("login-password").fill("EmployeePassword1234");
|
||
await page.getByTestId("login-submit").click();
|
||
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||
}
|
||
|
||
async function newWecomPage(browser: Browser) {
|
||
const context = await browser.newContext({
|
||
viewport: { width: 390, height: 844 },
|
||
isMobile: true,
|
||
hasTouch: true,
|
||
userAgent: WECOM_UA,
|
||
locale: "zh-CN",
|
||
serviceWorkers: "block",
|
||
});
|
||
return { context, page: await context.newPage() };
|
||
}
|
||
|
||
async function collectVisualMetrics(page: Page) {
|
||
return page.evaluate(() => {
|
||
function parseRgb(value: string) {
|
||
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i);
|
||
if (!match) return null;
|
||
return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: match[4] == null ? 1 : Number(match[4]) };
|
||
}
|
||
|
||
const nav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav");
|
||
const navRect = nav?.getBoundingClientRect();
|
||
const activeNav =
|
||
document.querySelector("[data-testid='mobile-nav-reimbursements']") ??
|
||
document.querySelector("nav a[aria-current='page']");
|
||
const activeStyle = activeNav ? getComputedStyle(activeNav) : null;
|
||
const activeRgb = activeStyle ? parseRgb(activeStyle.backgroundColor) : null;
|
||
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
||
const stickyRect = stickyActions?.getBoundingClientRect();
|
||
const materialAddButton = document.querySelector("[data-testid='reimbursement-invoice-add']");
|
||
const stickyAddButton = document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']");
|
||
const submitButton = document.querySelector("[data-testid='reimbursement-submit']");
|
||
const submitRect = submitButton?.getBoundingClientRect();
|
||
const bodyText = document.body?.innerText ?? "";
|
||
|
||
return {
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
||
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
|
||
navCount: document.querySelectorAll("[data-testid^='mobile-nav-']").length,
|
||
navHeight: navRect ? Math.round(navRect.height) : 0,
|
||
activeNavBackground: activeStyle?.backgroundColor ?? "",
|
||
activeNavAverage: activeRgb ? Math.round((activeRgb.r + activeRgb.g + activeRgb.b) / 3) : 255,
|
||
activeNavAlpha: activeRgb?.a ?? 0,
|
||
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
|
||
stickyBottomGap: stickyRect ? Math.round(window.innerHeight - stickyRect.bottom) : null,
|
||
stickyHeight: stickyRect ? Math.round(stickyRect.height) : 0,
|
||
materialInvoiceAddVisible: Boolean(materialAddButton?.getBoundingClientRect().height),
|
||
stickyInvoiceAddVisible: Boolean(stickyAddButton?.getBoundingClientRect().height),
|
||
stickyButtonMinHeight: submitRect?.height ?? 0,
|
||
activeElementTestId: document.activeElement?.getAttribute("data-testid") ?? "",
|
||
bodyText,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
|
||
page: Page,
|
||
options: { mobile: boolean; decision: "manual_review" | "warn" },
|
||
) {
|
||
const state = buildReimbursementState();
|
||
const targetItemId = `item-${options.decision}-${options.mobile ? "mobile" : "pc"}`;
|
||
const invoice = {
|
||
...buildUnlinkedInvoice(),
|
||
totalAmount: options.decision === "manual_review" ? null : "200.00",
|
||
amountEvidenceStatus: options.decision === "manual_review" ? "manual_review" as const : "verified" as const,
|
||
};
|
||
state.invoiceGuardEnabled = true;
|
||
state.invoiceAmountManualReviewRequired = options.decision === "manual_review";
|
||
state.precheckDecision = options.decision;
|
||
state.precheckStandardChecks = options.decision === "warn" ? [{
|
||
itemId: targetItemId,
|
||
expenseTypeId: "expense-type-r111",
|
||
expenseTypeName: "业务招待",
|
||
amount: "200.00",
|
||
standardId: "standard-r111",
|
||
standardName: "合成费用标准",
|
||
maxAmount: "100.00",
|
||
perUnit: "item",
|
||
controlMode: "warn",
|
||
scopeType: "global",
|
||
scopeKey: null,
|
||
exceeded: true,
|
||
exceededAmount: "100.00",
|
||
}] : [];
|
||
state.detailItems = [{
|
||
id: targetItemId,
|
||
expenseTypeId: "expense-type-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "200.00",
|
||
description: "合成恢复目标明细",
|
||
expenseType: { ...buildExpenseTypes().items[0] },
|
||
}];
|
||
state.invoices = [invoice];
|
||
state.submitGuardDetails = {
|
||
items: "invoice_item_guard_failed",
|
||
unlinkedInvoiceCount: 1,
|
||
unlinkedInvoiceIds: [invoice.id],
|
||
missingInvoiceItemIds: [targetItemId],
|
||
amountUnavailableItems: [],
|
||
amountExceededItems: [],
|
||
};
|
||
state.clearSubmitGuardAfterInvoiceLink = true;
|
||
|
||
await mockApis(page, state);
|
||
await login(page);
|
||
await page.setViewportSize(options.mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
|
||
await page.goto(options.mobile
|
||
? `/mobile/reimbursements/${reimbursementId}`
|
||
: `/reimbursements/${reimbursementId}`);
|
||
|
||
await page.getByTestId("reimbursement-submit").click();
|
||
const confirmDialog = page.getByRole("dialog");
|
||
await expect(confirmDialog).toBeVisible();
|
||
await expect(confirmDialog).toContainText(
|
||
options.decision === "manual_review" ? "需人工复核后继续" : "可继续,但需确认提醒",
|
||
);
|
||
await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click();
|
||
|
||
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
|
||
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
|
||
await expect(confirmDialog).toHaveCount(0);
|
||
await expect(page.locator("div.fixed.inset-0[data-state='open']")).toHaveCount(0);
|
||
const firstGuardIssue = page.locator('[data-reimbursement-guard-issue="true"]').first();
|
||
await expect(firstGuardIssue).toBeFocused();
|
||
|
||
const itemSelect = page.getByLabel("选择报销明细");
|
||
await itemSelect.selectOption(targetItemId);
|
||
await page.getByTestId("approval-unlinked-invoices").getByRole("button", { name: "确认关联", exact: true }).click();
|
||
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
|
||
invoiceId: invoice.id,
|
||
linkedItemId: targetItemId,
|
||
}]);
|
||
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toHaveCount(0);
|
||
|
||
await page.getByTestId("reimbursement-submit").click();
|
||
await expect(confirmDialog).toBeVisible();
|
||
await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click();
|
||
await expect.poll(() => state.submitSuccessCount).toBe(1);
|
||
await expect(page.getByTestId("reimbursement-action-error")).toHaveCount(0);
|
||
expect(state.submitRequestCount).toBe(2);
|
||
}
|
||
|
||
test.describe("R111 国内报销发票与附件双端体验", () => {
|
||
test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => {
|
||
const state = buildReimbursementState();
|
||
await page.addInitScript(() => {
|
||
const openCalls: unknown[][] = [];
|
||
Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true });
|
||
window.open = ((...args: unknown[]) => {
|
||
openCalls.push(args);
|
||
return null;
|
||
}) as typeof window.open;
|
||
});
|
||
await mockApis(page, state);
|
||
await login(page);
|
||
|
||
await page.setViewportSize({ width: 1440, height: 960 });
|
||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||
await expect(page.getByRole("heading", { name: "报销单 BX20260508001" })).toBeVisible();
|
||
await expect(page.getByText("预算与审核提示")).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.getByTestId("reimbursement-workflow-progress")).toBeVisible();
|
||
await expect(page.locator('a[href="#reimbursement-workflow-progress-panel"]')).toHaveCount(0);
|
||
await expect(page.locator('a[href="#reimbursement-processing-progress-panel"]')).toHaveCount(0);
|
||
await expect(page.getByText("ERP")).toHaveCount(0);
|
||
|
||
await page.getByTestId("reimbursement-invoice-add").click();
|
||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
||
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||
name: "北京悦海堂发票.pdf",
|
||
mimeType: "application/pdf",
|
||
buffer: Buffer.from("%PDF r111 invoice"),
|
||
});
|
||
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
|
||
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
|
||
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345678");
|
||
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345679");
|
||
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("无代码");
|
||
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("未识别销售方");
|
||
await expect(page.getByRole("button", { name: /查看发票:/ })).toHaveCount(2);
|
||
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: "查看附件", exact: true })).toHaveCount(0);
|
||
|
||
await page.locator("#reimbursement-attachment-upload").setInputFiles({
|
||
name: "付款截图.png",
|
||
mimeType: "image/png",
|
||
buffer: Buffer.from("r111 attachment"),
|
||
});
|
||
await page.getByRole("button", { name: /上传.*附件/ }).click();
|
||
await expect(page.getByTestId("approval-attachments")).toContainText("付款截图.png");
|
||
await expect(page.getByRole("button", { name: "查看补充附件:付款截图.png" })).toBeVisible();
|
||
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("合成差旅概要");
|
||
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("测试地点");
|
||
await page.getByRole("button", { name: "查看完整材料" }).click();
|
||
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
|
||
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0])).toContain("previewToken=merged-pdf");
|
||
|
||
const bodyText = await page.locator("body").innerText();
|
||
expect(bodyText).not.toContain("绑定整单");
|
||
expect(bodyText).not.toContain("不绑定明细");
|
||
expect(bodyText).not.toContain("发票池");
|
||
expect(bodyText).not.toContain("OCR");
|
||
expect(bodyText).not.toMatch(/\bdraft\b/);
|
||
expect(bodyText).not.toMatch(/\bbound\b/);
|
||
expect(bodyText).not.toContain("business_client_meal");
|
||
expect(bodyText).not.toContain("expense-type-r111");
|
||
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
|
||
});
|
||
|
||
test("B1/B2 在 PC 和移动端取消批准依据并保持设备提示与明确关联", async ({ page }) => {
|
||
const state = buildReimbursementState();
|
||
state.invoiceGuardEnabled = true;
|
||
state.detailItems = [{
|
||
id: "item-r111",
|
||
expenseTypeId: "expense-type-office-supplies-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "2000.00",
|
||
description: "合成办公配件",
|
||
assetPriorApprovalBasis: null,
|
||
expenseType: {
|
||
id: "expense-type-office-supplies-r111",
|
||
code: "office_supplies",
|
||
name: "办公用品",
|
||
category: "office",
|
||
enabled: true,
|
||
requiresInvoice: true,
|
||
},
|
||
}];
|
||
await mockApis(page, state);
|
||
await login(page);
|
||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||
|
||
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
|
||
await page.getByTestId("reimbursement-item-add").click();
|
||
await page.locator("#expenseTypeId").selectOption("expense-type-office-supplies-r111");
|
||
await page.getByTestId("reimbursement-item-amount").fill("2000.00");
|
||
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
|
||
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
|
||
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toHaveCount(0);
|
||
await expect(page.getByText(/请在明细说明中写明物品名称、实际使用人或责任人、存放位置/)).toBeVisible();
|
||
await page.getByTestId("reimbursement-item-description").fill("合成设备,测试员工使用,测试位置");
|
||
await page.getByTestId("reimbursement-item-save").click();
|
||
await expect.poll(() => state.itemSaveRequests.length).toBe(1);
|
||
expect(state.itemSaveRequests[0]).not.toHaveProperty("assetPriorApprovalBasis");
|
||
|
||
await page.getByTestId("reimbursement-invoice-add").click();
|
||
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
|
||
await invoiceDialog.locator("input[type='checkbox']").first().check();
|
||
await expect(invoiceDialog.getByText("请选择该发票对应的费用明细。")).toBeVisible();
|
||
const bindButton = invoiceDialog.getByRole("button", { name: "添加选中发票" });
|
||
await expect(bindButton).toBeDisabled();
|
||
await invoiceDialog.getByLabel("关联费用明细").selectOption("item-r111");
|
||
await expect(bindButton).toBeEnabled();
|
||
|
||
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||
name: "待识别办公发票.pdf",
|
||
mimeType: "application/pdf",
|
||
buffer: Buffer.from("%PDF r111 guarded invoice"),
|
||
});
|
||
await invoiceDialog.getByLabel("新上传发票关联明细").selectOption("item-r111");
|
||
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||
await expect.poll(() => state.bindRequests).toContainEqual({
|
||
reimbursementId,
|
||
linkedItemId: "item-r111",
|
||
});
|
||
|
||
await page.keyboard.press("Escape");
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
|
||
await page.getByRole("button", { name: "新增明细", exact: true }).click();
|
||
await page.getByLabel("费用类型").selectOption("expense-type-office-supplies-r111");
|
||
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
|
||
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
|
||
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
|
||
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
|
||
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
|
||
await page.getByRole("button", { name: "保存", exact: true }).click();
|
||
await expect.poll(() => state.itemSaveRequests.length).toBe(2);
|
||
expect(state.itemSaveRequests[1]).not.toHaveProperty("assetPriorApprovalBasis");
|
||
|
||
state.invoiceGuardEnabled = false;
|
||
state.invoices = [];
|
||
state.detailItems = [{
|
||
id: "allowance-item-r111",
|
||
expenseTypeId: "expense-type-allowance-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "100.00",
|
||
description: "1 天 × 100 元/天",
|
||
assetPriorApprovalBasis: null,
|
||
expenseType: {
|
||
id: "expense-type-allowance-r111",
|
||
code: "travel_meal",
|
||
name: "伙食补助费",
|
||
category: "travel",
|
||
enabled: true,
|
||
requiresInvoice: false,
|
||
},
|
||
}];
|
||
await page.setViewportSize({ width: 1440, height: 960 });
|
||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||
await expect(page.getByRole("button", { name: "提交报销", exact: true })).toBeVisible();
|
||
await expect(page.getByText("下一步:提交审批", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("所属票据(0)")).toHaveCount(0);
|
||
await page.getByRole("button", { name: "提交报销", exact: true }).click();
|
||
await expect.poll(() => state.submitRequestCount).toBe(1);
|
||
});
|
||
|
||
for (const decision of ["manual_review", "warn"] as const) {
|
||
for (const surface of ["PC", "移动"] as const) {
|
||
test(`${surface} ${decision} 确认提交遇 B2 阻断后关闭弹窗并可恢复重提`, async ({ page }) => {
|
||
await exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(page, {
|
||
decision,
|
||
mobile: surface === "移动",
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
test("B2 提交错误在 PC/移动就近呈现,未归属发票可原子选择明细", async ({ page }) => {
|
||
const state = buildReimbursementState();
|
||
state.invoiceGuardEnabled = true;
|
||
state.detailItems = [
|
||
{
|
||
id: "item-missing-r111",
|
||
expenseTypeId: "expense-type-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "200.00",
|
||
description: "缺少发票的明细",
|
||
expenseType: { ...buildExpenseTypes().items[0] },
|
||
},
|
||
{
|
||
id: "item-ocr-r111",
|
||
expenseTypeId: "expense-type-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "300.00",
|
||
description: "金额识别失败的明细",
|
||
expenseType: { ...buildExpenseTypes().items[0] },
|
||
},
|
||
{
|
||
id: "item-over-r111",
|
||
expenseTypeId: "expense-type-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "1000.00",
|
||
description: "金额超过发票的明细",
|
||
expenseType: { ...buildExpenseTypes().items[0] },
|
||
},
|
||
{
|
||
id: "item-manual-r111",
|
||
expenseTypeId: "expense-type-r111",
|
||
expenseDate: "2026-05-08",
|
||
amount: "400.00",
|
||
description: "人工核对票面金额的明细",
|
||
expenseType: { ...buildExpenseTypes().items[0] },
|
||
},
|
||
];
|
||
const ocrFailedInvoice = {
|
||
...buildInvoice(),
|
||
id: "77777777-7777-4777-8777-777777777777",
|
||
invoiceNumber: "AMOUNT-UNAVAILABLE",
|
||
originalName: "金额未识别.pdf",
|
||
totalAmount: null,
|
||
linkedItemId: "item-ocr-r111",
|
||
amountEvidenceStatus: "ocr_failed" as const,
|
||
};
|
||
const amountOverInvoice = {
|
||
...buildInvoice(),
|
||
id: "88888888-8888-4888-8888-888888888888",
|
||
invoiceNumber: "AMOUNT-OVER",
|
||
originalName: "金额不足.pdf",
|
||
totalAmount: "600.00",
|
||
linkedItemId: "item-over-r111",
|
||
};
|
||
const manualReviewInvoice = {
|
||
...buildInvoice(),
|
||
id: "99999999-9999-4999-8999-999999999999",
|
||
invoiceNumber: "MANUAL-REVIEW",
|
||
originalName: "待人工核对.pdf",
|
||
totalAmount: null,
|
||
linkedItemId: "item-manual-r111",
|
||
amountEvidenceStatus: "manual_review" as const,
|
||
};
|
||
state.invoices = [buildUnlinkedInvoice(), ocrFailedInvoice, amountOverInvoice, manualReviewInvoice];
|
||
state.invoiceAmountManualReviewRequired = true;
|
||
state.submitGuardDetails = {
|
||
items: "invoice_item_guard_failed",
|
||
unlinkedInvoiceCount: 1,
|
||
unlinkedInvoiceIds: [unlinkedInvoiceId],
|
||
missingInvoiceItemIds: ["item-missing-r111"],
|
||
amountUnavailableItems: [{
|
||
itemId: "item-ocr-r111",
|
||
invoiceIds: [ocrFailedInvoice.id],
|
||
}],
|
||
amountExceededItems: [{
|
||
itemId: "item-over-r111",
|
||
declaredAmount: "1000.00",
|
||
invoiceTotal: "600.00",
|
||
}],
|
||
};
|
||
|
||
await mockApis(page, state);
|
||
await login(page);
|
||
await page.setViewportSize({ width: 1440, height: 960 });
|
||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||
await page.getByTestId("reimbursement-submit").click();
|
||
|
||
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
|
||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toBeVisible();
|
||
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
|
||
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
|
||
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
|
||
await expect(page.getByText(
|
||
"该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。",
|
||
)).toBeVisible();
|
||
for (const forbiddenTerm of ["绑定整单", "不绑定明细", "发票池", "OCR"]) {
|
||
await expect(page.locator("body")).not.toContainText(forbiddenTerm);
|
||
}
|
||
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
|
||
await expect(page.getByText("item-missing-r111", { exact: false })).toHaveCount(0);
|
||
await page.getByLabel("选择报销明细").selectOption("item-missing-r111");
|
||
state.invoiceItemLinkError = { invoice: "invalid_unlinked_item_state" };
|
||
await page.getByRole("button", { name: "确认关联" }).click();
|
||
await expect(page.getByTestId("approval-unlinked-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。")).toBeVisible();
|
||
expect(state.invoices.find((invoice) => invoice.id === unlinkedInvoiceId)?.linkedItemId).toBeNull();
|
||
state.invoiceItemLinkError = null;
|
||
await page.getByRole("button", { name: "确认关联" }).click();
|
||
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
|
||
invoiceId: unlinkedInvoiceId,
|
||
linkedItemId: "item-missing-r111",
|
||
}, {
|
||
invoiceId: unlinkedInvoiceId,
|
||
linkedItemId: "item-missing-r111",
|
||
}]);
|
||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toHaveCount(0);
|
||
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
|
||
|
||
const structuredGuardDetails = state.submitGuardDetails;
|
||
state.submitGuardDetails = {
|
||
items: "invoice_item_guard_failed",
|
||
unlinkedInvoiceCount: "invalid-count",
|
||
unlinkedInvoiceIds: [123],
|
||
missingInvoiceItemIds: [{ id: "internal-only" }],
|
||
amountUnavailableItems: [{ itemId: null, invoiceIds: [unlinkedInvoiceId] }],
|
||
amountExceededItems: [{ itemId: "broken", declaredAmount: null, invoiceTotal: "600.00" }],
|
||
};
|
||
await page.getByTestId("reimbursement-submit").click();
|
||
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText(
|
||
"票据检查未通过,请核对每条明细与发票的关联和金额后重试。",
|
||
);
|
||
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
|
||
state.submitGuardDetails = structuredGuardDetails;
|
||
|
||
state.invoices = state.invoices.map((invoice) => (
|
||
invoice.id === unlinkedInvoiceId ? { ...invoice, linkedItemId: null } : invoice
|
||
));
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||
await page.getByTestId("reimbursement-submit").click();
|
||
await expect(page.getByTestId("reimbursement-action-error")).toContainText("票据检查未通过");
|
||
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请在票据区添加票据并选择这条明细。")).toBeVisible();
|
||
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
|
||
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
|
||
await expect(page.getByText(
|
||
"该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。",
|
||
)).toBeVisible();
|
||
await expect(page.getByLabel("选择报销明细")).toBeVisible();
|
||
for (const forbiddenTerm of ["绑定整单", "不绑定明细", "发票池", "OCR"]) {
|
||
await expect(page.locator("body")).not.toContainText(forbiddenTerm);
|
||
}
|
||
});
|
||
|
||
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
||
const state = buildReimbursementState();
|
||
const mobile = await newWecomPage(browser);
|
||
await mobile.page.addInitScript(() => {
|
||
const bridgeCalls: Array<{ name: string; params: Record<string, unknown> }> = [];
|
||
Object.defineProperty(window, "__r111BridgeCalls", { value: bridgeCalls, configurable: true });
|
||
Object.defineProperty(window, "WeixinJSBridge", {
|
||
configurable: true,
|
||
value: {
|
||
invoke(name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) {
|
||
bridgeCalls.push({ name, params });
|
||
callback({ err_msg: `${name}:ok` });
|
||
},
|
||
on() {},
|
||
},
|
||
});
|
||
const openCalls: unknown[][] = [];
|
||
Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true });
|
||
window.open = ((...args: unknown[]) => {
|
||
openCalls.push(args);
|
||
return null;
|
||
}) as typeof window.open;
|
||
});
|
||
await mockApis(mobile.page, state);
|
||
|
||
try {
|
||
await login(mobile.page);
|
||
await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
|
||
await expect(mobile.page.getByRole("heading", { name: "报销内容", exact: true })).toBeVisible();
|
||
|
||
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
|
||
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
|
||
expect(beforeDialogMetrics.tableCount).toBe(0);
|
||
expect(beforeDialogMetrics.navCount).toBe(0);
|
||
expect(beforeDialogMetrics.navHeight).toBe(0);
|
||
expect(beforeDialogMetrics.stickyActionsVisible).toBe(true);
|
||
expect(beforeDialogMetrics.stickyBottomGap).toBeLessThanOrEqual(2);
|
||
expect(beforeDialogMetrics.materialInvoiceAddVisible).toBe(true);
|
||
expect(beforeDialogMetrics.stickyInvoiceAddVisible).toBe(false);
|
||
expect(beforeDialogMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44);
|
||
|
||
await mobile.page.getByTestId("reimbursement-invoice-add").click();
|
||
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
||
const dialogMetrics = await collectVisualMetrics(mobile.page);
|
||
expect(dialogMetrics.activeElementTestId).not.toBe("mobile-reimbursement-invoice-search");
|
||
|
||
const invoiceDialog = mobile.page.getByTestId("reimbursement-invoice-dialog");
|
||
await invoiceDialog.getByRole("button", { name: "已有发票" }).click();
|
||
const availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600111-87654321" });
|
||
const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" });
|
||
await expect(availableInvoiceCard).toBeVisible();
|
||
await expect(availableInvoiceCard.getByText("可添加", { exact: true })).toBeVisible();
|
||
await expect(failedInvoiceCard.getByText("识别失败", { exact: true })).toBeVisible();
|
||
await expect(failedInvoiceCard).toContainText("可删除后重新上传");
|
||
|
||
await availableInvoiceCard.getByRole("button", { name: "删除" }).click();
|
||
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
|
||
await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click();
|
||
await expect(availableInvoiceCard).toHaveCount(0);
|
||
await expect(failedInvoiceCard).toBeVisible();
|
||
|
||
await failedInvoiceCard.getByRole("button", { name: "删除" }).click();
|
||
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
|
||
await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click();
|
||
await expect(failedInvoiceCard).toHaveCount(0);
|
||
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
|
||
|
||
await invoiceDialog.getByRole("button", { name: "上传" }).click();
|
||
await expect(invoiceDialog.getByText("选择 PDF 发票")).toBeVisible();
|
||
await expect(invoiceDialog.getByText("拍照或选图片")).toHaveCount(0);
|
||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", ".pdf,application/pdf");
|
||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).not.toHaveAttribute("capture", /.+/);
|
||
|
||
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||
name: "北京悦海堂发票.pdf",
|
||
mimeType: "application/pdf",
|
||
buffer: Buffer.from("%PDF r111 mobile invoice"),
|
||
});
|
||
await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||
await expect(mobile.page.getByTestId("reimbursement-invoice-pending-hint")).toHaveCount(1);
|
||
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).not.toContainText("识别完成后可在");
|
||
await expect(
|
||
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
await mobile.page.keyboard.press("Escape");
|
||
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
|
||
const mobileEvidence = mobile.page.getByTestId("reimbursement-approval-evidence");
|
||
await expect(mobileEvidence).toContainText("12345678");
|
||
await expect(mobileEvidence).toContainText("12345679");
|
||
await expect(mobileEvidence).not.toContainText("无代码");
|
||
await expect(mobileEvidence).not.toContainText("未识别销售方");
|
||
const mobileItem = mobile.page.getByTestId("approval-item-evidence").filter({ hasText: "客户招待" }).first();
|
||
await expect(mobileItem).not.toContainText("12345678");
|
||
await expect(mobileItem).not.toContainText("12345679");
|
||
await expect(mobile.page.getByTestId("approval-unlinked-invoices")).toContainText("12345679");
|
||
await expect(mobileEvidence.getByRole("button", { name: /查看发票:/ })).toHaveCount(2);
|
||
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
|
||
await expect(mobile.page.getByRole("button", { name: "查看附件", exact: true })).toHaveCount(0);
|
||
|
||
const maintenanceSection = mobile.page.locator("section").filter({ has: mobile.page.getByRole("heading", { name: "维护报销内容" }) });
|
||
await expect(maintenanceSection.getByRole("button", { name: "选择附件" })).toBeVisible();
|
||
await maintenanceSection.locator("input[type='file']").setInputFiles({
|
||
name: "付款截图.png",
|
||
mimeType: "image/png",
|
||
buffer: Buffer.from("r111 mobile attachment"),
|
||
});
|
||
await maintenanceSection.getByRole("button", { name: "上传 1 个附件" }).click();
|
||
await expect(mobile.page.getByRole("button", { name: "查看补充附件:付款截图.png" })).toBeVisible();
|
||
await expect(mobile.page.getByText("合成差旅概要")).toBeVisible();
|
||
await expect(mobile.page.getByText("测试地点")).toBeVisible();
|
||
await expect(mobile.page.getByTestId("approval-attachments")).toContainText("补充附件(1)");
|
||
const openAllButton = mobile.page.getByRole("button", { name: "查看完整材料" });
|
||
const openAllTarget = await openAllButton.boundingBox();
|
||
expect(openAllTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||
await openAllButton.click();
|
||
await expect.poll(() => mobile.page.evaluate(() => (
|
||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
|
||
const nativePreviewCalls = await mobile.page.evaluate(() => (
|
||
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
|
||
).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
|
||
expect(nativePreviewCalls).toEqual([
|
||
expect.objectContaining({
|
||
params: expect.objectContaining({
|
||
url: expect.stringContaining("previewToken=merged-pdf"),
|
||
name: "reimbursement-materials-preview.pdf",
|
||
size: 8192,
|
||
}),
|
||
}),
|
||
]);
|
||
expect(await mobile.page.evaluate(() => (
|
||
window as typeof window & { __r111OpenCalls: unknown[][] }
|
||
).__r111OpenCalls.length)).toBe(0);
|
||
|
||
await mobile.page.setViewportSize({ width: 667, height: 375 });
|
||
const landscapeMetrics = await collectVisualMetrics(mobile.page);
|
||
expect(landscapeMetrics.scrollWidth).toBeLessThanOrEqual(landscapeMetrics.width + 2);
|
||
await mobile.page.setViewportSize({ width: 390, height: 844 });
|
||
|
||
const bodyText = await mobile.page.locator("body").innerText();
|
||
expect(bodyText).not.toContain("绑定整单");
|
||
expect(bodyText).not.toContain("不绑定明细");
|
||
expect(bodyText).not.toContain("发票池");
|
||
expect(bodyText).not.toContain("OCR");
|
||
await mobile.page.screenshot({ path: screenshotPath("mobile-wecom-reimbursement-detail.png"), fullPage: true });
|
||
} finally {
|
||
await mobile.context.close();
|
||
}
|
||
});
|
||
});
|