From ab80d481cb3a99c33c3f773893d772861913e702 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 17 May 2026 09:35:37 +0800 Subject: [PATCH] =?UTF-8?q?CASHV=20=E5=A2=9E=E5=8A=A0=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E9=93=B6=E8=A1=8C=E5=9B=9E=E5=8D=95=E9=99=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/paymentBatches.ts | 54 +++- client/src/pages/PaymentBatchesPage.tsx | 194 ++++++++++- .../reimbursement-payment-batch-flow.spec.ts | 28 ++ docs/trialrun/人工试运行报告.md | 3 + docs/文档事实源状态矩阵.md | 7 +- docs/文档索引.md | 4 +- ...17_09-04-37-cashier-payment-voucher-v1.csv | 9 + .../migration.sql | 33 ++ server/prisma/schema.prisma | 22 ++ server/src/routes/paymentBatches.js | 304 +++++++++++++++++- .../paymentBatches.reimbursement.test.js | 249 +++++++++++++- 11 files changed, 891 insertions(+), 16 deletions(-) create mode 100644 issues/2026-05-17_09-04-37-cashier-payment-voucher-v1.csv create mode 100644 server/prisma/migrations/20260517090437_cashier_payment_receipts/migration.sql diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index d136fb9e..089cd851 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -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 { + 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" }, + ); +} diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index fdd4ea6b..c63b3388 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -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(""); const [voucherPrefix, setVoucherPrefix] = useState(""); + const receiptInputRef = useRef(null); + const [selectedReceiptFiles, setSelectedReceiptFiles] = useState([]); 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 ( @@ -390,6 +487,93 @@ export function PaymentBatchesPage() { +
+
+
+

银行回单附件

+

先在网银完成对公付款,再上传银行回单并执行 OA 已付款标记。

+
+ {canManageBatch ? ( +
+ setSelectedReceiptFiles(Array.from(event.target.files ?? []))} + /> + + +
+ ) : null} +
+ {selectedReceiptFiles.length ? ( +
+ 待上传:{selectedReceiptFiles.map((file) => file.name).join("、")} +
+ ) : null} + {receiptAttachmentsQuery.isError ? ( +
+ {resolveError(receiptAttachmentsQuery.error)} +
+ ) : null} +
+ {(receiptAttachmentsQuery.data?.items ?? []).map((attachment) => ( +
+
+
{attachment.originalName}
+
+ {attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDateTime(attachment.createdAt)} + {attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""} +
+
+
+ + {canManageBatch ? ( + + ) : null} +
+
+ ))} + {!receiptAttachmentsQuery.isLoading && !(receiptAttachmentsQuery.data?.items ?? []).length ? ( +
+ 暂无银行回单附件;默认银行渠道执行前需要上传至少一份回单。 +
+ ) : null} +
+
+

批次项

@@ -406,19 +590,19 @@ export function PaymentBatchesPage() { updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} /> updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} />