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 行删除
+9 -2
查看文件
@@ -1,4 +1,5 @@
import { formatInvoiceOcrFieldLabels } from "../lib/invoiceOcr";
import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "./errorReasons";
function readStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
@@ -71,7 +72,13 @@ export function mapApiErrorMessage(
code: string,
details?: Record<string, unknown>,
fallbackMessage?: string,
) {
): string {
if (code === "VALID_001" && details?.reasonCode === "reimbursement.items_invoice_item_guard_failed") {
return mapApiErrorMessage(code, { ...details, reasonCode: undefined, items: "invoice_item_guard_failed" });
}
if (code === "VALID_001" && details?.reasonCode !== undefined) {
return resolveErrorReasonMessage(details.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE;
}
if (code === "AUTH_001") {
return "账号或密码错误,请检查后重试。";
}
@@ -512,5 +519,5 @@ export function mapApiErrorMessage(
}
}
return fallbackMessage || "请求失败,请稍后重试。";
return code === "VALID_001" ? UNKNOWN_VALIDATION_MESSAGE : fallbackMessage || "请求失败,请稍后重试。";
}
+152
查看文件
@@ -0,0 +1,152 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
"reimbursement.invoice_owner_mismatch": "票据所属人与报销人不一致,请选择本人对应的票据。",
"reimbursement.invoice_item_required": "请为该票据选择对应的报销明细后再添加。",
"reimbursement.travel_invoice_bind_status_invalid": "差旅订单当前不支持添加票据,请核实订单状态。",
"invoice_verification.too_frequent": "验真触发过于频繁,请稍后重试。",
"invoice_verification.attempts_exceeded": "验真重试次数已达上限,请联系财务人工核验。",
"reimbursement.invalid_input": "报销内容不完整或格式不正确,请核对页面必填项和日期后重试。",
"reimbursement.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。",
"reimbursement.body_empty": "尚未填写修改内容,请补充后再保存。",
"reimbursement.date_to_must_be_after_date_from": "结束日期不能早于开始日期,请调整查询日期范围。",
"reimbursement.amount_must_be_positive": "明细金额必须大于 0。",
"reimbursement.template": "模板中的明细格式已不适用,请联系管理员修正模板后重试。",
"reimbursement.owner_employee_id_invalid_owner": "所选报销人不可用,请重新选择有效员工。",
"reimbursement.total_amount_invalid": "报销合计金额不正确,请核对各项明细金额。",
"reimbursement.total_amount_negative_not_allowed": "报销合计不能为负数,请核对各项明细金额。",
"reimbursement.workflow_missing_approval_rule": "尚未配置适用的报销审批规则,请联系管理员维护后重试。",
"reimbursement.precheck": "报销提交检查未通过,请按页面检查清单逐项处理后重试。",
"reimbursement.template_id_invalid_template": "所选报销模板已失效,请重新选择有效模板。",
"reimbursement.template_id_contains_disabled_expense_type": "模板包含已停用的费用类型,请联系管理员修正模板后重试。",
"reimbursement.linked_travel_request_id_not_found": "所选出差申请已不存在,请刷新后重新选择。",
"reimbursement.linked_travel_request_id_not_approved": "所选出差申请尚未获批,请先完成出差审批。",
"reimbursement.linked_travel_request_id_owner_mismatch": "出差申请与报销人不一致,请选择本人对应的出差申请。",
"reimbursement.linked_travel_request_id_already_linked": "该出差申请已关联其他报销单,请核对已有报销记录。",
"reimbursement.linked_travel_request_id_missing_expense_type_mapping": "该出差项目尚未配置对应费用类型,请联系管理员维护后重试。",
"reimbursement.status_invalid_edit_status": "当前报销单状态不允许编辑,请刷新并确认审批进度。",
"reimbursement.travel_end_date_invalid_date_range": "出差结束日期不能早于开始日期,请调整日期。",
"reimbursement.status_invalid_delete_status": "当前报销单状态不允许删除,请核实审批和付款进度。",
"reimbursement.status_invalid_attachment_edit_status": "当前报销单不能修改附件,请刷新并确认单据状态。",
"reimbursement.file_unsupported_mime_type": "附件类型不支持,请选择允许的图片或文档格式。",
"reimbursement.file_file_too_large": "附件超过大小限制,请压缩或更换文件后重试。",
"reimbursement.file_empty_file": "所选附件为空,请重新选择有效文件。",
"reimbursement.file_too_many_files": "一次最多上传 5 个附件,请减少数量后重试。",
"reimbursement.file_missing": "请先选择需要上传的附件。",
"reimbursement.reason_required": "读取该材料需要填写用途,请补充访问原因。",
"reimbursement.status_invalid_item_edit_status": "当前报销单不能修改明细,请刷新并确认单据状态。",
"reimbursement.expense_type_id_invalid_or_disabled": "所选费用类型已失效或停用,请重新选择。",
"reimbursement.reimbursement_invalid_status_for_invoice_item_link": "当前报销单状态已变化,不能再关联发票,请刷新页面。",
"reimbursement.invoice_invalid_unlinked_item_state": "这张发票的关联状态已变化,请刷新后重试。",
"reimbursement.linked_item_id_item_not_in_reimbursement": "所选明细已不存在或不属于本单,请刷新后重新选择。",
"reimbursement.status_invalid_submit_status": "当前报销单状态不允许提交,请刷新并确认是否已提交。",
"reimbursement.items_at_least_one_required": "请先添加至少 1 条报销明细。",
"reimbursement.items_positive_amount_required": "所有报销明细金额都必须大于 0,请修改后再提交。",
"reimbursement.items_invoice_item_guard_failed": "票据检查未通过,请核对每条明细与发票的关联和金额后重试。",
"reimbursement.travel_summary_incomplete": "出差汇总信息尚未填写完整,请补充页面提示的出差信息后提交。",
"reimbursement.submit_already_in_review": "该报销单已经在审批中,请勿重复提交,可查看审批进度。",
"reimbursement.invoices_bound_invoice_required": "报销单需要有效发票,请先添加并关联发票后提交。",
"reimbursement.budget_exceeded_block_mode": "本次报销超出可用预算,请调整金额或联系预算管理员处理。",
"reimbursement.workflow_missing_definition": "尚未配置可用报销审批流程,请联系管理员维护后重试。",
"reimbursement.workflow_no_actionable_approver": "当前没有可处理该报销的审批人,请联系管理员核实审批配置。",
"reimbursement.status_invalid_withdraw_status": "当前报销单不能撤回,请刷新并核实审批进度。",
"reimbursement.withdraw_no_pending_instance": "当前报销单没有可撤回的审批,请刷新并核实进度。",
"reimbursement.withdraw_already_processed_by_approver": "审批人已处理该报销单,不能撤回,请联系审批人确认后续处理。",
"reimbursement.feature_flag_project_linking_v1_disabled": "项目关联暂未开放,请联系管理员确认处理方式。",
"reimbursement.reimbursement_invalid_status_for_project_link": "当前报销单不能修改关联项目,请刷新并确认单据状态。",
"reimbursement.payment_payment_batch_v1_required": "该报销单须通过付款批次安排付款,请前往付款批次查看或创建付款安排。",
"reimbursement.status_invalid_pay_status": "当前报销单尚不满足付款条件,请核实审批和已有付款记录。",
"reimbursement.payment_already_paid_conflict": "该报销单已有不同的付款记录,请核实原付款,勿重复登记。",
"payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。",
"payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。",
"payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。",
"payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。",
"payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。",
"payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。",
"payment_batch.payee_account_unavailable": "收款账户资料暂不可用,请联系管理员检查后重试。",
"payment_batch.source_status_invalid": "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。",
"payment_batch.already_paid_conflict": "来源单据已有不同的付款记录,请核实已有记录,勿重复付款。",
"payment_batch.source_amount_changed": "来源单据金额已变化,请核对单据后重新安排付款。",
"payment_batch.settled_transaction_required": "尚未找到已结算的交易,请确认结算记录后再登记付款。",
"payment_batch.receipt_security_failed": "付款回单未通过安全检查,请联系管理员核实并重新上传有效回单。",
"payment_batch.receipt_field_invalid": "请通过回单上传入口重新选择文件。",
"payment_batch.receipt_too_large": "回单文件超过大小限制,请压缩后重试。",
"payment_batch.receipt_too_many": "上传回单数量超过限制,请减少文件数量后重试。",
"payment_batch.receipt_upload_limit": "回单上传超过请求限制,请减少文件数量或大小后重试。",
"payment_batch.receipt_file_invalid": "回单文件不符合上传要求,请核对格式、大小和文件内容后重新上传。",
"payment_batch.receipt_required": "请先选择有效的付款回单文件。",
"payment_batch.travel_payment_frozen": "差旅付款组批已暂停,请联系财务确认处理方式。",
"payment_batch.account_reveal_unavailable": "当前批次不支持查看收款账户,请核实批次类型和状态。",
"payment_batch.payee_snapshot_missing": "历史批次缺少收款资料快照,请联系管理员核实,不要据此重复付款。",
"payment_batch.receipt_not_editable": "当前付款状态不允许修改回单,请刷新并确认批次进度。",
"payment_batch.receipt_voided": "该回单已作废,请选择有效回单。",
"payment_batch.download_reason_required": "下载该回单需要填写用途,请补充下载原因。",
"payment_batch.receipt_void_required": "回单不能直接删除,请使用作废操作并说明原因。",
"payment_batch.receipt_already_voided": "该回单已经作废,请刷新列表,勿重复操作。",
"payment_batch.metadata_not_editable": "当前批次状态不允许修改付款信息,请刷新并核实进度。",
"payment_batch.item_not_editable": "只能修改尚未完成的付款明细,请核实该明细状态。",
"payment_batch.batch_not_reviewable": "当前批次状态不允许复核,请刷新并确认是否已处理。",
"payment_batch.items_not_pending": "部分明细已处理,不能复核该批次,请刷新并核实明细状态。",
"payment_batch.maker_required": "该批次缺少制单人信息,请联系管理员核实后再复核。",
"payment_batch.maker_checker_conflict": "制单人不能复核自己的付款批次,请由另一位有权限的经理或负责人复核。",
"payment_batch.risk_resolution_required": "还有未处理的风险提示,请逐项确认并处理后再复核。",
"payment_batch.batch_not_executable": "只有已复核或待重试的批次才能登记付款,请先确认批次状态。",
"payment_batch.review_required": "该批次尚未完成有效复核,请先由独立复核人完成复核。",
"payment_batch.batch_not_cancellable": "该批次当前不能取消,请核实是否已有付款结果并联系财务处理。",
"leave.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。",
"leave.invalid_input": "提交内容不完整或格式不正确,请核对必填项和日期后重试。",
"leave.date_from_invalid": "开始日期不正确,请重新选择。",
"leave.date_to_invalid": "结束日期不正确,请重新选择。",
"leave.date_to_must_be_after_date_from": "结束日期不能早于开始日期,请调整日期范围。",
"leave.department_id_scope_all_only": "当前查询范围不支持指定部门,请调整查询范围。",
"leave.date_invalid": "日期格式不正确,请重新选择日期。",
"leave.file_only_single_file_supported": "每次只能上传一个证明文件,请重新选择。",
"leave.file_file_too_large": "文件超过上传大小限制,请压缩或更换文件后重试。",
"leave.leave_type_id_missing_active_policy": "当前假种尚未配置有效策略,请联系人事维护后重试。",
"leave.file_required": "该假种需要证明材料,请上传后再提交。",
"leave.file_unsupported_mime_type": "文件类型不支持,请选择允许的证明材料格式。",
"leave.status_invalid_edit_status": "当前单据状态不允许编辑,请刷新并确认审批进度。",
"leave.status_invalid_submit_status": "当前单据状态不允许提交,请刷新并确认是否已提交。",
"leave.submit_already_in_review": "该申请已经在审批中,请勿重复提交,可查看审批进度。",
"leave.leave_conflict_range": "该时段已有请假申请,请调整日期或先处理已有申请。",
"leave.leave_insufficient_balance": "可用假期余额不足,请调整天数或联系人事核实。",
"leave.workflow_missing_definition": "尚未配置可用审批流程,请联系管理员维护后重试。",
"leave.workflow_no_actionable_approver": "当前没有可处理该申请的审批人,请联系管理员核实审批配置。",
"leave.status_invalid_withdraw_status": "当前单据状态不允许撤回,请刷新并确认审批进度。",
"leave.withdraw_no_pending_instance": "当前没有可撤回的审批,请刷新并确认审批进度。",
"leave.withdraw_already_processed_by_approver": "审批人已处理该申请,不能再撤回,请联系审批人确认后续处理。",
"leave.session_invalid": "请选择有效的上午或下午时段。",
"leave.leave_invalid_range": "请假结束时间早于开始时间,请调整请假时段。",
"leave.leave_non_working_range": "所选时段没有可请假的工作日,请调整请假日期。",
"leave.leave_missing_reserve_ledger": "请假额度预留记录缺失,请联系人事核实后处理。",
"leave.file_empty": "所选文件为空,请重新选择有效文件。",
"attendance_correction.invalid_input": "提交内容不完整或格式不正确,请核对必填项和日期后重试。",
"attendance_correction.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。",
"attendance_correction.datetime_invalid": "时间格式不正确,请重新选择发生时间。",
"attendance_correction.feature_flag_attendance_unified_v1_disabled": "考勤修正暂未开放,请联系管理员确认处理方式。",
"attendance_correction.workflow_missing_definition": "尚未配置可用审批流程,请联系管理员维护后重试。",
"attendance_correction.workflow_no_actionable_approver": "当前没有可处理该申请的审批人,请联系管理员核实审批配置。",
"attendance_correction.status_invalid_cancel_status": "当前申请已不能取消,请刷新并确认审批结果。",
"payment_batch.legal_entity_required": "报销人尚未维护所属公司,请联系管理员补充后再安排付款。",
"payment_batch.multiple_legal_entities": "所选单据属于不同公司,请按公司分别创建付款安排。",
"payment_batch.payee_bank_required": "收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。",
"payment_batch.payee_account_required": "收款人尚未维护银行卡,请联系管理员补充真实收款资料。",
"payment_batch.payee_bank_unsupported": "当前银行付款仅支持建行收款账户,请停止本次银行付款并联系财务确认处理方式,不要修改真实开户行。",
"payment_batch.payment_account_required": "所属公司尚无可用的建行付款账户,请联系账户管理员维护后重试。",
"payment_batch.payment_account_unavailable": "公司付款账户暂不可用,请联系管理员检查账户配置后重试。",
"payment_batch.active_batch_exists": "所选单据已进入其他付款安排,请刷新列表并查看已有批次。",
"payment_batch.reviewer_required": "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。",
};
export const UNKNOWN_VALIDATION_MESSAGE = "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。";
export function resolveErrorReasonMessage(reasonCode: unknown): string | undefined {
return typeof reasonCode === "string" && Object.hasOwn(ERROR_REASON_MESSAGES, reasonCode)
? ERROR_REASON_MESSAGES[reasonCode]
: undefined;
}
+19 -1
查看文件
@@ -89,7 +89,13 @@ export type PaymentBatchDetail = PaymentBatch & {
processingProgress: ProcessingProgress;
};
export type PaymentReadiness = {
ready: boolean;
blockers: Array<{ reasonCode: string; entityId?: string; entityCode?: string }>;
};
export type PaymentBatchCandidate = {
readiness: PaymentReadiness;
entityId: string;
entityType: "reimbursement";
businessNo: string;
@@ -140,15 +146,27 @@ export async function fetchPaymentBatchDetail(batchId: string) {
return apiRequestData<{ item: PaymentBatchDetail }>(`/api/payment-batches/${batchId}`);
}
export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number } = {}) {
export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number; paymentChannel?: PaymentChannel } = {}) {
const params = new URLSearchParams();
if (query.q?.trim()) params.set("q", query.q.trim());
if (query.page) params.set("page", String(query.page));
if (query.pageSize) params.set("pageSize", String(query.pageSize));
if (query.paymentChannel) params.set("paymentChannel", query.paymentChannel);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiRequestData<PaymentBatchCandidatesResponse>(`/api/payment-batches/candidates${suffix}`);
}
export async function fetchPaymentBatchReadiness(payload: {
entityType: PaymentBatchEntityType;
entityIds: string[];
defaultPaymentChannel: PaymentChannel;
}) {
return apiRequestData<PaymentReadiness>("/api/payment-batches/readiness", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function createPaymentBatch(payload: {
entityType: PaymentBatchEntityType;
name?: string;
+16 -1
查看文件
@@ -1,4 +1,19 @@
import type { PaymentBatchStatus } from "../api/paymentBatches";
import type { PaymentBatchItem, PaymentBatchStatus } from "../api/paymentBatches";
import type { RiskFlag } from "../api/feeControl";
import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "../api/errorReasons";
export function paymentItemFailureMessage(item: Pick<PaymentBatchItem, "id" | "status" | "failureCode" | "failureMessage" | "openRiskFlags">, batchId: string) {
if (item.status !== "failed" && !item.failureCode && !item.failureMessage) return null;
const flag = item.openRiskFlags.find((risk) => risk.riskCode === "payment_batch_execute_failed"
&& risk.payloadJson?.batchId === batchId && risk.payloadJson?.itemId === item.id);
return resolveErrorReasonMessage(flag?.payloadJson?.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE;
}
export function paymentRiskFlagsForDisplay(flags: RiskFlag[]) {
return flags.map((flag) => flag.riskCode === "payment_batch_execute_failed" ? {
...flag, detail: resolveErrorReasonMessage(flag.payloadJson?.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE,
} : flag);
}
const PAYMENT_BATCH_STATUS_LABELS: Record<PaymentBatchStatus, string> = {
draft: "待付款复核",
+9 -1
查看文件
@@ -1,5 +1,6 @@
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { UNKNOWN_VALIDATION_MESSAGE } from "../api/errorReasons";
function toUserSafeMessage(message: string | undefined, fallback: string) {
const normalized = (message ?? "")
@@ -8,9 +9,11 @@ function toUserSafeMessage(message: string | undefined, fallback: string) {
if (!normalized) {
return fallback;
}
if (normalized === "参数校验失败") return UNKNOWN_VALIDATION_MESSAGE;
const exposesTechnicalDetail =
!/[\u3400-\u9fff]/.test(normalized) ||
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i.test(normalized) ||
/https?:\/\/|\/api\/|(?:^|\s)\/[A-Za-z0-9._-]+(?:\/[^\s]*)?|[A-Za-z]:\\|(?:^|\n)\s*at\s|\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/|^(?:\{|\[)/.test(normalized);
return exposesTechnicalDetail ? fallback : normalized;
@@ -21,8 +24,13 @@ export function resolveErrorMessage(
fallback = "操作失败,请稍后重试",
): string {
if (isApiError(error)) {
const message = mapApiErrorMessage(error.code, error.details, error.message);
if (message === UNKNOWN_VALIDATION_MESSAGE) {
const requestId = /^[A-Za-z0-9_-]{1,80}$/.test(error.requestId) ? error.requestId : "";
return requestId ? `${message} 排障编号:${requestId}` : message;
}
return toUserSafeMessage(
mapApiErrorMessage(error.code, error.details, error.message),
message,
fallback,
);
}
+53 -19
查看文件
@@ -1,4 +1,5 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "../api/errorReasons";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
@@ -9,6 +10,7 @@ import {
downloadPaymentOrder,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchReadiness,
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
@@ -62,7 +64,7 @@ import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
import { downloadBlob } from "../lib/download";
import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay";
import { paymentBatchStatusLabel, paymentItemFailureMessage, paymentRiskFlagsForDisplay } from "../lib/paymentBatchDisplay";
import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
@@ -149,6 +151,7 @@ export function PaymentBatchesPage() {
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
const [creationError, setCreationError] = useState("");
const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState("");
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
@@ -211,15 +214,33 @@ export function PaymentBatchesPage() {
queryFn: () => fetchPaymentBatches(),
});
const candidatesQuery = useQuery({
queryKey: ["payment-batch-candidates", candidateSearch, candidatePage, candidatePageSize],
queryKey: ["payment-batch-candidates", candidateSearch, candidatePage, candidatePageSize, selectedPaymentChannel],
enabled: canManageBatch,
queryFn: () => fetchPaymentBatchCandidates({
q: candidateSearch,
page: candidatePage,
pageSize: candidatePageSize,
paymentChannel: selectedPaymentChannel,
}),
});
const creationReadinessQuery = useQuery({
queryKey: ["payment-batch-readiness", [...selectedCandidateIds].sort(), selectedPaymentChannel],
enabled: canManageBatch && selectedCandidateIds.length > 0 && activeView === "pending",
queryFn: () => fetchPaymentBatchReadiness({
entityType: "reimbursement", entityIds: selectedCandidateIds, defaultPaymentChannel: selectedPaymentChannel,
}),
staleTime: 0,
});
const creationBlocker = creationReadinessQuery.isFetching || creationReadinessQuery.isPending
? "正在核对付款条件,请稍候。"
: creationReadinessQuery.isError
? resolveUserErrorMessage(creationReadinessQuery.error)
: creationReadinessQuery.data?.ready !== true
? [...new Set((creationReadinessQuery.data?.blockers ?? []).map((blocker) => resolveErrorReasonMessage(blocker.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE))].join(" ") || UNKNOWN_VALIDATION_MESSAGE
: "";
useEffect(() => { setCreationError(""); }, [selectedCandidateIds, selectedPaymentChannel]);
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const candidateTotal = candidatesQuery.data?.total ?? 0;
const candidateTotalPages = Math.max(1, Math.ceil(candidateTotal / candidatePageSize));
@@ -344,12 +365,15 @@ export function PaymentBatchesPage() {
};
const createMutation = useMutation({
mutationFn: async () =>
createPaymentBatch({
mutationFn: async () => {
if (!selectedCandidateIds.length || creationBlocker) throw new Error(creationBlocker || "请先选择可付款的单据。");
return createPaymentBatch({
entityType: "reimbursement",
entityIds: selectedCandidateIds,
defaultPaymentChannel: selectedPaymentChannel,
}),
});
},
onMutate: () => { setCreationError(""); },
onSuccess: async ({ item }) => {
setError("");
setSelectedCandidateIds([]);
@@ -365,8 +389,10 @@ export function PaymentBatchesPage() {
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
setCreationError(message);
void candidatesQuery.refetch();
void creationReadinessQuery.refetch();
toast.error("未创建付款安排,请查看动作旁的提示。");
},
});
@@ -392,10 +418,11 @@ export function PaymentBatchesPage() {
const executeMutation = useMutation({
mutationFn: executePaymentBatch,
onSuccess: async () => {
onSuccess: async ({ item }) => {
setError("");
await refresh();
toast.success("付款已登记完成");
if (item.status === "failed") toast.error("部分付款登记未完成,请查看明细中的原因后重试。");
else toast.success("付款已登记完成");
},
onError: (err) => {
const message = resolveError(err);
@@ -697,6 +724,9 @@ export function PaymentBatchesPage() {
) : null}
{(candidatesQuery.data?.items ?? []).map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.entityId);
const candidateReady = candidate.readiness?.ready === true;
const blockerText = [...new Set((candidate.readiness?.blockers ?? []).map((blocker) => resolveErrorReasonMessage(blocker.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE))].join(" ") || UNKNOWN_VALIDATION_MESSAGE;
const reasonId = `payment-candidate-reason-${candidate.entityId}`;
return (
<label
key={candidate.entityId}
@@ -707,6 +737,8 @@ export function PaymentBatchesPage() {
type="checkbox"
className="mt-1 h-5 w-5"
checked={selected}
disabled={(!candidateReady && !selected) || candidatesQuery.isFetching || createMutation.isPending}
aria-describedby={!candidateReady ? reasonId : undefined}
onChange={(event) => setSelectedCandidateIds((current) =>
event.target.checked
? Array.from(new Set([...current, candidate.entityId]))
@@ -722,6 +754,7 @@ export function PaymentBatchesPage() {
{formatDisplayDate(candidate.businessDate)}
{candidate.summary ? ` · ${candidate.summary}` : ""}
</span>
{!candidateReady ? <span id={reasonId} className="mt-2 block text-sm text-red-700">{blockerText}</span> : null}
</span>
</label>
);
@@ -739,8 +772,6 @@ export function PaymentBatchesPage() {
</div>
</div>
) : null}
{selectedCandidateIds.length ? (
<>
<div className="max-w-sm space-y-2">
<Label htmlFor="payment-batch-default-channel">付款渠道</Label>
<select
@@ -748,6 +779,7 @@ export function PaymentBatchesPage() {
data-testid="payment-batch-default-channel"
className="select-std w-full"
value={selectedPaymentChannel}
disabled={createMutation.isPending}
onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)}
>
{PAYMENT_CHANNEL_OPTIONS.map((option) => (
@@ -758,13 +790,15 @@ export function PaymentBatchesPage() {
<div className="text-xs text-zinc-500">
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
</div>
</>
) : null}
</div>
{selectedCandidateIds.length ? (
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "提交中..." : "创建并提交复核"}
</Button>
<div className="min-w-0 space-y-2">
{creationError || creationBlocker ? <div id="payment-creation-reason" role="alert" aria-live="polite" className="break-words text-sm text-red-700">{creationError || creationBlocker}</div> : null}
{creationReadinessQuery.isError ? <Button type="button" variant="outline" onClick={() => void creationReadinessQuery.refetch()}>重新检查付款条件</Button> : null}
<Button type="button" disabled={createMutation.isPending || Boolean(creationBlocker)} aria-describedby={creationError || creationBlocker ? "payment-creation-reason" : undefined} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "提交中..." : "创建并提交复核"}
</Button>
</div>
) : null}
</div>
) : null}
@@ -904,21 +938,21 @@ export function PaymentBatchesPage() {
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
/>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
{paymentItemFailureMessage(item, selectedBatch.id) ? `失败原因:${paymentItemFailureMessage(item, selectedBatch.id)}` : "付款异常:无"}
</div>
</div>
) : ["executed", "cancelled"].includes(selectedBatch.status) ? (
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-3" data-testid="payment-batch-item-payment-readonly">
<div className="detail-fact-card"><div className="detail-fact-card__label">付款凭证号</div><div className="detail-fact-card__value">{item.paymentVoucherNoOverride || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实际付款日期</div><div className="detail-fact-card__value">{formatDisplayDate(item.paymentDateOverride ?? selectedBatch.defaultPaymentDate)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款结果</div><div className="detail-fact-card__value">{item.failureMessage || paymentItemStatusLabel(item.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款结果</div><div className="detail-fact-card__value">{paymentItemFailureMessage(item, selectedBatch.id) || paymentItemStatusLabel(item.status)}</div></div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
付款复核通过后,由财务登记付款凭证号和实际付款日期。
</div>
)}
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
<RiskFlagsCard items={paymentRiskFlagsForDisplay(item.openRiskFlags)} title="关联开放风险" />
</div>
))}
</div>
@@ -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" },
},
}),
});