feat: 完成错误原因合同与付款就绪提示(09-05-error-explainability-payment-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-05 19:00:35 +08:00
父节点 ab82fe30c0
当前提交 a837912638
共修改 42 个文件,包含 2867 行新增和 876 行删除
@@ -35,6 +35,8 @@ type SyntheticContract = {
};
type MockScenario = {
readiness?: { ready: boolean; blockers: Array<{ reasonCode: string }> };
createFailure?: string;
paymentCandidates?: Array<{
entityId: string;
entityType: "reimbursement";
@@ -44,6 +46,7 @@ type MockScenario = {
currencyCode: "CNY";
businessDate: string | null;
summary: string | null;
readiness?: { ready: boolean; blockers: Array<{ reasonCode: string }> };
}>;
paymentCandidateTotal?: number;
onCandidateQuery?: (query: URLSearchParams) => void;
@@ -267,7 +270,7 @@ async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: Mo
return;
}
if (pathName === "/api/payment-batches/candidates") {
const items = scenario.paymentCandidates ?? [];
const items = (scenario.paymentCandidates ?? []).map((item) => ({ ...item, readiness: item.readiness ?? { ready: true, blockers: [] } }));
scenario.onCandidateQuery?.(requestUrl.searchParams);
await fulfillJson(route, {
items,
@@ -277,8 +280,16 @@ async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: Mo
});
return;
}
if (pathName === "/api/payment-batches/readiness") {
await fulfillJson(route, scenario.readiness ?? { ready: true, blockers: [] });
return;
}
if (pathName === "/api/payment-batches" && request.method() === "POST") {
scenario.onCreatePaymentBatch?.(request.postDataJSON());
if (scenario.createFailure) {
await route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ requestId: "synthetic-readiness-race", error: { code: "VALID_001", message: "internal payload", details: { reasonCode: scenario.createFailure, private: "/private/hidden" } } }) });
return;
}
await fulfillJson(route, { item: { id: CREATED_PAYMENT_BATCH_ID } });
return;
}
@@ -404,6 +415,140 @@ test("无票据上传权限的兼容角色直达票据页时失败关闭", async
expectNoPageErrors(pageErrors);
});
for (const [reasonCode, message] of [
["payment_batch.legal_entity_required", "报销人尚未维护所属公司,请联系管理员补充后再安排付款。"],
["payment_batch.payee_bank_required", "收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。"],
["payment_batch.payee_account_required", "收款人尚未维护银行卡,请联系管理员补充真实收款资料。"],
["payment_batch.payee_bank_unsupported", "当前银行付款仅支持建行收款账户,请停止本次银行付款并联系财务确认处理方式,不要修改真实开户行。"],
["payment_batch.active_batch_exists", "所选单据已进入其他付款安排,请刷新列表并查看已有批次。"],
["payment_batch.reviewer_required", "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。"],
]) {
test(`付款候选就近解释并禁用 ${reasonCode}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode }] } }] });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const row = page.getByTestId("payment-batch-candidate-row");
await expect(row.getByText(message, { exact: true })).toBeVisible();
await expect(row.getByRole("checkbox")).toBeDisabled();
await expect(row.getByRole("checkbox")).toHaveAccessibleDescription(message);
await expect(row.getByRole("link")).toHaveCount(0);
});
}
test("跨公司组合原因紧邻禁用主动作并可被读屏读取", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
let creates = 0;
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.multiple_legal_entities" }] }, onCreatePaymentBatch: () => { creates += 1; } });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const checkbox = page.getByTestId("payment-batch-candidate-row").getByRole("checkbox");
await checkbox.focus();
await page.keyboard.press("Space");
await expect(checkbox).toBeChecked();
const button = page.getByRole("button", { name: "创建并提交复核", exact: true });
await expect(button).toBeDisabled();
await expect(button).toHaveAccessibleDescription("所选单据属于不同公司,请按公司分别创建付款安排。");
await expect(page.locator("#payment-creation-reason")).toHaveAttribute("role", "alert");
expect(creates).toBe(0);
});
test("可先换渠道再选择,已选项失效后仍可取消", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
const scenario: MockScenario = { paymentCandidates: [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] } }] };
await mockSyntheticApis(page, FINANCE_ACTOR, scenario);
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const checkbox = page.getByTestId("payment-batch-candidate-row").getByRole("checkbox");
await expect(checkbox).toBeDisabled();
const channel = page.getByTestId("payment-batch-default-channel");
await expect(channel).toBeVisible();
scenario.paymentCandidates = [{ ...PAYMENT_CANDIDATE, readiness: { ready: true, blockers: [] } }];
await channel.selectOption("cash");
await expect(checkbox).toBeEnabled();
await checkbox.check();
scenario.paymentCandidates = [{ ...PAYMENT_CANDIDATE, readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] } }];
scenario.readiness = { ready: false, blockers: [{ reasonCode: "payment_batch.payee_bank_required" }] };
await channel.selectOption("bank");
await expect(page.getByTestId("payment-batch-candidate-row")).toContainText("收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。");
await expect(checkbox).toBeChecked();
await expect(checkbox).toBeEnabled();
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
await expect(page.getByRole("button", { name: "创建并提交复核", exact: true })).toHaveCount(0);
});
for (const [reasonCode, message] of [
["payment_batch.active_batch_exists", "所选单据已进入其他付款安排,请刷新列表并查看已有批次。"],
["unknown.synthetic", "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。 排障编号:synthetic-readiness-race"],
]) {
test(`创建并发失败留在动作旁 ${reasonCode}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], createFailure: reasonCode });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
await page.getByTestId("payment-batch-candidate-row").getByRole("checkbox").check();
if (reasonCode === "unknown.synthetic") await page.setViewportSize({ width: 375, height: 812 });
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page.locator("#payment-creation-reason")).toHaveText(message);
await expect(page.getByRole("button", { name: "创建并提交复核", exact: true })).toHaveAccessibleDescription(message);
await expect(page.getByText("internal payload", { exact: false })).toHaveCount(0);
await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0);
});
}
test("完整银行候选没有解释噪音并可提交复核", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
let created: unknown = null;
await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], onCreatePaymentBatch: (payload) => { created = payload; } });
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const row = page.getByTestId("payment-batch-candidate-row");
await expect(row.locator('[id^="payment-candidate-reason-"]')).toHaveCount(0);
await row.getByRole("checkbox").check();
const button = page.getByRole("button", { name: "创建并提交复核", exact: true });
await expect(button).toBeEnabled();
await expect(page.locator("#payment-creation-reason")).toHaveCount(0);
await button.click();
await expect(page).toHaveURL(new RegExp(`id=${CREATED_PAYMENT_BATCH_ID}`));
expect(created).toEqual({ entityType: "reimbursement", entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], defaultPaymentChannel: "bank" });
});
test("付款失败摘要与历史风险不向 PC 或移动视口暴露技术文案", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据");
await mockSyntheticApis(page, FINANCE_ACTOR);
const payload: Record<string, unknown> = { batchId: CREATED_PAYMENT_BATCH_ID, itemId: "failed-item", reasonCode: "payment_batch.source_status_invalid" };
const detail = {
id: CREATED_PAYMENT_BATCH_ID, batchNo: "PB-SYN-FAIL", name: "合成失败批次", status: "failed",
entityType: "reimbursement", totalAmount: "1280.50", highRisk: false,
defaultPaymentDate: null, defaultPaymentChannel: "cash", createdBy: FINANCE_ACTOR.id,
paymentAccount: null, paymentOrderExportCount: 0, canExportPaymentOrder: false,
paymentOrderExportBlockers: [], processingProgress: { stages: [] },
reviewRequirement: { completedReviewCount: 1, requiredReviewCount: 1, nextStep: "ready_for_execute" },
items: [{
id: "failed-item", entityId: PAYMENT_CANDIDATE_ENTITY_ID, entityCode: PAYMENT_CANDIDATE.businessNo,
entityTitle: "合成失败明细", amount: "1280.50", currencyCode: "CNY", status: "failed",
paymentChannelOverride: null, paymentDateOverride: null, paymentVoucherNoOverride: null,
failureCode: "VALID_001", failureMessage: "参数校验失败 /private/hidden", expenseItems: [], expenseItemsTotal: "0.00",
openRiskFlags: [{ id: "failure-risk", riskCode: "payment_batch_execute_failed", title: "付款登记失败", level: "high", detail: "参数校验失败 /private/hidden", source: "payment_batch", status: "open", payloadJson: payload }],
}],
};
await page.route(`**/api/payment-batches/${CREATED_PAYMENT_BATCH_ID}`, (route) => fulfillJson(route, { item: detail }));
await page.route(`**/api/payment-batches/${CREATED_PAYMENT_BATCH_ID}/receipt-attachments`, (route) => fulfillJson(route, { items: [] }));
await login(page, FINANCE_ACTOR);
await page.goto(`/payment-batches?id=${CREATED_PAYMENT_BATCH_ID}`);
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("失败原因:来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。");
await expect(page.getByText("参数校验失败", { exact: false })).toHaveCount(0);
await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0);
delete payload.reasonCode;
await page.reload();
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("失败原因:操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。");
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.getByTestId("payment-batch-items-panel")).toBeVisible();
await expect(page.getByText("参数校验失败", { exact: false })).toHaveCount(0);
await expect(page.getByText("/private/hidden", { exact: false })).toHaveCount(0);
});
test("财务从业务候选选择报销并使用所选实体创建付款批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
@@ -81,7 +81,7 @@ async function fulfillApiError(route: Route, status: number, details: Record<str
error: {
code: "VALID_001",
message: "参数校验失败",
details,
details: { ...details, reasonCode: details.verification === "too_frequent" ? "invoice_verification.too_frequent" : "invoice_verification.attempts_exceeded" },
},
}),
});
@@ -1227,8 +1227,9 @@ test("PC 票据辅助核验错误提示可理解", async ({ page }) => {
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
await page.getByTestId("invoice-trigger-verification").click();
await expect(page.getByRole("main").getByText("VALID_001: 验真触发过于频繁,请稍后重试。")).toBeVisible();
await expect(page.getByRole("main").getByText("验真触发过于频繁,请稍后重试。", { exact: true })).toBeVisible();
await page.getByTestId("invoice-retry-verification").click();
await expect(page.getByRole("main").getByText("VALID_001: 验真重试次数已达上限,请联系财务人工核验。")).toBeVisible();
await expect(page.getByRole("main").getByText("验真重试次数已达上限,请联系财务人工核验。", { exact: true })).toBeVisible();
await expect(page.getByRole("main").getByText("VALID_001:", { exact: false })).toHaveCount(0);
});
@@ -542,7 +542,7 @@ async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) {
error: {
code: "VALID_001",
message: "参数校验失败",
details: { file: "unsupported_type" },
details: { file: "unsupported_type", reasonCode: "purchase_attachment.unsupported_type" },
},
}),
});
@@ -446,7 +446,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
error: {
code: "VALID_001",
message: "参数校验失败",
details: state.submitGuardDetails,
details: { ...state.submitGuardDetails, reasonCode: "reimbursement.items_invoice_item_guard_failed" },
},
}),
});
@@ -479,7 +479,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
error: {
code: "VALID_001",
message: "参数校验失败",
details: state.invoiceItemLinkError,
details: { ...state.invoiceItemLinkError, reasonCode: "reimbursement.invoice_invalid_unlinked_item_state" },
},
}),
});