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 行删除
+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>