修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)

这个提交包含在:
2026-08-31 20:20:20 +08:00
父节点 8b19538584
当前提交 f57e2236f2
共修改 21 个文件,包含 1829 行新增和 49 行删除
@@ -107,6 +107,7 @@ function buildInvoice() {
mimeType: "application/pdf",
fileSize: 4096,
linkedItemId: "item-r111" as string | null,
amountEvidenceStatus: "verified" as "verified" | "manual_review" | "ocr_failed",
};
}
@@ -188,7 +189,11 @@ function buildReimbursementState() {
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
itemSaveRequests: [] as Array<Record<string, unknown>>,
submitRequestCount: 0,
submitGuardDetails: null as null | Record<string, unknown>,
invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>,
invoiceItemLinkError: null as null | Record<string, unknown>,
invoiceGuardEnabled: false,
invoiceAmountManualReviewRequired: false,
detailItems: null as null | Array<Record<string, unknown>>,
};
}
@@ -237,6 +242,7 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
approvalHistory: [],
riskFlags: [],
invoiceGuardEnabled: state.invoiceGuardEnabled,
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
items: state.detailItems ?? [
{
id: "item-r111",
@@ -421,7 +427,61 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
state.submitRequestCount += 1;
await fulfillJson(route, { id: reimbursementId, status: "reviewing" });
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,
},
}),
});
return;
}
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,
},
}),
});
return;
}
state.invoices = state.invoices.map((invoice) => (
invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice
));
await fulfillJson(route, {
reimbursementId,
invoiceId,
linkedItemId: payload.linkedItemId,
});
return;
}
@@ -855,6 +915,153 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect.poll(() => state.submitRequestCount).toBe(1);
});
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: "OCR-FAILED",
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("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
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("reimbursement-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("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).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("mobile-reimbursement-invoice-guard-summary")).toContainText("共 4 项需要处理");
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
await expect(page.getByLabel("选择报销明细")).toBeVisible();
});
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
const state = buildReimbursementState();
const mobile = await newWecomPage(browser);