From ae054ca497d2709ed7ba9a2d24eba5a7739c8a43 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 8 May 2026 10:05:38 +0800 Subject: [PATCH] =?UTF-8?q?R111/A7-A10=20=E7=BB=9F=E4=B8=80=E6=8A=A5?= =?UTF-8?q?=E9=94=80=E5=8F=8C=E7=AB=AF=E5=8F=91=E7=A5=A8=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/invoicePool.ts | 36 +- client/src/api/reimbursements.ts | 47 ++- client/src/pages/InvoicePoolPage.tsx | 8 +- .../pages/MobileReimbursementDetailPage.tsx | 281 +++++++++++--- client/src/pages/ReimbursementDetailPage.tsx | 291 +++++++++----- .../migration.sql | 33 ++ server/prisma/schema.prisma | 22 ++ server/src/routes/reimbursements.js | 358 +++++++++++++++++- 8 files changed, 908 insertions(+), 168 deletions(-) create mode 100644 server/prisma/migrations/20260508091500_reimbursement_attachments/migration.sql diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index 71d5aeb8..0d038b3d 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -62,9 +62,12 @@ export type InvoicePoolListResponse = { }; export type UploadInvoiceResponse = { - invoicePoolId: string; + invoicePoolId: string | null; status: InvoicePoolStatus; fileSha256: string; + uploadAction?: "create" | "reuse_existing" | "blocked"; + unavailableReason?: string | null; + unavailableMessage?: string | null; }; export type InvoicePoolDetail = { @@ -136,6 +139,31 @@ export type BindableInvoiceListResponse = { items: BindableInvoiceSummary[]; }; +export type InvoiceStatusItem = { + id: string; + status: InvoicePoolStatus; + reason: string; + message: string; + invoiceCode: string | null; + invoiceNumber: string | null; + invoiceDate: string | null; + sellerName: string | null; + totalAmount: string | null; + originalName: string | null; + linkedReimbursementId: string | null; + linkedItemId: string | null; + linkedTravelOrderId: string | null; + ocrAttempts: number; + ocrLastError: string | null; + ocrCompletedAt: string | null; + updatedAt: string | null; +}; + +export type InvoiceStatusResponse = { + items: InvoiceStatusItem[]; + missingIds: string[]; +}; + function toQueryString(query: Record) { const params = new URLSearchParams(); @@ -170,6 +198,12 @@ export async function fetchBindableInvoices(query: { ); } +export async function fetchInvoiceStatuses(ids: string[]) { + return apiRequestData( + `/api/invoice-pool/status${toQueryString({ ids: ids.join(",") })}`, + ); +} + export async function uploadInvoice( file: File, ocrType?: string, diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 36e31647..af2c05f5 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -1,4 +1,4 @@ -import { apiRequestData } from "./http"; +import { apiRequestBlob, apiRequestData } from "./http"; import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl"; export type ReimbursementStatus = @@ -165,6 +165,19 @@ export type ReimbursementDetail = { originalName: string | null; linkedItemId: string | null; }>; + attachments: Array<{ + id: string; + originalName: string; + mimeType: string; + fileSize: number; + fileSha256: string; + uploadedBy: { + id: string; + name: string; + employeeNo: string; + } | null; + createdAt: string | null; + }>; budgetHint: ReimbursementBudgetHint | null; pendingWorkflow: { instanceId: string; @@ -463,6 +476,38 @@ export async function deleteReimbursementItem(reimbursementId: string, itemId: s ); } +export async function uploadReimbursementAttachments(reimbursementId: string, files: File[]) { + const formData = new FormData(); + for (const file of files) { + formData.append("files", file); + } + + return apiRequestData<{ items: ReimbursementDetail["attachments"] }>( + `/api/reimbursements/${reimbursementId}/attachments`, + { + method: "POST", + body: formData, + }, + ); +} + +export async function deleteReimbursementAttachment(reimbursementId: string, attachmentId: string) { + return apiRequestData<{ attachmentId: string; outcome: string }>( + `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}`, + { + method: "DELETE", + }, + ); +} + +export function reimbursementAttachmentFileUrl(reimbursementId: string, attachmentId: string) { + return `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`; +} + +export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string) { + return apiRequestBlob(reimbursementAttachmentFileUrl(reimbursementId, attachmentId)); +} + export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) { return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, { diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index 2aa54758..e72c1319 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -237,7 +237,11 @@ export function InvoicePoolPage() { setUploadProgress({ completed: index, total: files.length, currentFileName: file.name }); try { const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined); - result.successes.push({ file, data }); + if (data.uploadAction === "blocked" || !data.invoicePoolId) { + result.failures.push({ file, message: data.unavailableMessage ?? "该发票不可添加" }); + } else { + result.successes.push({ file, data }); + } } catch (err) { result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") }); } @@ -249,7 +253,7 @@ export function InvoicePoolPage() { onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); const lastSuccess = result.successes.at(-1); - if (lastSuccess) { + if (lastSuccess?.data.invoicePoolId) { setDetailInvoiceId(lastSuccess.data.invoicePoolId); } setSelectedFiles(result.failures.map((item) => item.file)); diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 7b7e9652..ed11081d 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -6,18 +6,22 @@ import { fetchExpenseTypes } from "../api/expenseTypes"; import { bindInvoice, fetchBindableInvoices, + fetchInvoiceStatuses, unbindInvoice, uploadInvoice, type BindableInvoiceSummary, } from "../api/invoicePool"; import { createReimbursementItem, + deleteReimbursementAttachment, deleteReimbursement, deleteReimbursementItem, + downloadReimbursementAttachment, fetchReimbursementDetail, precheckReimbursement, submitReimbursement, updateReimbursementItem, + uploadReimbursementAttachments, type ReimbursementItem, type ReimbursementStatus, } from "../api/reimbursements"; @@ -77,7 +81,7 @@ const INVOICE_STATUS_LABEL: Record = { pending_ocr: "识别中", ocr_failed: "识别失败", available: "可用", - bound: "已绑定", + bound: "已添加", archived: "已归档", }; @@ -100,6 +104,13 @@ function formatDate(value: string | null | undefined) { return value || "未填写"; } +function formatDateTime(value: string | null | undefined) { + if (!value) { + return "-"; + } + return value.slice(0, 19).replace("T", " "); +} + function formatInvoiceTitle(invoice: { invoiceCode?: string | null; invoiceNumber?: string | null; @@ -128,6 +139,7 @@ export function MobileReimbursementDetailPage() { const queryClient = useQueryClient(); const { me, permissions } = useAuth(); const fileInputRef = useRef(null); + const attachmentInputRef = useRef(null); const [itemForm, setItemForm] = useState(null); const [itemError, setItemError] = useState(""); @@ -141,6 +153,7 @@ export function MobileReimbursementDetailPage() { const [invoiceSearch, setInvoiceSearch] = useState(""); const [selectedInvoices, setSelectedInvoices] = useState({}); const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); + const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [invoiceActionError, setInvoiceActionError] = useState(""); const [invoiceActionMessage, setInvoiceActionMessage] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); @@ -168,6 +181,7 @@ export function MobileReimbursementDetailPage() { const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice); const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense)); const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense)); + const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense)); const selectedInvoiceEntries = Object.entries(selectedInvoices); const bindableInvoicesQuery = useQuery({ @@ -182,6 +196,13 @@ export function MobileReimbursementDetailPage() { refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, }); + const pendingInvoiceStatusesQuery = useQuery({ + enabled: pendingOcrInvoiceIds.length > 0, + queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds], + queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds), + refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false, + }); + useEffect(() => { const timerId = window.setTimeout(() => { setInvoiceSearch(invoiceSearchInput.trim()); @@ -205,23 +226,6 @@ export function MobileReimbursementDetailPage() { ]); }; - const resolveDefaultLinkedItemId = (invoice?: BindableInvoiceSummary | null) => { - if (!detail?.items.length) { - return ""; - } - if (detail.items.length === 1) { - return detail.items[0].id; - } - - const invoiceAmount = Number(invoice?.totalAmount ?? NaN); - if (!Number.isFinite(invoiceAmount)) { - return ""; - } - - const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount); - return matchedItems.length === 1 ? matchedItems[0].id : ""; - }; - const startCreateItem = () => { setItemForm({ mode: "create", @@ -348,14 +352,14 @@ export function MobileReimbursementDetailPage() { if (!failed.length) { setInvoiceActionError(""); - setInvoiceActionMessage(`已绑定 ${succeededIds.length} 张发票`); - toast.success(`已绑定 ${succeededIds.length} 张发票`); + setInvoiceActionMessage(`已添加 ${succeededIds.length} 张发票`); + toast.success(`已添加 ${succeededIds.length} 张发票`); return; } - const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "绑定失败"}`).join(";"); + const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "添加失败"}`).join(";"); setInvoiceActionError(message); - toast.error("部分发票绑定失败"); + toast.error("部分发票添加失败"); }, onError: (error) => { const message = resolveErrorMessage(error); @@ -369,7 +373,7 @@ export function MobileReimbursementDetailPage() { onSuccess: async () => { await invalidateDetail(); setInvoiceActionError(""); - toast.success("发票已解绑"); + toast.success("发票已移除"); }, onError: (error) => { const message = resolveErrorMessage(error); @@ -401,21 +405,24 @@ export function MobileReimbursementDetailPage() { ); uploadedCount += 1; - if (uploadResult.status === "available") { - const linkedItemId = resolveDefaultLinkedItemId(null); + if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) { + failures.push({ + file, + message: uploadResult.unavailableMessage ?? "该发票不可添加", + }); + } else if (uploadResult.status === "available") { try { - await bindInvoice(uploadResult.invoicePoolId, { - reimbursementId, - ...(linkedItemId ? { linkedItemId } : {}), - }); + await bindInvoice(uploadResult.invoicePoolId, { reimbursementId }); boundCount += 1; } catch (error) { - failures.push({ file, message: `上传成功,绑定失败:${resolveErrorMessage(error)}` }); + failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` }); } } else if (uploadResult.status === "pending_ocr") { pendingIds.push(uploadResult.invoicePoolId); } else if (uploadResult.status === "ocr_failed") { - failures.push({ file, message: "OCR 识别失败,请到发票池补录后再绑定" }); + failures.push({ file, message: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" }); + } else { + failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" }); } } catch (error) { failures.push({ file, message: resolveErrorMessage(error) }); @@ -438,7 +445,7 @@ export function MobileReimbursementDetailPage() { const messages: string[] = []; if (result.boundCount) { - messages.push(`${result.boundCount} 张已绑定`); + messages.push(`${result.boundCount} 张已添加`); } if (result.pendingIds.length) { messages.push(`${result.pendingIds.length} 张识别中`); @@ -464,6 +471,80 @@ export function MobileReimbursementDetailPage() { onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), }); + const uploadAttachmentsMutation = useMutation({ + mutationFn: async (files: File[]) => { + if (!files.length) { + throw new Error("请选择附件材料"); + } + return uploadReimbursementAttachments(reimbursementId, files); + }, + onSuccess: async () => { + await invalidateDetail(); + setSelectedAttachmentFiles([]); + if (attachmentInputRef.current) { + attachmentInputRef.current.value = ""; + } + toast.success("附件材料已上传"); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + + const deleteAttachmentMutation = useMutation({ + mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId), + onSuccess: async () => { + await invalidateDetail(); + toast.success("附件材料已删除"); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + + const openAttachmentMutation = useMutation({ + mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId), + onSuccess: (result) => { + const url = URL.createObjectURL(result.blob); + window.open(url, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + + useEffect(() => { + const statuses = pendingInvoiceStatusesQuery.data?.items ?? []; + if (!statuses.length || bindInvoicesMutation.isPending) { + return; + } + + const availableIds = statuses + .filter((item) => item.status === "available") + .map((item) => item.id); + const finishedIds = new Set( + statuses + .filter((item) => item.status !== "pending_ocr") + .map((item) => item.id), + ); + const failedMessages = statuses + .filter((item) => item.status === "ocr_failed" || item.status === "archived") + .map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`); + + if (failedMessages.length) { + setInvoiceActionError(failedMessages.join(";")); + } + + if (availableIds.length) { + bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId }))); + } + + if (finishedIds.size) { + setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId))); + } + }, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]); + const handleSubmit = async () => { if (!detail) { return; @@ -529,21 +610,14 @@ export function MobileReimbursementDetailPage() { } return { ...previous, - [invoice.id]: previous[invoice.id] ?? resolveDefaultLinkedItemId(invoice), + [invoice.id]: previous[invoice.id] ?? "", }; }); }; - const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => { - setSelectedInvoices((previous) => ({ - ...previous, - [invoiceId]: linkedItemId, - })); - }; - const resolveLinkedItemLabel = (linkedItemId: string | null) => { if (!linkedItemId) { - return "整单"; + return "自动生成或匹配明细"; } const item = detail?.items.find((candidate) => candidate.id === linkedItemId); return item ? itemLabel(item) : "明细"; @@ -760,7 +834,7 @@ export function MobileReimbursementDetailPage() {
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
-
绑定:{resolveLinkedItemLabel(invoice.linkedItemId)}
+
明细:{resolveLinkedItemLabel(invoice.linkedItemId)}
{formatCurrency(invoice.totalAmount)}
@@ -778,7 +852,7 @@ export function MobileReimbursementDetailPage() { disabled={unbindInvoiceMutation.isPending} onClick={() => unbindInvoiceMutation.mutate(invoice.id)} > - 解绑 + 移除 ) : null} @@ -791,6 +865,98 @@ export function MobileReimbursementDetailPage() { )} +
+
+
+

