修复报销确认弹窗阻断恢复(08-30-b2-submit-error-ocr-fallback)
这个提交包含在:
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
在新工单中引用
屏蔽一个用户