实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
@@ -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);