收紧付款复核资料与阶段编辑(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 15:15:01 +08:00
父节点 9afebdb44d
当前提交 185d64dcd6
共修改 14 个文件,包含 777 行新增和 121 行删除
+15 -4
查看文件
@@ -1402,10 +1402,21 @@ export function ApOperationsPage() {
</div>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-700">快照</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-950/95 p-4 text-xs text-zinc-100">
{prettyJson(failedDetailItem.snapshotJson)}
</pre>
<div className="mb-2 text-sm font-medium text-zinc-700">付款信息</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">收款人</div>
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.payeeName ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">开户银行</div>
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.bankName ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">银行账户</div>
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.bankAccountMasked ?? "-"}</div>
</div>
</div>
</div>
</div>
) : null}
+90 -38
查看文件
@@ -7,13 +7,13 @@ import {
downloadPaymentBatchReceiptAttachment,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment,
type PaymentBatch,
type PaymentBatchReceiptAttachment,
type PaymentChannel,
} from "../api/paymentBatches";
@@ -102,6 +102,7 @@ export function PaymentBatchesPage() {
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState("");
const [showCreatePanel, setShowCreatePanel] = useState(false);
@@ -131,9 +132,12 @@ export function PaymentBatchesPage() {
const selectedBatchId = searchParams.get("id") ?? "";
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const selectedBatch = useMemo<PaymentBatch | null>(() => {
return batches.find((item) => item.id === selectedBatchId) ?? null;
}, [batches, selectedBatchId]);
const selectedBatchQuery = useQuery({
queryKey: ["payment-batches", "detail", selectedBatchId],
enabled: Boolean(selectedBatchId),
queryFn: () => fetchPaymentBatchDetail(selectedBatchId),
});
const selectedBatch = selectedBatchQuery.data?.item ?? null;
const receiptAttachmentsQuery = useQuery({
queryKey: ["payment-batch-receipts", selectedBatch?.id],
enabled: Boolean(selectedBatch?.id),
@@ -143,11 +147,11 @@ export function PaymentBatchesPage() {
const canEditSelectedBatch =
canManageBatch &&
!!selectedBatch &&
["draft", "reviewed", "failed"].includes(selectedBatch.status);
["reviewed", "failed"].includes(selectedBatch.status);
const canEditReceiptAttachments =
canManageBatch &&
!!selectedBatch &&
selectedBatch.status !== "cancelled";
["reviewed", "failed"].includes(selectedBatch.status);
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch =
canReviewBatch &&
@@ -194,11 +198,13 @@ export function PaymentBatchesPage() {
createPaymentBatch({
entityType: "reimbursement",
entityIds: selectedCandidateIds,
defaultPaymentChannel: selectedPaymentChannel,
}),
onSuccess: async ({ item }) => {
setError("");
setSelectedCandidateIds([]);
setCandidateSearch("");
setSelectedPaymentChannel("bank");
const next = new URLSearchParams(searchParams);
next.set("id", item.id);
setSearchParams(next, { replace: true });
@@ -214,7 +220,9 @@ export function PaymentBatchesPage() {
const reviewMutation = useMutation({
mutationFn: async (batchId: string) => {
const batch = listQuery.data?.items.find((item) => item.id === batchId);
const batch = selectedBatch?.id === batchId
? selectedBatch
: listQuery.data?.items.find((item) => item.id === batchId);
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
},
@@ -261,11 +269,10 @@ export function PaymentBatchesPage() {
});
const updateItemMutation = useMutation({
mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; paymentChannelOverride?: PaymentChannel | null }) =>
mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null }) =>
updatePaymentBatchItem(payload.batchId, payload.itemId, {
paymentVoucherNoOverride: payload.paymentVoucherNoOverride,
paymentDateOverride: payload.paymentDateOverride,
paymentChannelOverride: payload.paymentChannelOverride,
}),
onSuccess: async () => {
setError("");
@@ -430,8 +437,23 @@ export function PaymentBatchesPage() {
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
) : null}
</div>
<div className="max-w-sm space-y-2">
<Label htmlFor="payment-batch-default-channel">付款渠道</Label>
<select
id="payment-batch-default-channel"
data-testid="payment-batch-default-channel"
className="select-std w-full"
value={selectedPaymentChannel}
disabled={!canManageBatch}
onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)}
>
{PAYMENT_CHANNEL_OPTIONS.map((option) => (
<option key={option} value={option}>{paymentChannelLabel(option)}</option>
))}
</select>
</div>
<div className="text-xs text-zinc-500">
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
</div>
</div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
@@ -441,7 +463,22 @@ export function PaymentBatchesPage() {
) : null}
{selectedBatchId ? (
selectedBatch ? (
selectedBatchQuery.isLoading ? (
<div className="finance-command-panel text-sm text-zinc-500">正在加载付款批次...</div>
) : selectedBatchQuery.isError ? (
<div className="finance-command-panel flex flex-wrap items-center justify-between gap-3 text-sm text-red-600">
<span>{resolveError(selectedBatchQuery.error)}</span>
<Button
type="button"
size="sm"
variant="secondary"
disabled={selectedBatchQuery.isFetching}
onClick={() => void selectedBatchQuery.refetch()}
>
{selectedBatchQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</div>
) : selectedBatch ? (
<div className="space-y-5">
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -586,31 +623,42 @@ export function PaymentBatchesPage() {
</div>
<div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-4">
<Input
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号"
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<DateTextInput
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
/>
<select
className="select-std"
defaultValue={item.paymentChannelOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}
>
<option value="">默认渠道</option>
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{paymentChannelLabel(option)}</option>)}
</select>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
</div>
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
</div>
{["reviewed", "failed"].includes(selectedBatch.status) ? (
<div className="mt-3 grid gap-3 md:grid-cols-3" data-testid="payment-batch-item-payment-fields">
<Input
aria-label={`付款凭证号 ${item.entityCode}`}
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="付款凭证号"
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<DateTextInput
aria-label={`实际付款日期 ${item.entityCode}`}
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
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}` : "付款异常:无"}
</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>
) : (
<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="关联开放风险" />
</div>
))}
@@ -621,7 +669,11 @@ export function PaymentBatchesPage() {
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">银行回单</h3>
<p className="mt-1 text-sm text-zinc-500">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p>
<p className="mt-1 text-sm text-zinc-500">
{selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
</p>
</div>
{canUploadReceipt ? (
<div className="flex flex-wrap items-center gap-2">
@@ -686,12 +738,12 @@ export function PaymentBatchesPage() {
>
下载
</Button>
{canManageBatch ? (
{canEditReceiptAttachments ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={selectedBatch.status === "cancelled" || Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
disabled={Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({