修复报销确认弹窗阻断恢复(08-30-b2-submit-error-ocr-fallback)

这个提交包含在:
2026-09-01 12:58:35 +08:00
父节点 b6ff44d504
当前提交 5b53d29846
共修改 6 个文件,包含 128 行新增和 8 行删除
@@ -436,9 +436,10 @@ export function MobileReimbursementDetailPage() {
onError: (error) => {
const message = resolveErrorMessage(error);
const issues = parseReimbursementInvoiceGuardIssues(error);
setSubmitConfirmPrecheck(null);
setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) focusFirstInvoiceGuardIssue();
if (issues) window.requestAnimationFrame(focusFirstInvoiceGuardIssue);
toast.error(message);
},
});
+2 -1
查看文件
@@ -276,9 +276,10 @@ export function ReimbursementDetailPage() {
onError: (error) => {
const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
const issues = parseReimbursementInvoiceGuardIssues(error);
setSubmitConfirmPrecheck(null);
setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) focusFirstInvoiceGuardIssue();
if (issues) window.requestAnimationFrame(focusFirstInvoiceGuardIssue);
toast.error(message);
},
});
@@ -189,11 +189,15 @@ function buildReimbursementState() {
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
itemSaveRequests: [] as Array<Record<string, unknown>>,
submitRequestCount: 0,
submitSuccessCount: 0,
submitGuardDetails: null as null | Record<string, unknown>,
invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>,
invoiceItemLinkError: null as null | Record<string, unknown>,
clearSubmitGuardAfterInvoiceLink: false,
invoiceGuardEnabled: false,
invoiceAmountManualReviewRequired: false,
precheckDecision: "pass" as "pass" | "warn" | "manual_review",
precheckStandardChecks: [] as Array<Record<string, unknown>>,
detailItems: null as null | Array<Record<string, unknown>>,
};
}
@@ -413,14 +417,20 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
await fulfillJson(route, {
decision: "pass",
entityType: "reimbursement",
entityId: reimbursementId,
decision: state.precheckDecision,
matchedBudgetControlRule: null,
standardChecks: [],
standardChecks: state.precheckStandardChecks,
budget: null,
riskFlags: [],
warnings: [],
warnings: state.precheckDecision === "pass" ? [] : [{
code: `synthetic_${state.precheckDecision}`,
title: state.precheckDecision === "warn" ? "费用标准提醒" : "人工复核提醒",
detail: "合成提交确认路径",
}],
blockingReasons: [],
nextActions: ["可直接提交"],
nextActions: [state.precheckDecision === "pass" ? "可直接提交" : "确认后提交"],
});
return;
}
@@ -442,6 +452,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
});
return;
}
state.submitSuccessCount += 1;
await fulfillJson(route, {
reimbursementId,
status: "reviewing",
@@ -477,6 +488,9 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
state.invoices = state.invoices.map((invoice) => (
invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice
));
if (state.clearSubmitGuardAfterInvoiceLink) {
state.submitGuardDetails = null;
}
await fulfillJson(route, {
reimbursementId,
invoiceId,
@@ -733,6 +747,96 @@ async function collectVisualMetrics(page: Page) {
});
}
async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
page: Page,
options: { mobile: boolean; decision: "manual_review" | "warn" },
) {
const state = buildReimbursementState();
const targetItemId = `item-${options.decision}-${options.mobile ? "mobile" : "pc"}`;
const invoice = {
...buildUnlinkedInvoice(),
totalAmount: options.decision === "manual_review" ? null : "200.00",
amountEvidenceStatus: options.decision === "manual_review" ? "manual_review" as const : "verified" as const,
};
state.invoiceGuardEnabled = true;
state.invoiceAmountManualReviewRequired = options.decision === "manual_review";
state.precheckDecision = options.decision;
state.precheckStandardChecks = options.decision === "warn" ? [{
itemId: targetItemId,
expenseTypeId: "expense-type-r111",
expenseTypeName: "业务招待",
amount: "200.00",
standardId: "standard-r111",
standardName: "合成费用标准",
maxAmount: "100.00",
perUnit: "item",
controlMode: "warn",
scopeType: "global",
scopeKey: null,
exceeded: true,
exceededAmount: "100.00",
}] : [];
state.detailItems = [{
id: targetItemId,
expenseTypeId: "expense-type-r111",
expenseDate: "2026-05-08",
amount: "200.00",
description: "合成恢复目标明细",
expenseType: { ...buildExpenseTypes().items[0] },
}];
state.invoices = [invoice];
state.submitGuardDetails = {
items: "invoice_item_guard_failed",
unlinkedInvoiceCount: 1,
unlinkedInvoiceIds: [invoice.id],
missingInvoiceItemIds: [targetItemId],
amountUnavailableItems: [],
amountExceededItems: [],
};
state.clearSubmitGuardAfterInvoiceLink = true;
await mockApis(page, state);
await login(page);
await page.setViewportSize(options.mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await page.goto(options.mobile
? `/mobile/reimbursements/${reimbursementId}`
: `/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
const confirmDialog = page.getByRole("dialog");
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog).toContainText(
options.decision === "manual_review" ? "需人工复核后继续" : "可继续,但需确认提醒",
);
await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click();
if (options.mobile) {
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 2 项需要处理");
} else {
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
}
await expect(confirmDialog).toHaveCount(0);
await expect(page.locator("div.fixed.inset-0[data-state='open']")).toHaveCount(0);
const firstGuardIssue = page.locator('[data-reimbursement-guard-issue="true"]').first();
await expect(firstGuardIssue).toBeFocused();
const itemSelect = page.getByLabel("选择报销明细");
await itemSelect.selectOption(targetItemId);
await itemSelect.locator("..").getByRole("button", { name: "确认关联", exact: true }).click();
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
invoiceId: invoice.id,
linkedItemId: targetItemId,
}]);
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toHaveCount(0);
await page.getByTestId("reimbursement-submit").click();
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click();
await expect.poll(() => state.submitSuccessCount).toBe(1);
await expect(page.getByTestId("reimbursement-action-error")).toHaveCount(0);
expect(state.submitRequestCount).toBe(2);
}
test.describe("R111 国内报销发票与附件双端体验", () => {
test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => {
const state = buildReimbursementState();
@@ -915,6 +1019,17 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect.poll(() => state.submitRequestCount).toBe(1);
});
for (const decision of ["manual_review", "warn"] as const) {
for (const surface of ["PC", "移动"] as const) {
test(`${surface} ${decision} 确认提交遇 B2 阻断后关闭弹窗并可恢复重提`, async ({ page }) => {
await exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(page, {
decision,
mobile: surface === "移动",
});
});
}
}
test("B2 提交错误在 PC/移动就近呈现,未归属发票可原子选择明细", async ({ page }) => {
const state = buildReimbursementState();
state.invoiceGuardEnabled = true;