feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-09 23:19:02 +08:00
父节点 b87cc55aca
当前提交 e5685f18b2
共修改 28 个文件,包含 1616 行新增和 121 行删除
@@ -585,6 +585,9 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === "/api/invoice-pool/status") {
state.statusPollCount += 1;
const stillPending = state.statusPollCount === 1;
if (!stillPending && !state.availableInvoices.some((invoice) => invoice.id === pendingInvoiceId)) {
state.availableInvoices.push({ ...buildAvailableInvoice(), id: pendingInvoiceId, invoiceNumber: "12345678" });
}
await fulfillJson(route, {
items: [
{
@@ -835,7 +838,135 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
}
test.describe("R111 国内报销发票与附件双端体验", () => {
test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => {
for (const mobile of [false, true]) {
test(`选票优先空单 ${mobile ? "手机" : "桌面"} 上传不占用、一次建明细并批量分类`, async ({ page }) => {
const state = buildReimbursementState();
state.detailItems = [];
state.invoices = [];
state.availableInvoices = [];
state.failedInvoices = [];
state.invoiceGuardEnabled = true;
await mockApis(page, state);
let bindCount = 0;
let classificationCount = 0;
await page.route("**/api/invoice-pool/upload", async (route) => {
state.availableInvoices = [buildAvailableInvoice()];
await fulfillJson(route, { invoicePoolId: availableInvoiceId, status: "available", uploadAction: "create" });
});
await page.route(`**/api/invoice-pool/${availableInvoiceId}/bind`, async (route) => {
expect(route.request().postDataJSON()).toEqual({ reimbursementId, createItem: true });
bindCount += 1;
state.detailItems = [{ id: "item-auto", expenseTypeId: null, expenseType: null, expenseDate: "2026-05-08", amount: "1683.00", description: "" }];
state.availableInvoices = [];
await fulfillJson(route, { id: availableInvoiceId, status: "bound", linkedItemId: "item-auto" });
});
await page.route(`**/api/reimbursements/${reimbursementId}/items/classify`, async (route) => {
expect(route.request().postDataJSON()).toEqual({ itemIds: ["item-auto"], expenseTypeId: "expense-type-office-supplies-r111" });
classificationCount += 1;
const expenseType = buildExpenseTypes().items.find((type) => type.id === "expense-type-office-supplies-r111")!;
state.detailItems = state.detailItems!.map((item) => ({ ...item, expenseTypeId: expenseType.id, expenseType }));
await fulfillJson(route, {});
});
await login(page);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-invoice-add").click();
await expect(page.getByRole("dialog")).toContainText("我的发票");
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "synthetic.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4 synthetic") });
await page.getByTestId("reimbursement-invoice-upload-submit").click();
const dialog = page.getByRole("dialog");
const checkbox = dialog.getByRole("checkbox").first();
await expect(checkbox).toBeChecked();
expect(bindCount).toBe(0);
await dialog.getByRole("button", { name: "返回报销单" }).click();
expect(bindCount).toBe(0);
await page.getByTestId("reimbursement-invoice-add").click();
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await expect(checkbox).not.toBeChecked();
await checkbox.check();
await checkbox.uncheck();
await expect(dialog.getByRole("button", { name: /添加到报销单/ })).toBeDisabled();
expect(bindCount).toBe(0);
await checkbox.check();
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => bindCount).toBe(1);
if (mobile) await page.keyboard.press("Escape");
await expect(page.getByText("费用分类:1 条待补充")).toBeVisible();
await page.reload();
await expect(page.getByText("费用分类:1 条待补充")).toBeVisible();
await page.getByRole("checkbox", { name: /明细 1/ }).check();
await page.getByLabel("批量费用类型").selectOption("expense-type-office-supplies-r111");
await page.getByRole("button", { name: "设置所选 1 条分类" }).click();
await expect.poll(() => classificationCount).toBe(1);
await expect(page.getByText("费用分类:1 条待补充")).toHaveCount(0);
await page.reload();
await expect(page.getByText("费用分类(可批量修改)")).toBeVisible();
});
test(`已有明细入口 ${mobile ? "手机" : "桌面"} 选旧票只发送指定明细`, async ({ page }) => {
const state = buildReimbursementState();
await mockApis(page, state);
const requests: unknown[] = [];
await page.route(`**/api/invoice-pool/${availableInvoiceId}/bind`, async (route) => {
requests.push(route.request().postDataJSON());
await fulfillJson(route, { id: availableInvoiceId, linkedItemId: "item-r111" });
});
await login(page);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
await page.getByRole("button", { name: "为明细添加发票" }).first().click();
const dialog = page.getByRole("dialog");
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await dialog.getByRole("checkbox").first().check();
await expect(dialog.getByLabel("添加到哪条明细")).toHaveValue("item-r111");
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => requests.length).toBe(1);
expect(requests[0]).toEqual({ reimbursementId, linkedItemId: "item-r111" });
if (mobile) await dialog.getByRole("button", { name: "返回报销单" }).click();
await expect(dialog).not.toBeVisible();
await page.getByTestId("reimbursement-invoice-add").click();
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await dialog.getByRole("checkbox").first().check();
await expect(dialog.getByLabel("添加到哪条明细")).toHaveValue("");
});
test(`多票添加 ${mobile ? "手机" : "桌面"} 部分失败仅重试失败票`, async ({ page }) => {
const state = buildReimbursementState();
state.failedInvoices = [];
state.availableInvoices = [buildAvailableInvoice(), { ...buildAvailableInvoice(), id: pendingInvoiceId, invoiceNumber: "87654321" }];
await mockApis(page, state);
const attempts: Record<string, number> = {};
await page.route("**/api/invoice-pool/*/bind", async (route) => {
const invoiceId = new URL(route.request().url()).pathname.split("/").at(-2)!;
expect(route.request().postDataJSON()).toEqual({ reimbursementId, createItem: true });
attempts[invoiceId] = (attempts[invoiceId] ?? 0) + 1;
if (invoiceId === pendingInvoiceId && attempts[invoiceId] === 1) {
await route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ error: { code: "VALID_001", message: "validation failed", details: { reason: "invoice_prefill_incomplete" } } }) });
return;
}
state.availableInvoices = state.availableInvoices.filter((invoice) => invoice.id !== invoiceId);
await fulfillJson(route, { id: invoiceId, status: "bound" });
});
await login(page);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-invoice-add").click();
const dialog = page.getByRole("dialog");
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await dialog.getByRole("checkbox").nth(0).check();
await dialog.getByRole("checkbox").nth(1).check();
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => attempts[pendingInvoiceId]).toBe(1);
await expect(dialog.getByRole("checkbox")).toHaveCount(1);
await expect(dialog.getByRole("checkbox")).toBeChecked();
await expect(dialog.getByRole("button", { name: /添加到报销单/ })).toBeEnabled();
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => attempts[pendingInvoiceId]).toBe(2);
expect(attempts[availableInvoiceId]).toBe(1);
});
}
test("PC 报销详情上传发票后选中添加,并提供附件材料", async ({ page }) => {
const state = buildReimbursementState();
await page.addInitScript(() => {
const openCalls: unknown[][] = [];
@@ -868,6 +999,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
buffer: Buffer.from("%PDF r111 invoice"),
});
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
expect(state.bindRequests).toHaveLength(0);
await page.getByRole("dialog").getByRole("button", { name: "添加到报销单" }).click();
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
@@ -946,10 +1080,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
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(invoiceDialog.getByText("点击添加后生成可编辑明细;也可选择加入已有明细。")).toBeVisible();
const bindButton = invoiceDialog.getByRole("button", { name: "添加到报销单" });
await expect(bindButton).toBeEnabled();
await invoiceDialog.getByLabel("添加到哪条明细").selectOption("item-r111");
await expect(bindButton).toBeEnabled();
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
@@ -957,8 +1091,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r111 guarded invoice"),
});
await invoiceDialog.getByLabel("新上传发票关联明细").selectOption("item-r111");
await invoiceDialog.getByLabel("新发票添加位置").selectOption("item-r111");
await invoiceDialog.locator("input[type='checkbox']").first().uncheck();
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
expect(state.bindRequests).toHaveLength(0);
await bindButton.click();
await expect.poll(() => state.bindRequests).toContainEqual({
reimbursementId,
linkedItemId: "item-r111",
@@ -969,10 +1107,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
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.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 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();
@@ -1251,6 +1389,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
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.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
expect(state.bindRequests).toHaveLength(0);
await invoiceDialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect(
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
).toBeVisible({ timeout: 15_000 });