附件材料

+

合同、订单、付款截图或情况说明

+
+ {canManageAttachments ? ( + + ) : null} +
+ + {canManageAttachments ? ( +
+ setSelectedAttachmentFiles(Array.from(event.target.files ?? []))} + /> + {selectedAttachmentFiles.length ? ( +
+ {selectedAttachmentFiles.slice(0, 4).map((file) => ( +
+ {file.name} · {Math.ceil(file.size / 1024)} KB +
+ ))} + {selectedAttachmentFiles.length > 4 ?
另有 {selectedAttachmentFiles.length - 4} 个文件
: null} +
+ ) : null} + +
+ ) : null} + + {(detail.attachments ?? []).length ? ( +
+ {(detail.attachments ?? []).map((attachment) => ( +
+
+
{attachment.originalName}
+
+ {Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)} +
+
+
+ + {canManageAttachments ? ( + + ) : null} +
+
+ ))} +
+ ) : ( +
+ 暂无附件材料 +
+ )} +
+

操作

{actionError ? ( @@ -833,7 +999,7 @@ export function MobileReimbursementDetailPage() { > 添加发票 - 可一次上传多张,识别完成后再绑定。 + 可一次上传多张,识别完成后会自动添加。
@@ -900,7 +1066,7 @@ export function MobileReimbursementDetailPage() { {pendingOcrInvoiceIds.length ? (

- {pendingOcrInvoiceIds.length} 张发票正在识别。识别完成后可在“已有发票”中绑定。 + {pendingOcrInvoiceIds.length} 张发票正在识别,完成后会自动刷新并添加。

) : null} @@ -940,7 +1106,7 @@ export function MobileReimbursementDetailPage() { ) : null} {!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
- 暂无可绑定发票 + 暂无可添加发票
) : null} {(bindableInvoicesQuery.data?.items ?? []).map((invoice) => { @@ -965,18 +1131,9 @@ export function MobileReimbursementDetailPage() {
{checked ? ( - +
+ 添加后自动生成或匹配报销明细 +
) : null} ); @@ -989,14 +1146,14 @@ export function MobileReimbursementDetailPage() { disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending} onClick={() => bindInvoicesMutation.mutate( - selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({ + selectedInvoiceEntries.map(([invoiceId]) => ({ invoiceId, - linkedItemId: linkedItemId || undefined, + linkedItemId: undefined, })), ) } > - {bindInvoicesMutation.isPending ? "绑定中..." : `绑定选中发票 ${selectedInvoiceEntries.length}`} + {bindInvoicesMutation.isPending ? "添加中..." : `添加选中发票 ${selectedInvoiceEntries.length}`} )} @@ -1050,7 +1207,7 @@ export function MobileReimbursementDetailPage() { open={confirmDeleteReimbursement} onOpenChange={setConfirmDeleteReimbursement} title="删除草稿?" - description="删除后无法恢复,已绑定发票会回到可用状态。" + description="删除后无法恢复,已添加发票会回到可用状态。" variant="destructive" confirmLabel="删除草稿" onConfirm={() => deleteReimbursementMutation.mutate()} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 05787dad..ec6e9df8 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -9,22 +9,24 @@ import { exportReimbursementJournal, previewReimbursementJournal } from "../api/ import { fetchProjects } from "../api/projects"; import { createReimbursementItem, + deleteReimbursementAttachment, deleteReimbursement, deleteReimbursementItem, + downloadReimbursementAttachment, fetchReimbursementDetail, payReimbursement, precheckReimbursement, submitReimbursement, updateReimbursementItem, + uploadReimbursementAttachments, withdrawReimbursement, linkReimbursementProject, type ReimbursementItem, } from "../api/reimbursements"; import { bindInvoice, - type BindableInvoiceSummary, fetchBindableInvoices, - fetchInvoicePoolDetail, + fetchInvoiceStatuses, unbindInvoice, uploadInvoice, } from "../api/invoicePool"; @@ -130,6 +132,7 @@ export function ReimbursementDetailPage() { const [invoiceSearch, setInvoiceSearch] = useState(""); const [selectedInvoices, setSelectedInvoices] = useState({}); const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); + const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [invoiceActionError, setInvoiceActionError] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); @@ -156,7 +159,8 @@ export function ReimbursementDetailPage() { queryFn: () => fetchProjects({ mine: true }), }); - const canManageIntegration = hasPermission(permissions, "integration.manage"); + const showExternalSyncUi = import.meta.env.VITE_SHOW_ERP_UI === "true"; + const canManageIntegration = hasPermission(permissions, "integration.manage") && showExternalSyncUi; const canSubmitExpense = hasPermission(permissions, "expense.submit"); const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); @@ -443,7 +447,7 @@ export function ReimbursementDetailPage() { results.push({ invoiceId: selection.invoiceId, success: false, - message: buildActionErrorMessage(error, "绑定发票失败"), + message: buildActionErrorMessage(error, "添加发票失败"), }); } } @@ -472,22 +476,22 @@ export function ReimbursementDetailPage() { setSelectedInvoices({}); setSelectedInvoiceFiles([]); setPendingOcrInvoiceIds([]); - toast.success(`已绑定 ${succeededIds.length} 张发票`); + toast.success(`已添加 ${succeededIds.length} 张发票`); return; } const summary = failedResults - .map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "绑定失败"}`) + .map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "添加失败"}`) .join(";"); setInvoiceActionError(summary); if (succeededIds.length) { - toast.success(`已绑定 ${succeededIds.length} 张,${failedResults.length} 张失败`); + toast.success(`已添加 ${succeededIds.length} 张,${failedResults.length} 张失败`); } else { - toast.error("发票绑定失败"); + toast.error("发票添加失败"); } }, onError: (error) => { - const message = buildActionErrorMessage(error, "发票绑定失败"); + const message = buildActionErrorMessage(error, "发票添加失败"); setInvoiceActionError(message); toast.error(message); }, @@ -502,10 +506,10 @@ export function ReimbursementDetailPage() { queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), ]); setInvoiceActionError(""); - toast.success("已解绑发票"); + toast.success("发票已移除"); }, onError: (error) => { - const message = buildActionErrorMessage(error, "解绑发票失败"); + const message = buildActionErrorMessage(error, "移除发票失败"); setInvoiceActionError(message); toast.error(message); }, @@ -526,15 +530,27 @@ export function ReimbursementDetailPage() { const pendingById = new Map(); const availableIds: string[] = []; const ocrFailures: string[] = []; + const bindFailures: string[] = []; + let boundCount = 0; setInvoiceUploadProgress({ completed: 0, total: files.length }); for (const [index, file] of files.entries()) { try { const result = await uploadInvoice(file, "vat", ownerEmployeeId); - if (result.status === "available") { + if (result.uploadAction === "blocked" || !result.invoicePoolId) { + uploadFailures.push({ + file, + message: result.unavailableMessage ?? "该发票不可添加", + }); + } else if (result.status === "available") { availableIds.push(result.invoicePoolId); } else if (result.status === "pending_ocr") { pendingById.set(result.invoicePoolId, file.name); + } else { + uploadFailures.push({ + file, + message: result.unavailableMessage ?? "该发票不可添加", + }); } } catch (error) { uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") }); @@ -547,17 +563,9 @@ export function ReimbursementDetailPage() { const deadline = Date.now() + 60_000; while (pendingById.size && Date.now() < deadline) { await sleep(2_000); - const invoiceResults = await Promise.all( - Array.from(pendingById.keys()).map(async (invoiceId) => { - try { - return await fetchInvoicePoolDetail(invoiceId); - } catch { - return null; - } - }), - ); + const invoiceResults = await fetchInvoiceStatuses(Array.from(pendingById.keys())); - for (const invoice of invoiceResults) { + for (const invoice of invoiceResults.items) { if (!invoice) { continue; } @@ -567,7 +575,11 @@ export function ReimbursementDetailPage() { pendingById.delete(invoice.id); } else if (invoice.status === "ocr_failed") { const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8); - ocrFailures.push(`${fileName}: ${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`); + ocrFailures.push(`${fileName}: ${invoice.message || formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`); + pendingById.delete(invoice.id); + } else if (invoice.status === "archived" || invoice.status === "bound") { + const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8); + ocrFailures.push(`${fileName}: ${invoice.message}`); pendingById.delete(invoice.id); } } @@ -575,10 +587,20 @@ export function ReimbursementDetailPage() { setPendingOcrInvoiceIds(Array.from(pendingById.keys())); } + for (const invoiceId of availableIds) { + try { + await bindInvoice(invoiceId, { reimbursementId }); + boundCount += 1; + } catch (error) { + bindFailures.push(`${invoiceId.slice(0, 8)}: ${buildActionErrorMessage(error, "添加发票失败")}`); + } + } + return { uploadedCount: files.length - uploadFailures.length, uploadFailures, - availableIds, + boundCount, + bindFailures, pendingIds: Array.from(pendingById.keys()), ocrFailures, }; @@ -590,28 +612,17 @@ export function ReimbursementDetailPage() { setPendingOcrInvoiceIds(result.pendingIds); await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); - const refreshed = await bindableInvoicesQuery.refetch(); - const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id)); - const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId)); - if (selectableIds.length) { - const invoiceById = new Map((refreshed.data?.items ?? []).map((item) => [item.id, item])); - setSelectedInvoices((previous) => ({ - ...previous, - ...Object.fromEntries( - selectableIds.map((invoiceId) => [ - invoiceId, - previous[invoiceId] ?? resolveDefaultLinkedItemId(invoiceById.get(invoiceId)), - ]), - ), - })); - } + await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); + await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); + await bindableInvoicesQuery.refetch(); const messages = [ ...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`), ...result.ocrFailures, + ...result.bindFailures, ]; if (result.pendingIds.length) { - messages.push(`${result.pendingIds.length} 张发票仍在 OCR 识别中,可稍后刷新`); + messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`); } if (messages.length) { @@ -622,7 +633,7 @@ export function ReimbursementDetailPage() { } setInvoiceActionError(""); - toast.success(`发票上传完成:${selectableIds.length || result.uploadedCount} 张可绑定`); + toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`); }, onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }), onError: (error) => { @@ -632,6 +643,46 @@ export function ReimbursementDetailPage() { }, }); + const uploadAttachmentsMutation = useMutation({ + mutationFn: async (files: File[]) => { + if (!files.length) { + throw new Error("请选择附件材料"); + } + return uploadReimbursementAttachments(reimbursementId, files); + }, + onSuccess: async () => { + setSelectedAttachmentFiles([]); + await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); + toast.success("附件材料已上传"); + }, + onError: (error) => { + toast.error(buildActionErrorMessage(error, "上传附件失败")); + }, + }); + + const deleteAttachmentMutation = useMutation({ + mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }); + toast.success("附件材料已删除"); + }, + onError: (error) => { + toast.error(buildActionErrorMessage(error, "删除附件失败")); + }, + }); + + const openAttachmentMutation = useMutation({ + mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId), + onSuccess: (result) => { + const url = URL.createObjectURL(result.blob); + window.open(url, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + }, + onError: (error) => { + toast.error(buildActionErrorMessage(error, "打开附件失败")); + }, + }); + if (!reimbursementId) { return (
@@ -684,7 +735,7 @@ export function ReimbursementDetailPage() { const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); - const missingInvoiceSubmitMessage = "提交前需先绑定至少 1 张发票,可在“关联票据”区域添加。"; + const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。"; const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : ""; const selectedInvoiceEntries = Object.entries(selectedInvoices); @@ -786,24 +837,6 @@ export function ReimbursementDetailPage() { } }; - const resolveDefaultLinkedItemId = (invoice: BindableInvoiceSummary | null | undefined) => { - if (!detail.items.length) { - return ""; - } - - if (detail.items.length === 1) { - return detail.items[0].id; - } - - const invoiceAmount = Number(invoice?.totalAmount ?? NaN); - if (!Number.isFinite(invoiceAmount)) { - return ""; - } - - const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount); - return matchedItems.length === 1 ? matchedItems[0].id : ""; - }; - const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => { setSelectedInvoices((previous) => { if (!checked) { @@ -812,37 +845,29 @@ export function ReimbursementDetailPage() { return next; } - const invoice = (bindableInvoicesQuery.data?.items ?? []).find((item) => item.id === invoiceId); return { ...previous, - [invoiceId]: previous[invoiceId] ?? resolveDefaultLinkedItemId(invoice), + [invoiceId]: previous[invoiceId] ?? "", }; }); }; - const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => { - setSelectedInvoices((previous) => ({ - ...previous, - [invoiceId]: linkedItemId, - })); - }; - const handleBindSelectedInvoices = () => { if (!selectedInvoiceEntries.length) { return; } bindInvoicesMutation.mutate( - selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({ + selectedInvoiceEntries.map(([invoiceId]) => ({ invoiceId, - linkedItemId: linkedItemId || undefined, + linkedItemId: undefined, })), ); }; const resolveLinkedItemLabel = (linkedItemId: string | null) => { if (!linkedItemId) { - return "整张报销单"; + return "自动生成或匹配明细"; } const linkedItem = detail.items.find((item) => item.id === linkedItemId); @@ -908,7 +933,7 @@ export function ReimbursementDetailPage() {
{detail.items.length}
-
关联票据
+
发票
{detail.invoices.length}
@@ -987,6 +1012,84 @@ export function ReimbursementDetailPage() { + +
+
+
+

附件材料

+

支付截图、合同、订单或情况说明,不参与发票识别。

+
+ {canEditItems ? ( +
+ + setSelectedAttachmentFiles(Array.from(event.target.files ?? []))} + /> + +
+ ) : null} +
+ + {(detail.attachments ?? []).length ? ( +
+ {(detail.attachments ?? []).map((attachment) => ( +
+
+
{attachment.originalName}
+
+ {Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)} +
+
+
+ + {canEditItems ? ( + + ) : null} +
+
+ ))} +
+ ) : ( +

暂无附件材料

+ )} +
@@ -1143,7 +1246,7 @@ export function ReimbursementDetailPage() {
-

关联票据

+

发票

{canOpenInvoiceBindDialog ? (
@@ -1187,7 +1290,7 @@ export function ReimbursementDetailPage() { 原始文件:{item.originalName || item.id}
- 绑定范围:{resolveLinkedItemLabel(item.linkedItemId)} + 明细:{resolveLinkedItemLabel(item.linkedItemId)}
@@ -1202,7 +1305,7 @@ export function ReimbursementDetailPage() { disabled={unbindInvoiceMutation.isPending} onClick={() => unbindInvoiceMutation.mutate(item.id)} > - 解绑 + 移除 ) : null}
@@ -1211,13 +1314,13 @@ export function ReimbursementDetailPage() { ))} ) : ( -

暂无关联票据

+

暂无发票

)}
- +
{budgetHint ? (
@@ -1406,10 +1509,10 @@ export function ReimbursementDetailPage() {
{bindableInvoicesQuery.isLoading ? ( -

正在加载可绑定发票...

+

正在加载可添加发票...

) : bindableInvoicesQuery.isError ? (

- {buildActionErrorMessage(bindableInvoicesQuery.error, "加载可绑定发票失败")} + {buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}

) : (bindableInvoicesQuery.data?.items ?? []).length ? ( bindableInvoicesQuery.data?.items.map((item) => { @@ -1440,22 +1543,8 @@ export function ReimbursementDetailPage() {
-
-
绑定到明细(可选)
- +
+ 添加后自动生成或匹配报销明细
@@ -1463,7 +1552,7 @@ export function ReimbursementDetailPage() { }) ) : (
- 当前没有可绑定发票,可直接在下方上传新发票。 + 当前没有可添加发票,可直接在下方上传新发票。
)}
@@ -1475,7 +1564,7 @@ export function ReimbursementDetailPage() { disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending} onClick={handleBindSelectedInvoices} > - {bindInvoicesMutation.isPending ? "绑定中..." : "绑定选中发票"} + {bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"} @@ -1530,7 +1619,7 @@ export function ReimbursementDetailPage() { {pendingOcrInvoiceIds.length ? (

- {pendingOcrInvoiceIds.length} 张发票正在 OCR 识别,可稍后刷新。 + {pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。

) : null} @@ -1640,7 +1729,7 @@ export function ReimbursementDetailPage() { open={confirmDeleteReimbursement} onOpenChange={setConfirmDeleteReimbursement} title={`确认删除 ${displayCode}?`} - description="删除后无法恢复,已绑定发票会回到可用状态。" + description="删除后无法恢复,已添加发票会回到可用状态。" variant="destructive" confirmLabel="删除草稿" onConfirm={() => { deleteReimbursementMutation.mutate(); }} diff --git a/server/prisma/migrations/20260508091500_reimbursement_attachments/migration.sql b/server/prisma/migrations/20260508091500_reimbursement_attachments/migration.sql new file mode 100644 index 00000000..c3017499 --- /dev/null +++ b/server/prisma/migrations/20260508091500_reimbursement_attachments/migration.sql @@ -0,0 +1,33 @@ +-- R111: 报销附件材料。用于支付截图、合同、订单、说明等非发票材料,不参与发票 OCR/查重。 +CREATE TABLE "reimbursement_attachments" ( + "id" UUID NOT NULL, + "reimbursement_id" UUID NOT NULL, + "storage_path" TEXT NOT NULL, + "original_name" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "file_size" INTEGER NOT NULL, + "file_sha256" TEXT NOT NULL, + "uploaded_by" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reimbursement_attachments_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "idx_reimbursement_attachments_form_created" + ON "reimbursement_attachments"("reimbursement_id", "created_at"); + +CREATE INDEX "idx_reimbursement_attachments_uploader" + ON "reimbursement_attachments"("uploaded_by"); + +CREATE INDEX "idx_reimbursement_attachments_file_sha256" + ON "reimbursement_attachments"("file_sha256"); + +ALTER TABLE "reimbursement_attachments" + ADD CONSTRAINT "reimbursement_attachments_reimbursement_id_fkey" + FOREIGN KEY ("reimbursement_id") REFERENCES "reimbursement_forms"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "reimbursement_attachments" + ADD CONSTRAINT "reimbursement_attachments_uploaded_by_fkey" + FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b3c56ee7..1943c438 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -600,6 +600,7 @@ model Employee { submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter") owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner") paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy") + reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader") leave_requests LeaveRequest[] leave_ledgers LeaveLedger[] owned_cost_centers CostCenter[] @relation("CostCenterOwner") @@ -1303,6 +1304,7 @@ model ReimbursementForm { travel_request TravelRequest? @relation(fields: [linked_travel_request_id], references: [id], onDelete: SetNull) items ReimbursementItem[] + attachments ReimbursementAttachment[] invoice_pool_items InvoicePool[] workflow_instances WorkflowInstance[] approval_transfers ApprovalTransfer[] @@ -1345,6 +1347,26 @@ model ReimbursementItem { @@map("reimbursement_items") } +model ReimbursementAttachment { + id String @id @default(uuid()) @db.Uuid + reimbursement_id String @db.Uuid + storage_path String + original_name String + mime_type String + file_size Int + file_sha256 String + uploaded_by String @db.Uuid + created_at DateTime @default(now()) + + reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade) + uploader Employee @relation("ReimbursementAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict) + + @@index([reimbursement_id, created_at], map: "idx_reimbursement_attachments_form_created") + @@index([uploaded_by], map: "idx_reimbursement_attachments_uploader") + @@index([file_sha256], map: "idx_reimbursement_attachments_file_sha256") + @@map("reimbursement_attachments") +} + model LeaveType { id String @id @default(uuid()) @db.Uuid code String @unique diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index b0ca1ac8..3e7f9385 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -1,9 +1,12 @@ const { Prisma } = require("@prisma/client"); +const crypto = require("node:crypto"); +const path = require("node:path"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); -const { currentDateString, parseDateOnlyString } = require("../utils/date"); +const { sha256Hex } = require("../utils/crypto"); +const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date"); const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation"); const { hasPermission, resolveEntityPermission } = require("../constants/permissions"); const { @@ -27,6 +30,7 @@ const { resolveEntityPrecheck, } = require("../services/feeControl"); const { issueDocumentNumber } = require("../services/documentSequence"); +const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); const { buildWorkflowStepsFromApprovalRule, getWorkflowStepsSnapshot, @@ -137,6 +141,12 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { other: "transport", }; const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]); +const ATTACHMENT_MIME_ALLOWLIST = new Set([ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", +]); function ensureOwnOrCanViewAll(fastify, request, reimbursement) { const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); @@ -257,6 +267,29 @@ function ensureApplicantOrAdmin(request, reimbursement) { } } +function sanitizeAttachmentFilename(fileName) { + if (!fileName) return "file"; + return fileName.replace(/[\r\n"]/g, "_"); +} + +function mapReimbursementAttachment(item) { + return { + id: item.id, + originalName: item.original_name, + mimeType: item.mime_type, + fileSize: item.file_size, + fileSha256: item.file_sha256, + uploadedBy: item.uploader + ? { + id: item.uploader.id, + name: item.uploader.name, + employeeNo: item.uploader.employee_no, + } + : null, + createdAt: item.created_at?.toISOString?.() ?? null, + }; +} + function parseWithdrawBody(body) { const bodySchema = z.object({ comment: z.string().trim().min(1).max(500).optional(), @@ -1969,6 +2002,24 @@ async function reimbursementRoutes(fastify) { }, }, }, + attachments: { + orderBy: { created_at: "asc" }, + select: { + id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + created_at: true, + uploader: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + }, + }, invoice_pool_items: { select: { id: true, @@ -2129,6 +2180,7 @@ async function reimbursementRoutes(fastify) { } : null, })), + attachments: reimbursement.attachments.map(mapReimbursementAttachment), budgetHint, invoices: reimbursement.invoice_pool_items.map((item) => ({ id: item.id, @@ -2298,6 +2350,310 @@ async function reimbursementRoutes(fastify) { }, ); + fastify.post( + "/reimbursements/:id/attachments", + { + preHandler: fastify.requirePermission("expense.edit_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + + const reimbursement = await fastify.prisma.reimbursementForm.findUnique({ + where: { id: reimbursementId }, + select: { + id: true, + owner_id: true, + submitter_id: true, + status: true, + }, + }); + if (!reimbursement) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + ensureApplicantOrAdmin(request, reimbursement); + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_attachment_edit_status" }, + }); + } + + const files = []; + try { + for await (const part of request.parts()) { + if (part.type !== "file") { + continue; + } + + const originalName = part.filename ?? "attachment"; + const mimeType = part.mimetype; + if (!ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "unsupported_mime_type" }, + }); + } + + const chunks = []; + let fileSize = 0; + for await (const chunk of part.file) { + fileSize += chunk.length; + if (fileSize > fastify.config.maxUploadSizeBytes) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "file_too_large" }, + }); + } + chunks.push(chunk); + } + + if (fileSize === 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "empty_file" }, + }); + } + + files.push({ + buffer: Buffer.concat(chunks), + originalName, + mimeType, + fileSize, + }); + } + } catch (error) { + if (error?.code === "FST_REQ_FILE_TOO_LARGE") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "file_too_large" }, + }); + } + throw error; + } + + if (!files.length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "missing" }, + }); + } + + const datePrefix = compactDateString(fastify.config.appTimezone); + const createdItems = []; + for (const file of files) { + const fileSha256 = sha256Hex(file.buffer); + const extension = path.extname(file.originalName ?? "").toLowerCase(); + const objectKey = path.posix.join("reimbursement-attachments", datePrefix, `${crypto.randomUUID()}${extension}`); + const storagePath = await writeBuffer({ + config: fastify.config, + key: objectKey, + buffer: file.buffer, + contentType: file.mimeType, + }); + + const created = await fastify.prisma.reimbursementAttachment.create({ + data: { + reimbursement_id: reimbursementId, + storage_path: storagePath, + original_name: file.originalName, + mime_type: file.mimeType, + file_size: file.fileSize, + file_sha256: fileSha256, + uploaded_by: request.employee.id, + }, + select: { + id: true, + original_name: true, + mime_type: true, + file_size: true, + file_sha256: true, + created_at: true, + uploader: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + }, + }); + createdItems.push(created); + } + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "REIMBURSEMENT_ATTACHMENTS_UPLOADED", + targetType: "reimbursement", + targetId: reimbursementId, + extra: { + attachmentIds: createdItems.map((item) => item.id), + }, + }), + }); + + return sendData(reply, request, { + items: createdItems.map(mapReimbursementAttachment), + }); + }, + ); + + fastify.delete( + "/reimbursements/:id/attachments/:attachmentId", + { + preHandler: fastify.requirePermission("expense.edit_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId); + if (!parsedAttachmentId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { attachmentId: "invalid_uuid" }, + }); + } + + const result = await fastify.prisma.$transaction(async (tx) => { + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + ensureApplicantOrAdmin(request, reimbursement); + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_attachment_edit_status" }, + }); + } + + const deleted = await tx.reimbursementAttachment.deleteMany({ + where: { + id: parsedAttachmentId.data, + reimbursement_id: reimbursementId, + }, + }); + if (deleted.count === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + return { attachmentId: parsedAttachmentId.data }; + }); + + return sendData(reply, request, { + attachmentId: result.attachmentId, + outcome: "deleted", + }); + }, + ); + + fastify.get( + "/reimbursements/:id/attachments/:attachmentId/file", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId); + if (!parsedAttachmentId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { attachmentId: "invalid_uuid" }, + }); + } + + const attachment = await fastify.prisma.reimbursementAttachment.findFirst({ + where: { + id: parsedAttachmentId.data, + reimbursement_id: reimbursementId, + }, + select: { + id: true, + storage_path: true, + original_name: true, + mime_type: true, + reimbursement_form: { + select: { + owner_id: true, + submitter_id: true, + }, + }, + }, + }); + if (!attachment) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form); + + let stream = null; + try { + const opened = await openReadStream({ + config: fastify.config, + storagePath: attachment.storage_path, + }); + stream = opened.stream; + } catch (error) { + if (error instanceof StorageNotFoundError) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + throw error; + } + + reply.type(attachment.mime_type || "application/octet-stream"); + reply.header( + "content-disposition", + `inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`, + ); + return reply.send(stream); + }, + ); + fastify.post( "/reimbursements/:id/items", {