实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -2,6 +2,8 @@ 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 =
|
||||
@@ -63,6 +65,26 @@ function buildExpenseTypes() {
|
||||
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,
|
||||
},
|
||||
],
|
||||
@@ -163,6 +185,10 @@ function buildReimbursementState() {
|
||||
availableInvoices: [buildAvailableInvoice()],
|
||||
failedInvoices: [buildFailedInvoice()],
|
||||
statusPollCount: 0,
|
||||
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
||||
submitRequestCount: 0,
|
||||
invoiceGuardEnabled: false,
|
||||
detailItems: null as null | Array<Record<string, unknown>>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,7 +235,8 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
|
||||
},
|
||||
approvalHistory: [],
|
||||
riskFlags: [],
|
||||
items: [
|
||||
invoiceGuardEnabled: state.invoiceGuardEnabled,
|
||||
items: state.detailItems ?? [
|
||||
{
|
||||
id: "item-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
@@ -360,13 +387,23 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||||
await fulfillJson(route, {
|
||||
decision: "pass",
|
||||
matchedBudgetControlRule: null,
|
||||
standardChecks: [],
|
||||
budget: null,
|
||||
riskFlags: [],
|
||||
warnings: [],
|
||||
blockingReasons: [],
|
||||
nextActions: ["可直接提交"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
|
||||
state.submitRequestCount += 1;
|
||||
await fulfillJson(route, { id: reimbursementId, status: "reviewing" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/reimbursements/${reimbursementId}/attachments` && request.method() === "POST") {
|
||||
state.attachments = [buildAttachment()];
|
||||
await fulfillJson(route, { items: state.attachments });
|
||||
@@ -483,6 +520,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
}
|
||||
|
||||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) {
|
||||
state.bindRequests.push(request.postDataJSON());
|
||||
state.invoices = [buildInvoice(), buildUnlinkedInvoice()];
|
||||
await fulfillJson(route, {
|
||||
id: pendingInvoiceId,
|
||||
@@ -697,6 +735,91 @@ test.describe("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-assets-r111");
|
||||
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toBeVisible();
|
||||
await expect(page.getByText("2,000 元及以上通常选本类型,不等同于会计固定资产标准。")).toBeVisible();
|
||||
|
||||
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-assets-r111");
|
||||
await expect(page.getByText("事前批准依据", { exact: true })).toBeVisible();
|
||||
|
||||
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.getByTestId("reimbursement-overview").getByText("添加发票", { exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "提交报销", exact: true }).click();
|
||||
await expect.poll(() => state.submitRequestCount).toBe(1);
|
||||
});
|
||||
|
||||
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
||||
const state = buildReimbursementState();
|
||||
const mobile = await newWecomPage(browser);
|
||||
|
||||
在新工单中引用
屏蔽一个用户