feat: 完成错误原因合同与付款就绪提示(09-05-error-explainability-payment-readiness)
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户