CASHV 增加付款批次银行回单附件

这个提交包含在:
2026-05-17 09:35:37 +08:00
父节点 94d2cc8bf9
当前提交 ab80d481cb
共修改 11 个文件,包含 891 行新增和 16 行删除
+53 -1
查看文件
@@ -1,4 +1,4 @@
import { apiRequestData } from "./http";
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
import type { RiskFlag } from "./feeControl";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
@@ -58,6 +58,20 @@ export type PaymentBatch = {
items: PaymentBatchItem[];
};
export type PaymentBatchReceiptAttachment = {
id: string;
originalName: string;
mimeType: string;
fileSize: number;
fileSha256: string;
uploadedBy: {
id: string;
employeeNo: string;
name: string;
} | null;
createdAt: string | null;
};
export type PaymentBatchListResponse = { items: PaymentBatch[] };
export async function fetchPaymentBatches(query: { entityType?: PaymentBatchEntityType; status?: PaymentBatchStatus } = {}) {
@@ -120,3 +134,41 @@ export async function cancelPaymentBatch(batchId: string) {
body: JSON.stringify({}),
});
}
export async function fetchPaymentBatchReceiptAttachments(batchId: string) {
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
`/api/payment-batches/${batchId}/receipt-attachments`,
);
}
export async function uploadPaymentBatchReceiptAttachments(batchId: string, files: File[]) {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
`/api/payment-batches/${batchId}/receipt-attachments`,
{
method: "POST",
body: formData,
},
);
}
export function paymentBatchReceiptAttachmentFileUrl(batchId: string, attachmentId: string) {
return `/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/file`;
}
export async function downloadPaymentBatchReceiptAttachment(
batchId: string,
attachmentId: string,
): Promise<ApiBlobResponse> {
return apiRequestBlob(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId));
}
export async function deletePaymentBatchReceiptAttachment(batchId: string, attachmentId: string) {
return apiRequestData<{ attachmentId: string; outcome: "deleted" }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}`,
{ method: "DELETE" },
);
}
+189 -5
查看文件
@@ -1,14 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
deletePaymentBatchReceiptAttachment,
downloadPaymentBatchReceiptAttachment,
executePaymentBatch,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
type PaymentBatch,
type PaymentBatchReceiptAttachment,
type PaymentBatchEntityType,
type PaymentChannel,
} from "../api/paymentBatches";
@@ -22,6 +27,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { downloadBlob } from "../lib/download";
import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
@@ -41,6 +47,18 @@ function entityTypeLabel(value: string) {
return value || "-";
}
function formatFileSize(value: number) {
if (!Number.isFinite(value) || value <= 0) return "-";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function formatDateTime(value: string | null | undefined) {
if (!value) return "-";
return new Date(value).toLocaleString();
}
function resolveError(error: unknown) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
@@ -61,6 +79,8 @@ export function PaymentBatchesPage() {
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
const [voucherPrefix, setVoucherPrefix] = useState("");
const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
const [error, setError] = useState("");
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment" | "cancel";
@@ -79,6 +99,12 @@ export function PaymentBatchesPage() {
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
}, [listQuery.data?.items, selectedBatchId]);
const receiptAttachmentsQuery = useQuery({
queryKey: ["payment-batch-receipts", selectedBatch?.id],
enabled: Boolean(selectedBatch?.id),
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
});
useEffect(() => {
if (!selectedBatch) {
return;
@@ -91,7 +117,14 @@ export function PaymentBatchesPage() {
setSearchParams(next, { replace: true });
}, [searchParams, selectedBatch, selectedBatchId, setSearchParams]);
const canEditSelectedBatch = canManageBatch && selectedBatch?.status === "draft";
const canEditSelectedBatch =
canManageBatch &&
!!selectedBatch &&
["draft", "reviewed", "failed"].includes(selectedBatch.status);
const canEditReceiptAttachments =
canManageBatch &&
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
@@ -111,6 +144,7 @@ export function PaymentBatchesPage() {
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-receipts"] });
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
};
@@ -204,6 +238,69 @@ export function PaymentBatchesPage() {
},
});
const uploadReceiptMutation = useMutation({
mutationFn: async () => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
if (!selectedReceiptFiles.length) {
throw new Error("请选择银行回单附件");
}
return uploadPaymentBatchReceiptAttachments(selectedBatch.id, selectedReceiptFiles);
},
onSuccess: async () => {
setError("");
setSelectedReceiptFiles([]);
if (receiptInputRef.current) {
receiptInputRef.current.value = "";
}
await refresh();
toast.success("银行回单附件已上传");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const downloadReceiptMutation = useMutation({
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
return { response, attachment };
},
onSuccess: ({ response, attachment }) => {
downloadBlob(response.blob, response.filename ?? attachment.originalName);
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const deleteReceiptMutation = useMutation({
mutationFn: async (attachmentId: string) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
return deletePaymentBatchReceiptAttachment(selectedBatch.id, attachmentId);
},
onSuccess: async () => {
setError("");
await refresh();
toast.success("银行回单附件已删除");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
return (
<ListWorkspace>
<WorkspaceHeader title="支付批次" subtitle="财务批量付款、复核与执行;高风险批次达到 5 万元后需完成二次复核。" />
@@ -390,6 +487,93 @@ export function PaymentBatchesPage() {
</div>
</div>
<div className="card-std space-y-4 p-4 md:p-5">
<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">先在网银完成对公付款,再上传银行回单并执行 OA 已付款标记。</p>
</div>
{canManageBatch ? (
<div className="flex flex-wrap items-center gap-2">
<input
ref={receiptInputRef}
type="file"
className="hidden"
accept=".pdf,image/jpeg,image/png,image/webp"
multiple
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending}
onChange={(event) => setSelectedReceiptFiles(Array.from(event.target.files ?? []))}
/>
<Button
type="button"
variant="secondary"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
选择回单
</Button>
<Button
type="button"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending || selectedReceiptFiles.length === 0}
onClick={() => uploadReceiptMutation.mutate()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传"}
</Button>
</div>
) : null}
</div>
{selectedReceiptFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
待上传:{selectedReceiptFiles.map((file) => file.name).join("、")}
</div>
) : null}
{receiptAttachmentsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(receiptAttachmentsQuery.error)}
</div>
) : null}
<div className="space-y-2">
{(receiptAttachmentsQuery.data?.items ?? []).map((attachment) => (
<div key={attachment.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDateTime(attachment.createdAt)}
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="secondary"
disabled={downloadReceiptMutation.isPending}
onClick={() => downloadReceiptMutation.mutate(attachment)}
>
下载
</Button>
{canManageBatch ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditReceiptAttachments || deleteReceiptMutation.isPending}
onClick={() => deleteReceiptMutation.mutate(attachment.id)}
>
删除
</Button>
) : null}
</div>
</div>
))}
{!receiptAttachmentsQuery.isLoading && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单附件;默认银行渠道执行前需要上传至少一份回单。
</div>
) : null}
</div>
</div>
<div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
<div className="space-y-4">
@@ -406,19 +590,19 @@ export function PaymentBatchesPage() {
<Input
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号"
disabled={!canEditSelectedBatch || item.status !== "pending"}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<Input
type="date"
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || item.status !== "pending"}
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 || item.status !== "pending"}
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>
@@ -145,6 +145,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
await createCard.getByPlaceholder("用逗号或换行分隔 reimbursement / travel_order ID").fill(reimbursementId);
await createCard.locator('input[type="date"]').fill(paymentDate);
await createCard.locator("select").nth(1).selectOption("bank");
await createCard.getByPlaceholder("例如 PBX").fill("R78PB");
await createCard.getByRole("button", { name: "创建批次" }).click();
@@ -181,6 +182,23 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
const executedBatchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last();
await expect(executedBatchDetailCard).toBeVisible();
await expect(executedBatchDetailCard).toContainText("复核进度:1/1");
const receiptCard = page.locator(".card-std").filter({ hasText: "银行回单附件" }).first();
await expect(receiptCard).toBeVisible();
await receiptCard.locator('input[type="file"]').setInputFiles({
name: "r78-bank-receipt.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
response.ok();
});
await receiptCard.getByRole("button", { name: "上传" }).click();
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
const executeBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
@@ -213,6 +231,16 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
paymentAudit.items.some((item) => item.targetId === reimbursementId && item.extra?.idempotent === false),
"付款动作应写入报销付款审计",
).toBe(true);
const receiptAudit = await apiGetData<{
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
}>(
adminApi.api,
"/api/audit-logs?action=PAYMENT_BATCH_RECEIPT_UPLOADED&page=1&pageSize=20",
);
expect(
receiptAudit.items.some((item) => item.targetId === batchId && Array.isArray(item.extra?.attachmentIds)),
"银行回单上传应写入付款批次审计",
).toBe(true);
await page.goto("/ap-operations");
await expect(page.getByRole("tab", { name: /财务台账/ })).toBeVisible();