feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -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 });
|
||||
|
||||
在新工单中引用
屏蔽一个用户