修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)
这个提交包含在:
@@ -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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户