CASHV 增加付款批次银行回单附件
这个提交包含在:
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户