From ba48e7d1d42c871dddcf251c3cbf14df8ae6bba0 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 11 Apr 2026 14:57:15 +0800 Subject: [PATCH] =?UTF-8?q?R58=20A1-A6=EF=BC=9A=E6=8A=A5=E9=94=80=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E5=86=85=E5=B5=8C=E5=8F=91=E7=A5=A8=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E4=B8=8E=E5=8D=95=E6=8D=AE=E6=89=93=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package-lock.json | 10 + client/package.json | 1 + client/src/api/invoicePool.ts | 25 + client/src/api/reimbursements.ts | 20 + client/src/pages/ReimbursementDetailPage.tsx | 498 +++++++++++++++++- client/src/pages/ReimbursementPrintPage.tsx | 325 ++++++++++++ client/src/router/index.tsx | 10 + .../src/router/lazy/ReimbursementPrintPage.ts | 1 + client/src/router/lazy/index.ts | 1 + .../2026-04-08_r58-invoice-inline-print.csv | 7 + server/src/routes/invoicePool/read.js | 117 ++++ server/src/routes/reimbursements.js | 109 ++++ .../unit/routes/invoicePool.bindable.test.js | 253 +++++++++ .../unit/routes/reimbursements.detail.test.js | 302 +++++++++++ 14 files changed, 1667 insertions(+), 12 deletions(-) create mode 100644 client/src/pages/ReimbursementPrintPage.tsx create mode 100644 client/src/router/lazy/ReimbursementPrintPage.ts create mode 100644 issues/2026-04-08_r58-invoice-inline-print.csv create mode 100644 server/tests/unit/routes/invoicePool.bindable.test.js create mode 100644 server/tests/unit/routes/reimbursements.detail.test.js diff --git a/client/package-lock.json b/client/package-lock.json index 9e9132f7..ff9874cc 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,6 +17,7 @@ "@zxing/browser": "^0.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.0", @@ -6131,6 +6132,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/client/package.json b/client/package.json index 3714a0d6..f0ce1879 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "@zxing/browser": "^0.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.0", diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index df328127..4ef7e895 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -115,6 +115,21 @@ export type InvoiceBindPayload = travelOrderId: string; }; +export type BindableInvoiceSummary = { + id: string; + invoiceCode: string | null; + invoiceNumber: string | null; + invoiceDate: string | null; + sellerName: string | null; + totalAmount: string | null; + originalName: string | null; + createdAt: string | null; +}; + +export type BindableInvoiceListResponse = { + items: BindableInvoiceSummary[]; +}; + function toQueryString(query: Record) { const params = new URLSearchParams(); @@ -139,6 +154,16 @@ export async function fetchInvoicePoolDetail(invoiceId: string) { return apiRequestData(`/api/invoice-pool/${invoiceId}`); } +export async function fetchBindableInvoices(query: { + reimbursementId: string; + search?: string; + limit?: number; +}) { + return apiRequestData( + `/api/invoice-pool/bindable${toQueryString(query as Record)}`, + ); +} + export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeId?: string) { const formData = new FormData(); formData.append("file", file); diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index b7792767..5ae7127b 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -111,13 +111,28 @@ export type ReimbursementWorkflowProgress = { }>; }; +export type ReimbursementApprovalHistoryItem = { + id: string; + stepName: string | null; + action: string; + actorName: string; + comment: string | null; + createdAt: string | null; +}; + export type ReimbursementDetail = { id: string; formNumber: string | null; submitterId: string; ownerId: string; departmentId: string; + department: { + id: string; + name: string; + } | null; status: ReimbursementStatus; + createdAt: string | null; + submittedAt: string | null; reimbursementDate: string | null; totalAmount: string | null; description: string | null; @@ -144,7 +159,11 @@ export type ReimbursementDetail = { status: string; invoiceCode: string | null; invoiceNumber: string | null; + invoiceDate: string | null; + sellerName: string | null; totalAmount: string | null; + originalName: string | null; + linkedItemId: string | null; }>; budgetHint: ReimbursementBudgetHint | null; pendingWorkflow: { @@ -158,6 +177,7 @@ export type ReimbursementDetail = { } | null; } | null; workflowProgress: ReimbursementWorkflowProgress | null; + approvalHistory: ReimbursementApprovalHistoryItem[]; riskFlags: RiskFlag[]; items: ReimbursementItem[]; }; diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 22b4f2f9..90152249 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; @@ -19,6 +19,13 @@ import { linkReimbursementProject, type ReimbursementItem, } from "../api/reimbursements"; +import { + bindInvoice, + fetchBindableInvoices, + fetchInvoicePoolDetail, + unbindInvoice, + uploadInvoice, +} from "../api/invoicePool"; import { toast } from "sonner"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; @@ -35,6 +42,7 @@ import { DialogHeader, DialogTitle, } from "../components/ui/dialog"; +import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; @@ -50,12 +58,29 @@ import { import { buildPrecheckDialogMessage } from "../lib/precheck"; type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; +type SelectedInvoiceMap = Record; function formatDateTime(value: string | null) { if (!value) return "-"; return value.slice(0, 19).replace("T", " "); } +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function buildActionErrorMessage(error: unknown, fallbackMessage: string) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + + if (error instanceof Error) { + return error.message; + } + + return fallbackMessage; +} + function resolveErpSyncStatus( exportState: | { @@ -84,7 +109,7 @@ function resolveErpSyncStatus( export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); - const { permissions } = useAuth(); + const { permissions, me } = useAuth(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(""); @@ -94,6 +119,13 @@ export function ReimbursementDetailPage() { const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false); const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); + const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); + const [invoiceSearchInput, setInvoiceSearchInput] = useState(""); + const [invoiceSearch, setInvoiceSearch] = useState(""); + const [selectedInvoices, setSelectedInvoices] = useState({}); + const [selectedInvoiceFile, setSelectedInvoiceFile] = useState(null); + const [invoiceActionError, setInvoiceActionError] = useState(""); + const [pendingOcrInvoiceId, setPendingOcrInvoiceId] = useState(null); const [editorInitialValues, setEditorInitialValues] = useState({ expenseTypeId: "", expenseDate: "", @@ -119,6 +151,9 @@ export function ReimbursementDetailPage() { const canManageIntegration = hasPermission(permissions, "integration.manage"); const canSubmitExpense = hasPermission(permissions, "expense.submit"); + const canUploadInvoice = hasPermission(permissions, "invoice.upload"); + const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); + const canViewAllExpense = hasPermission(permissions, "expense.view_all"); const reimbursementPrecheckQuery = useQuery({ enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""), @@ -132,6 +167,40 @@ export function ReimbursementDetailPage() { queryFn: () => previewReimbursementJournal(reimbursementId), }); + const bindableInvoicesQuery = useQuery({ + enabled: + Boolean(reimbursementId) && + isInvoiceDialogOpen && + canUploadInvoice && + canViewOwnInvoice && + Boolean(detailQuery.data) && + ["draft", "rejected"].includes(detailQuery.data?.status ?? "") && + Boolean(me?.id) && + [detailQuery.data?.ownerId, detailQuery.data?.submitterId].includes(me?.id ?? ""), + queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch], + queryFn: () => + fetchBindableInvoices({ + reimbursementId, + search: invoiceSearch || undefined, + limit: 20, + }), + }); + + useEffect(() => { + const timerId = window.setTimeout(() => { + setInvoiceSearch(invoiceSearchInput.trim()); + }, 300); + + return () => window.clearTimeout(timerId); + }, [invoiceSearchInput]); + + useEffect(() => { + const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id)); + setSelectedInvoices((previous) => + Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))), + ); + }, [bindableInvoicesQuery.data?.items]); + const mutation = useMutation({ mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => { if (!reimbursementId) { @@ -327,6 +396,141 @@ export function ReimbursementDetailPage() { }, }); + const bindInvoicesMutation = useMutation({ + mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => { + if (!reimbursementId) { + throw new Error("missing reimbursementId"); + } + + const results: Array<{ invoiceId: string; success: boolean; message?: string }> = []; + for (const selection of invoiceSelections) { + try { + await bindInvoice(selection.invoiceId, { + reimbursementId, + ...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : {}), + }); + results.push({ invoiceId: selection.invoiceId, success: true }); + } catch (error) { + results.push({ + invoiceId: selection.invoiceId, + success: false, + message: buildActionErrorMessage(error, "绑定发票失败"), + }); + } + } + + return results; + }, + onSuccess: async (results) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), + ]); + + const failedResults = results.filter((item) => !item.success); + const succeededIds = results.filter((item) => item.success).map((item) => item.invoiceId); + + setSelectedInvoices((previous) => + Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), + ); + + if (!failedResults.length) { + setInvoiceActionError(""); + toast.success(`已绑定 ${succeededIds.length} 张发票`); + return; + } + + const summary = failedResults + .map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "绑定失败"}`) + .join(";"); + setInvoiceActionError(summary); + if (succeededIds.length) { + toast.success(`已绑定 ${succeededIds.length} 张,${failedResults.length} 张失败`); + } else { + toast.error("发票绑定失败"); + } + }, + onError: (error) => { + const message = buildActionErrorMessage(error, "发票绑定失败"); + setInvoiceActionError(message); + toast.error(message); + }, + }); + + const unbindInvoiceMutation = useMutation({ + mutationFn: (invoiceId: string) => unbindInvoice(invoiceId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), + ]); + setInvoiceActionError(""); + toast.success("已解绑发票"); + }, + onError: (error) => { + const message = buildActionErrorMessage(error, "解绑发票失败"); + setInvoiceActionError(message); + toast.error(message); + }, + }); + + const uploadInvoiceMutation = useMutation({ + mutationFn: async (file: File) => { + const ownerEmployeeId = detailQuery.data?.ownerId; + if (!ownerEmployeeId) { + throw new Error("missing ownerEmployeeId"); + } + return uploadInvoice(file, undefined, ownerEmployeeId); + }, + onSuccess: async (result) => { + setInvoiceActionError(""); + setSelectedInvoiceFile(null); + setInvoiceSearchInput(""); + setInvoiceSearch(""); + setPendingOcrInvoiceId(result.invoicePoolId); + toast.success("发票上传成功,OCR 识别中"); + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + await sleep(2_000); + try { + const invoice = await fetchInvoicePoolDetail(result.invoicePoolId); + if (invoice.status === "available") { + const refreshed = await bindableInvoicesQuery.refetch(); + if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) { + setSelectedInvoices((previous) => ({ + ...previous, + [result.invoicePoolId]: previous[result.invoicePoolId] ?? "", + })); + } + setPendingOcrInvoiceId(null); + toast.success("发票 OCR 已完成,可直接绑定"); + return; + } + + if (invoice.status === "ocr_failed") { + setPendingOcrInvoiceId(null); + toast.error("发票 OCR 识别失败,请前往票据中心处理"); + return; + } + } catch { + // Polling errors should not block eventual refresh; keep retrying until timeout. + } + } + + setPendingOcrInvoiceId(null); + await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }); + toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表"); + }, + onError: (error) => { + const message = buildActionErrorMessage(error, "上传发票失败"); + setInvoiceActionError(message); + toast.error(message); + }, + }); + if (!reimbursementId) { return (
@@ -364,9 +568,17 @@ export function ReimbursementDetailPage() { const budgetHint = detail.budgetHint; const canPay = hasPermission(permissions, "expense.finance.pay"); const canEditItems = ["draft", "rejected"].includes(detail.status); + const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? ""); + const canOpenInvoiceBindDialog = + canEditItems && + isReimbursementParticipant && + canUploadInvoice && + canViewOwnInvoice; + const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense); 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 selectedInvoiceEntries = Object.entries(selectedInvoices); const expenseTypes = expenseTypesQuery.data?.items ?? []; @@ -449,6 +661,66 @@ export function ReimbursementDetailPage() { } }; + const handleInvoiceDialogOpenChange = (open: boolean) => { + setIsInvoiceDialogOpen(open); + if (!open) { + setInvoiceSearchInput(""); + setInvoiceSearch(""); + setSelectedInvoices({}); + setSelectedInvoiceFile(null); + setInvoiceActionError(""); + setPendingOcrInvoiceId(null); + } + }; + + const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => { + setSelectedInvoices((previous) => { + if (!checked) { + const next = { ...previous }; + delete next[invoiceId]; + return next; + } + + return { + ...previous, + [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]) => ({ + invoiceId, + linkedItemId: linkedItemId || undefined, + })), + ); + }; + + const resolveLinkedItemLabel = (linkedItemId: string | null) => { + if (!linkedItemId) { + return "整张报销单"; + } + + const linkedItem = detail.items.find((item) => item.id === linkedItemId); + if (!linkedItem) { + return linkedItemId; + } + + return `${linkedItem.expenseType?.name ?? linkedItem.expenseTypeId} · ${linkedItem.description ?? "未填写说明"}`; + }; + return ( )} actions={( - +
+ + +
)} /> @@ -718,15 +995,69 @@ export function ReimbursementDetailPage() { ) : null}
-
-

关联票据

+
+
+
+

关联票据

+

可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。

+
+ {canOpenInvoiceBindDialog ? ( + + ) : canEditItems ? ( +

当前账号不可在此页绑定发票

+ ) : null} +
+ + {invoiceActionError ? ( +

+ {invoiceActionError} +

+ ) : null} + {(detail.invoices ?? []).length ? ( -
+
{detail.invoices.map((item) => ( -
-
{item.id}
-
- 状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"} +
+
+
+
+ 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"} +
+
+ {item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"} +
+
+ 原始文件:{item.originalName || item.id} +
+
+ 绑定范围:{resolveLinkedItemLabel(item.linkedItemId)} +
+
+
+ + {item.status} + + {canUnbindInvoices ? ( + + ) : null} +
))} @@ -891,6 +1222,149 @@ export function ReimbursementDetailPage() {
+ + + + 添加发票 + 选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。 + + +
+
+ setInvoiceSearchInput(event.target.value)} + /> + +
+ + {invoiceActionError ? ( +

+ {invoiceActionError} +

+ ) : null} + +
+ {bindableInvoicesQuery.isLoading ? ( +

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

+ ) : bindableInvoicesQuery.isError ? ( +

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

+ ) : (bindableInvoicesQuery.data?.items ?? []).length ? ( + bindableInvoicesQuery.data?.items.map((item) => { + const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id); + return ( + + ); + }) + ) : ( +
+ 当前没有可绑定发票,可直接在下方上传新发票。 +
+ )} +
+ +
+
已选 {selectedInvoiceEntries.length} 张发票
+ +
+ +
+
+
或者上传新发票
+

上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。

+
+ +
+ + setSelectedInvoiceFile(event.target.files?.[0] ?? null)} + /> + +
+ + {pendingOcrInvoiceId ? ( +

+ 发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。 +

+ ) : null} +
+
+
+
+ diff --git a/client/src/pages/ReimbursementPrintPage.tsx b/client/src/pages/ReimbursementPrintPage.tsx new file mode 100644 index 00000000..d3107ae2 --- /dev/null +++ b/client/src/pages/ReimbursementPrintPage.tsx @@ -0,0 +1,325 @@ +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { QRCodeSVG } from "qrcode.react"; +import { fetchReimbursementDetail } from "../api/reimbursements"; +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { useAuth } from "../auth/AuthContext"; +import { CardSkeleton } from "../components/common/CardSkeleton"; +import { Button } from "../components/ui/button"; + +function formatDate(value: string | null) { + return value || "-"; +} + +function formatDateTime(value: string | null) { + if (!value) { + return "-"; + } + + return value.slice(0, 19).replace("T", " "); +} + +function formatActionLabel(action: string) { + const labels: Record = { + start: "提交", + approve: "通过", + reject: "驳回", + return: "退回", + transfer: "转审", + withdraw: "撤回", + }; + + return labels[action] ?? action; +} + +function formatCurrentPrintTime() { + return new Date().toLocaleString("zh-CN", { hour12: false }); +} + +export function ReimbursementPrintPage() { + const { reimbursementId = "" } = useParams(); + const navigate = useNavigate(); + const { me } = useAuth(); + + const detailQuery = useQuery({ + enabled: Boolean(reimbursementId), + queryKey: ["reimbursements", reimbursementId, "print"], + queryFn: () => fetchReimbursementDetail(reimbursementId), + }); + + const printTime = useMemo(() => formatCurrentPrintTime(), []); + + if (!reimbursementId) { + return ( +
+

缺少 reimbursementId 参数。

+
+ ); + } + + if (detailQuery.isLoading) { + return ; + } + + if (detailQuery.isError || !detailQuery.data) { + const message = isApiError(detailQuery.error) + ? `${detailQuery.error.code}: ${mapApiErrorMessage( + detailQuery.error.code, + detailQuery.error.details, + detailQuery.error.message, + )}` + : "加载打印视图失败"; + + return ( +
+

{message}

+ +
+ ); + } + + const detail = detailQuery.data; + const totalAmount = detail.totalAmount ?? "0.00"; + const qrValue = `https://oa.hyinda.com/reimbursements/${detail.id}`; + + return ( +
+ + +
+
+
+
费用报销单打印视图
+
浏览器可直接打印或另存为 PDF。
+
+
+ + +
+
+
+ +
+
+
+
+
+ LOGO +
+
+

费用报销单

+

标准打印稿,仅用于归档与线下签批留存。

+
+
+
+
表单号
+
{detail.formNumber ?? detail.id}
+
+
+ +
+
+
+
+ 报销人 + {detail.owner?.name ?? "-"} +
+
+ 部门 + {detail.department?.name ?? "-"} +
+
+ 报销日期 + {formatDate(detail.reimbursementDate)} +
+
+ 提交日期 + {formatDateTime(detail.submittedAt)} +
+
+
+ +
+
+
+ 当前状态 + {detail.status} +
+
+ 提交人 + {detail.submitter?.name ?? "-"} +
+
+ 表单编号 + {detail.formNumber ?? detail.id} +
+
+ 总金额 + ¥{totalAmount} +
+
+
+
+ +
+
报销明细
+
+ + + + + + + + + + + + {detail.items.map((item, index) => ( + + + + + + + + ))} + + + + + +
序号费用日期费用类型说明金额
{index + 1}{formatDate(item.expenseDate)}{item.expenseType?.name ?? item.expenseTypeId}{item.description ?? "-"}¥{item.amount}
合计¥{totalAmount}
+
+
+ +
+
发票清单
+
+ + + + + + + + + + + {detail.invoices.length ? ( + detail.invoices.map((invoice) => ( + + + + + + + )) + ) : ( + + + + )} + +
发票号发票日期卖方金额
+ {invoice.invoiceCode || "-"}-{invoice.invoiceNumber || "-"} + {formatDate(invoice.invoiceDate)}{invoice.sellerName || "-"}¥{invoice.totalAmount ?? "-"}
暂无关联发票
+
+
+ +
+
审批记录
+
+ + + + + + + + + + + + {detail.approvalHistory.length ? ( + detail.approvalHistory.map((record) => ( + + + + + + + + )) + ) : ( + + + + )} + +
步骤审批人审批时间状态意见
{record.stepName ?? "-"}{record.actorName || "-"}{formatDateTime(record.createdAt)}{formatActionLabel(record.action)}{record.comment || "-"}
暂无审批记录
+
+
+ + {(detail.linkedTravelRequest || detail.project) ? ( +
+
关联信息
+
+
+
关联差旅申请
+
+ {detail.linkedTravelRequest + ? `${detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id} · ${detail.linkedTravelRequest.purpose ?? "未填写目的"}` + : "未关联"} +
+
+
+
关联项目
+
+ {detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"} +
+
+
+
+ ) : null} + +
+
+
+ +
+
+
扫码查看线上原件
+
{qrValue}
+
+
+
+
打印时间:{printTime}
+
打印人:{me?.name ?? "-"}
+
+
+
+
+
+ ); +} + +export default ReimbursementPrintPage; diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 9f1be161..68a34c57 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -51,6 +51,7 @@ import { PurchaseRequestsPage, ReimbursementDetailPage, ReimbursementDashboardPage, + ReimbursementPrintPage, ReimbursementTemplatesPage, ReimbursementsPage, TravelRequestsPage, @@ -177,6 +178,15 @@ export function AppRouter() { } /> + + {withRouteFallback()} + + } + /> + import("./ContractsRegistryPage" export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage")); export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage")); export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage")); +export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage")); export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage")); export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage")); export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage")); diff --git a/issues/2026-04-08_r58-invoice-inline-print.csv b/issues/2026-04-08_r58-invoice-inline-print.csv new file mode 100644 index 00000000..ba498309 --- /dev/null +++ b/issues/2026-04-08_r58-invoice-inline-print.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R58 执行合同建档与范围冻结","创建并校验 R58 报销详情页内嵌发票绑定与单据打印执行合同 CSV,冻结范围仅包含发票候选查询、报销详情读模型增强、详情页内嵌绑定/解绑/上传和打印页。","issues/2026-04-08_r58-invoice-inline-print.csv 12 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 A1-A6,不混入 Prisma 迁移、bind/unbind/upload 核心逻辑改写或服务端 PDF 方案。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-08_r58-invoice-inline-print.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 校验字段完整;2) A2-A6 依赖链正确;3) Notes 写明无迁移和回滚方式。","manual","DONE","DONE","DONE","issues/2026-04-08_r58-invoice-inline-print.csv","[]","回滚路径:删除本次 CSV 不影响业务。" +"A2","发票可绑定查询与报销详情读模型增强","新增 /api/invoice-pool/bindable 候选查询,并增强 GET /api/reimbursements/:id 返回打印页与内嵌绑定所需字段。","/api/invoice-pool/bindable 按 reimbursementId 校验所有权和 draft/rejected 状态,支持 search 与 limit;GET /api/reimbursements/:id 返回 department、createdAt、submittedAt、扩展 invoices[] 与 approvalHistory[],且不改变现有 pendingWorkflow/workflowProgress 语义。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/invoicePool/read.js && node --check src/routes/reimbursements.js && npm test -- tests/unit/routes/invoicePool.bindable.test.js。业务:1) owner/submitter 可查自己单据候选发票;2) 非 draft/rejected 报销不可查询;3) 详情返回发票扩展字段、submittedAt 与 approvalHistory 正确。","manual","DONE","DONE","DONE","server/src/routes/invoicePool/read.js|server/src/routes/reimbursements.js|server/tests/unit/routes/invoicePool.bindable.test.js","[""A1""]","回滚路径:删除 bindable 路由与报销详情新增字段映射,恢复原详情响应。" +"A3","报销详情页内嵌发票绑定/解绑与快速上传","改造 ReimbursementDetailPage 的关联票据区,在 draft/rejected 状态内直接完成候选查询、绑定、解绑和上传后 OCR 等待。","详情页在满足权限与状态时显示添加发票入口;弹窗支持搜索候选发票、多选绑定、可选 linkedItemId;已绑定发票展示号/日期/卖方/金额/原始文件名并可解绑;上传成功后轮询到 available 自动进入候选列表。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) draft/rejected 可打开绑定弹窗;2) 可绑定到报销单或具体明细;3) 解绑后详情刷新;4) 上传新发票后提示 OCR 中,识别成功自动出现在候选列表。","manual","DONE","DONE","DONE","client/src/pages/ReimbursementDetailPage.tsx|client/src/api/invoicePool.ts|client/src/api/reimbursements.ts","[""A1"",""A2""]","回滚路径:恢复详情页只读票据展示,移除内嵌绑定弹窗与上传轮询逻辑。" +"A4","报销单打印视图、路由与入口","新增 A4 打印页,复用报销详情数据渲染 A4 布局,并在详情页提供打印 / PDF 入口。","/reimbursements/:reimbursementId/print 仅能查看报销详情的用户可访问;页面展示基本信息、明细、发票、审批记录、关联信息和二维码;工具栏仅屏幕可见,点击打印只触发 window.print;引入 qrcode.react 作为唯一新增前端依赖。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm install qrcode.react && npm run typecheck && npm run build。业务:1) 详情页可进入打印页;2) 打印页能渲染审批记录和二维码;3) 浏览器打印预览布局符合 A4,工具栏不出现在打印稿。","manual","DONE","DONE","DONE","client/src/pages/ReimbursementPrintPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ReimbursementPrintPage.ts|client/src/router/lazy/index.ts|client/package.json|client/package-lock.json","[""A1"",""A2""]","回滚路径:移除打印页、路由、入口和 qrcode.react 依赖。" +"A5","Reviewer 复核权限与数据暴露边界","对发票候选查询、详情页内嵌绑定、打印视图和新增依赖做实现后 review,自检权限、敏感字段和客户端打印行为。","review 覆盖 bindable 所有权/状态校验、linkedItemId 可空、上传 owner 使用 detail.ownerId 时的代理权限边界、打印页仅消费详情白名单字段、window.print 无服务端副作用以及 qrcode.react 依赖合理性。","检查:1) 阅读 server/src/routes/invoicePool/read.js、server/src/routes/reimbursements.js、client/src/pages/ReimbursementDetailPage.tsx、client/src/pages/ReimbursementPrintPage.tsx;2) 确认没有新增 Prisma 迁移、没有修改 bind/unbind/upload 核心逻辑;3) 记录 review 结论并回填 CSV。","manual","DONE","DONE","DONE","server/src/routes/invoicePool/read.js|server/src/routes/reimbursements.js|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/ReimbursementPrintPage.tsx|client/package.json","[""A2"",""A3"",""A4""]","回滚路径:若 review 发现权限或数据泄露风险,停止提交并回退对应改动。" +"A6","QA、回归、部署验证与提交收口","执行 post-check、手工 smoke、CSV 三列状态回填并完成提交。","后端语法/单测、前端 typecheck/build 全通过;手工验证详情页内绑定已有发票、上传新发票等待 OCR、打印页预览与打印动作;CSV 三列状态按实测回填;完成 git commit。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/invoicePool.js && node --check src/routes/invoicePool/read.js && node --check src/routes/reimbursements.js && npm test -- tests/unit/routes/invoicePool.bindable.test.js && cd ../client && npm install qrcode.react && npm run typecheck && npm run build。部署后:bash deploy/trialrun/deploy.sh,并手工 smoke 详情页内绑定、上传 OCR、打印页 window.print。","manual","DONE","DONE","DONE","issues/2026-04-08_r58-invoice-inline-print.csv|server/src/routes/invoicePool.js|server/src/routes/invoicePool/read.js|server/src/routes/reimbursements.js|server/tests/unit/routes/invoicePool.bindable.test.js|client/src/api/invoicePool.ts|client/src/api/reimbursements.ts|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/ReimbursementPrintPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ReimbursementPrintPage.ts|client/src/router/lazy/index.ts|client/package.json|client/package-lock.json","[""A2"",""A3"",""A4"",""A5""]","回滚路径:若 QA 或部署 smoke 失败,暂停提交并回退对应代码;本轮无 Prisma 迁移,无需远端 migrate status。已完成本地 post-check 与 bash deploy/trialrun/deploy.sh;live smoke 因缺少 LIVE_SMOKE_* 凭据被脚本自动跳过。" diff --git a/server/src/routes/invoicePool/read.js b/server/src/routes/invoicePool/read.js index 69d6758d..ab3ad736 100644 --- a/server/src/routes/invoicePool/read.js +++ b/server/src/routes/invoicePool/read.js @@ -5,6 +5,7 @@ const { sendData } = require("../../utils/reply"); const { buildAuditLogData } = require("../../utils/audit"); const { signPayload, verifyToken } = require("../../utils/signedUrl"); const { openReadStream, StorageNotFoundError } = require("../../services/storage"); +const { toMoneyString } = require("../../utils/workflow"); const { mapInvoiceSummary, normalizeInvoiceResponse, @@ -20,6 +21,11 @@ const invoiceListQuerySchema = z.object({ status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(), ownerScope: z.enum(["own", "all"]).optional(), }); +const bindableInvoiceQuerySchema = z.object({ + reimbursementId: z.string().uuid(), + search: z.string().trim().max(100).optional(), + limit: z.coerce.number().int().min(1).max(50).optional(), +}); function sanitizeFilename(fileName) { if (!fileName) return "file"; @@ -45,6 +51,24 @@ function parseInvoiceListQuery(query) { }; } +function parseBindableInvoiceQuery(query) { + const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return { + reimbursementId: parsed.data.reimbursementId, + search: parsed.data.search?.trim() || null, + limit: parsed.data.limit ?? 20, + }; +} + function ensureInvoiceReadableByUser(request, invoice) { const roleCodes = request.employee.roleCodes; const permissionCodes = request.employee.permissionCodes ?? []; @@ -102,6 +126,99 @@ async function hasPendingTaskAccess(fastify, request, linkedReimbursementId) { } function registerInvoicePoolReadRoutes(fastify) { + fastify.get( + "/invoice-pool/bindable", + { + schema: { tags: ["Invoice Pool"], summary: "List Bindable Invoices For Reimbursement" }, + preHandler: fastify.requirePermission("invoice.view_own"), + }, + async (request, reply) => { + const query = parseBindableInvoiceQuery(request.query); + + const reimbursement = await fastify.prisma.reimbursementForm.findUnique({ + where: { id: query.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, + }); + } + + const isOwn = + reimbursement.owner_id === request.employee.id || + reimbursement.submitter_id === request.employee.id; + if (!isOwn) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { reimbursement: "invalid_status_for_bind" }, + }); + } + + const where = { + owner_employee_id: reimbursement.owner_id, + status: "available", + linked_reimbursement_id: null, + ...(query.search + ? { + OR: [ + { invoice_code: { contains: query.search, mode: "insensitive" } }, + { invoice_number: { contains: query.search, mode: "insensitive" } }, + { seller_name: { contains: query.search, mode: "insensitive" } }, + ], + } + : {}), + }; + + const items = await fastify.prisma.invoicePool.findMany({ + where, + orderBy: { created_at: "desc" }, + take: query.limit, + select: { + id: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + seller_name: true, + total_amount: true, + original_name: true, + created_at: true, + }, + }); + + return sendData(reply, request, { + items: items.map((item) => ({ + id: item.id, + invoiceCode: item.invoice_code ?? null, + invoiceNumber: item.invoice_number ?? null, + invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, + sellerName: item.seller_name ?? null, + totalAmount: toMoneyString(item.total_amount), + originalName: item.original_name ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + })), + }); + }, + ); + fastify.get( "/invoice-pool", { diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 9b34df1a..9343c7be 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -136,6 +136,7 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { car: "travel_taxi", other: "transport", }; +const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]); function ensureOwnOrCanViewAll(fastify, request, reimbursement) { const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); @@ -195,6 +196,49 @@ function buildWorkflowProgress(workflowInstance, historyItems = []) { }; } +function buildWorkflowStepNameMap(workflowInstance) { + const steps = getWorkflowStepsSnapshot(workflowInstance); + return new Map(steps.map((step) => [step.id, step.name ?? step.id])); +} + +function resolveApprovalHistoryStepName(historyItem, stepNameMap, fallbackStepId = null) { + if (historyItem.workflow_task?.step_name) { + return historyItem.workflow_task.step_name; + } + + const payload = historyItem.payload ?? {}; + const stepId = + payload.from_step_id ?? + payload.step_id ?? + payload.to_step_id ?? + fallbackStepId ?? + null; + + if (!stepId) { + return null; + } + + return stepNameMap.get(String(stepId)) ?? String(stepId); +} + +function buildApprovalHistory(workflowInstance, historyItems = []) { + if (!workflowInstance) { + return []; + } + + const stepNameMap = buildWorkflowStepNameMap(workflowInstance); + return historyItems + .filter((item) => PRINTABLE_WORKFLOW_ACTIONS.has(item.action)) + .map((item) => ({ + id: item.id, + stepName: resolveApprovalHistoryStepName(item, stepNameMap, workflowInstance.current_step_id), + action: item.action, + actorName: item.actor?.name ?? "", + comment: item.comment ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + })); +} + function ensureApplicantOrAdmin(request, reimbursement) { if (request.employee?.roleCodes?.includes("admin")) { return; @@ -1869,12 +1913,19 @@ async function reimbursementRoutes(fastify) { submitter_id: true, owner_id: true, department_id: true, + created_at: true, reimbursement_date: true, total_amount: true, status: true, description: true, project_id: true, linked_travel_request_id: true, + department: { + select: { + id: true, + name: true, + }, + }, project: { select: { id: true, project_no: true, name: true } }, travel_request: { select: { @@ -1917,7 +1968,11 @@ async function reimbursementRoutes(fastify) { status: true, invoice_code: true, invoice_number: true, + invoice_date: true, + seller_name: true, total_amount: true, + original_name: true, + linked_item_id: true, }, }, workflow_instances: { @@ -1967,6 +2022,24 @@ async function reimbursementRoutes(fastify) { const pendingInstance = reimbursement.workflow_instances[0] ?? null; const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null; + const latestWorkflowInstance = await fastify.prisma.workflowInstance.findFirst({ + where: { + entity_type: "reimbursement", + entity_id: reimbursementId, + }, + orderBy: { created_at: "desc" }, + select: { + id: true, + current_step_id: true, + created_at: true, + context_data: true, + workflow_definition: { + select: { + definition_json: true, + }, + }, + }, + }); const workflowHistory = pendingInstance ? await fastify.prisma.workflowHistory.findMany({ where: { instance_id: pendingInstance.id }, @@ -1977,6 +2050,29 @@ async function reimbursementRoutes(fastify) { }, }) : []; + const approvalHistoryRows = latestWorkflowInstance + ? await fastify.prisma.workflowHistory.findMany({ + where: { instance_id: latestWorkflowInstance.id }, + orderBy: { created_at: "asc" }, + select: { + id: true, + action: true, + comment: true, + payload: true, + created_at: true, + actor: { + select: { + name: true, + }, + }, + workflow_task: { + select: { + step_name: true, + }, + }, + }, + }) + : []; const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ where: { entity_type: "reimbursement", @@ -1992,6 +2088,14 @@ async function reimbursementRoutes(fastify) { submitterId: reimbursement.submitter_id, ownerId: reimbursement.owner_id, departmentId: reimbursement.department_id, + department: reimbursement.department + ? { + id: reimbursement.department.id, + name: reimbursement.department.name, + } + : null, + createdAt: reimbursement.created_at?.toISOString?.() ?? null, + submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null, reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10), totalAmount: toMoneyString(reimbursement.total_amount), status: reimbursement.status, @@ -2024,7 +2128,11 @@ async function reimbursementRoutes(fastify) { status: item.status, invoiceCode: item.invoice_code, invoiceNumber: item.invoice_number, + invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, + sellerName: item.seller_name ?? null, totalAmount: toMoneyString(item.total_amount), + originalName: item.original_name ?? null, + linkedItemId: item.linked_item_id ?? null, })), pendingWorkflow: pendingInstance ? { @@ -2034,6 +2142,7 @@ async function reimbursementRoutes(fastify) { } : null, workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null, + approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows), riskFlags: riskFlags.map(buildPersistedRiskFlag), }); }, diff --git a/server/tests/unit/routes/invoicePool.bindable.test.js b/server/tests/unit/routes/invoicePool.bindable.test.js new file mode 100644 index 00000000..1535209a --- /dev/null +++ b/server/tests/unit/routes/invoicePool.bindable.test.js @@ -0,0 +1,253 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const { registerInvoicePoolReadRoutes } = require("../../../src/routes/invoicePool/read"); + +const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; +const OWNER_ID = "22222222-2222-4222-8222-222222222222"; +const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333"; + +function buildApp({ + permissionCodes = ["invoice.view_own"], + employeeId = OWNER_ID, + reimbursement = { + id: REIMBURSEMENT_ID, + owner_id: OWNER_ID, + submitter_id: SUBMITTER_ID, + status: "draft", + }, + bindableItems = [], +} = {}) { + const prisma = { + reimbursementForm: { + findUnique: vi.fn(async ({ where }) => { + if (where.id !== REIMBURSEMENT_ID) { + return null; + } + return reimbursement; + }), + }, + invoicePool: { + findMany: vi.fn(async ({ where, take }) => + bindableItems + .filter((item) => { + if (item.owner_employee_id !== where.owner_employee_id) { + return false; + } + if (item.status !== where.status) { + return false; + } + if (item.linked_reimbursement_id !== where.linked_reimbursement_id) { + return false; + } + if (!where.OR?.length) { + return true; + } + + const haystacks = [item.invoice_code, item.invoice_number, item.seller_name] + .filter(Boolean) + .map((value) => String(value).toLowerCase()); + return where.OR.some((condition) => { + const [field, matcher] = Object.entries(condition)[0]; + const needle = String(matcher.contains).toLowerCase(); + const value = item[field] ? String(item[field]).toLowerCase() : ""; + return value.includes(needle) || haystacks.some((entry) => entry.includes(needle)); + }); + }) + .sort((left, right) => right.created_at.getTime() - left.created_at.getTime()) + .slice(0, take), + ), + }, + }; + + const app = Fastify(); + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { + id: employeeId, + permissionCodes, + roleCodes: [], + }; + }); + app.decorate( + "hasPermission", + (employee, permissionCode) => + (employee?.permissionCodes ?? []).includes("*") || + (employee?.permissionCodes ?? []).includes(permissionCode), + ); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + return app.register(registerInvoicePoolReadRoutes, { prefix: "/api" }).then(() => ({ app, prisma })); +} + +describe("GET /api/invoice-pool/bindable", () => { + it("returns bindable invoices for reimbursement submitter and uses reimbursement owner for filtering", async () => { + const bindableItems = [ + { + id: "invoice-2", + owner_employee_id: OWNER_ID, + status: "available", + linked_reimbursement_id: null, + invoice_code: "FP002", + invoice_number: "0002", + invoice_date: new Date("2026-04-08T00:00:00.000Z"), + seller_name: "上海供应商", + total_amount: "88.88", + original_name: "receipt-2.pdf", + created_at: new Date("2026-04-09T09:00:00.000Z"), + }, + { + id: "invoice-1", + owner_employee_id: OWNER_ID, + status: "available", + linked_reimbursement_id: null, + invoice_code: "FP001", + invoice_number: "0001", + invoice_date: new Date("2026-04-07T00:00:00.000Z"), + seller_name: "北京供应商", + total_amount: "66.66", + original_name: "receipt-1.pdf", + created_at: new Date("2026-04-08T09:00:00.000Z"), + }, + ]; + const { app, prisma } = await buildApp({ + employeeId: SUBMITTER_ID, + bindableItems, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}&limit=1`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.items).toHaveLength(1); + expect(response.json().data.items[0]).toMatchObject({ + id: "invoice-2", + invoiceCode: "FP002", + sellerName: "上海供应商", + totalAmount: "88.88", + }); + expect(prisma.invoicePool.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 1, + where: expect.objectContaining({ + owner_employee_id: OWNER_ID, + status: "available", + linked_reimbursement_id: null, + }), + }), + ); + + await app.close(); + }); + + it("rejects requests without invoice.view_own permission", async () => { + const { app } = await buildApp({ permissionCodes: [] }); + + const response = await app.inject({ + method: "GET", + url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + + await app.close(); + }); + + it("rejects reimbursements that are not owned or submitted by current user", async () => { + const { app } = await buildApp({ employeeId: "44444444-4444-4444-8444-444444444444" }); + + const response = await app.inject({ + method: "GET", + url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + + await app.close(); + }); + + it("rejects reimbursements not in draft or rejected status", async () => { + const { app } = await buildApp({ + reimbursement: { + id: REIMBURSEMENT_ID, + owner_id: OWNER_ID, + submitter_id: SUBMITTER_ID, + status: "reviewing", + }, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind" }); + + await app.close(); + }); + + it("applies fuzzy search and returns empty results when nothing matches", async () => { + const { app } = await buildApp({ + bindableItems: [ + { + id: "invoice-1", + owner_employee_id: OWNER_ID, + status: "available", + linked_reimbursement_id: null, + invoice_code: "A100", + invoice_number: "001", + invoice_date: new Date("2026-04-07T00:00:00.000Z"), + seller_name: "北京供应商", + total_amount: "66.66", + original_name: "receipt-1.pdf", + created_at: new Date("2026-04-08T09:00:00.000Z"), + }, + ], + }); + + const response = await app.inject({ + method: "GET", + url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}&search=%E4%B8%8A%E6%B5%B7`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.items).toEqual([]); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/reimbursements.detail.test.js b/server/tests/unit/routes/reimbursements.detail.test.js new file mode 100644 index 00000000..94615bfd --- /dev/null +++ b/server/tests/unit/routes/reimbursements.detail.test.js @@ -0,0 +1,302 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const reimbursementRoutes = require("../../../src/routes/reimbursements"); + +const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; +const OWNER_ID = "22222222-2222-4222-8222-222222222222"; +const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333"; +const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444"; +const PENDING_INSTANCE_ID = "55555555-5555-4555-8555-555555555555"; + +function buildReimbursementRecord(overrides = {}) { + return { + id: REIMBURSEMENT_ID, + form_number: "BX20260410001", + submitter_id: SUBMITTER_ID, + owner_id: OWNER_ID, + department_id: DEPARTMENT_ID, + created_at: new Date("2026-04-10T09:00:00.000Z"), + reimbursement_date: new Date("2026-04-10T00:00:00.000Z"), + total_amount: "188.80", + status: "reviewing", + description: "客户拜访报销", + project_id: null, + linked_travel_request_id: null, + department: { id: DEPARTMENT_ID, name: "华东销售部" }, + project: null, + travel_request: null, + owner: { id: OWNER_ID, employee_no: "E1001", name: "张三" }, + submitter: { id: SUBMITTER_ID, employee_no: "E1002", name: "李四" }, + items: [ + { + id: "item-1", + expense_type_id: "expense-type-1", + expense_date: new Date("2026-04-09T00:00:00.000Z"), + amount: "188.80", + description: "客户打车", + expense_type: { + id: "expense-type-1", + code: "travel_taxi", + name: "打车费", + category: "travel", + enabled: true, + }, + }, + ], + invoice_pool_items: [ + { + id: "invoice-1", + status: "bound", + invoice_code: "FP001", + invoice_number: "0001", + invoice_date: new Date("2026-04-09T00:00:00.000Z"), + seller_name: "上海供应商", + total_amount: "188.80", + original_name: "receipt.pdf", + linked_item_id: "item-1", + }, + ], + workflow_instances: [ + { + id: PENDING_INSTANCE_ID, + current_step_id: "finance_approve", + current_status: "pending", + context_data: { + workflow_steps_snapshot: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务审批" }, + ], + }, + workflow_definition: { + definition_json: { + steps: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务审批" }, + ], + }, + }, + workflow_tasks: [ + { + id: "task-1", + step_name: "财务审批", + assignee_id: "finance-1", + assignee_role: "finance", + }, + ], + }, + ], + ...overrides, + }; +} + +async function buildApp({ + employeeId = OWNER_ID, + permissionCodes = ["expense.view_own"], + reimbursement = buildReimbursementRecord(), + latestWorkflowInstance = { + id: PENDING_INSTANCE_ID, + current_step_id: "finance_approve", + created_at: new Date("2026-04-10T10:00:00.000Z"), + context_data: { + workflow_steps_snapshot: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务审批" }, + ], + }, + workflow_definition: { + definition_json: { + steps: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务审批" }, + ], + }, + }, + }, + workflowHistoryRows = [ + { + id: "history-start", + action: "start", + comment: null, + payload: { step_id: "manager_approve" }, + created_at: new Date("2026-04-10T10:00:00.000Z"), + actor: { name: "李四" }, + workflow_task: { step_name: "主管审批" }, + }, + { + id: "history-approve", + action: "approve", + comment: "同意", + payload: { from_step_id: "manager_approve", to_step_id: "finance_approve" }, + created_at: new Date("2026-04-10T12:00:00.000Z"), + actor: { name: "王经理" }, + workflow_task: { step_name: "主管审批" }, + }, + { + id: "history-skip", + action: "skip", + comment: null, + payload: { step_id: "ceo_approve" }, + created_at: new Date("2026-04-10T12:01:00.000Z"), + actor: { name: "系统" }, + workflow_task: null, + }, + ], +} = {}) { + const app = Fastify(); + const prisma = { + reimbursementForm: { + findUnique: vi.fn(async ({ where }) => { + if (where.id !== REIMBURSEMENT_ID) { + return null; + } + return reimbursement; + }), + }, + workflowInstance: { + findFirst: vi.fn(async () => latestWorkflowInstance), + }, + workflowHistory: { + findMany: vi.fn(async () => workflowHistoryRows), + }, + entityRiskFlag: { + findMany: vi.fn(async () => []), + }, + budgetPool: { + findMany: vi.fn(async () => []), + }, + }; + + app.decorate("prisma", prisma); + app.decorate("config", { + budgetControlMode: "warn", + appTimezone: "Asia/Shanghai", + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + + request.employee = { + id: employeeId, + permissionCodes, + roleCodes: [], + department_id: DEPARTMENT_ID, + }; + }); + app.decorate( + "hasPermission", + (employee, permissionCode) => + (employee?.permissionCodes ?? []).includes("*") || + (employee?.permissionCodes ?? []).includes(permissionCode), + ); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.decorate("assertEntityPermission", () => {}); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(reimbursementRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("GET /api/reimbursements/:id detail enhancements", () => { + it("returns department, submittedAt, expanded invoices and printable approvalHistory", async () => { + const { app } = await buildApp(); + + const response = await app.inject({ + method: "GET", + url: `/api/reimbursements/${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toMatchObject({ + department: { + id: DEPARTMENT_ID, + name: "华东销售部", + }, + createdAt: "2026-04-10T09:00:00.000Z", + submittedAt: "2026-04-10T10:00:00.000Z", + invoices: [ + { + id: "invoice-1", + invoiceCode: "FP001", + invoiceNumber: "0001", + invoiceDate: "2026-04-09", + sellerName: "上海供应商", + totalAmount: "188.80", + originalName: "receipt.pdf", + linkedItemId: "item-1", + }, + ], + approvalHistory: [ + { + id: "history-start", + stepName: "主管审批", + action: "start", + actorName: "李四", + comment: null, + createdAt: "2026-04-10T10:00:00.000Z", + }, + { + id: "history-approve", + stepName: "主管审批", + action: "approve", + actorName: "王经理", + comment: "同意", + createdAt: "2026-04-10T12:00:00.000Z", + }, + ], + }); + expect(response.json().data.workflowProgress).toMatchObject({ + currentStepIndex: 1, + totalSteps: 2, + }); + + await app.close(); + }); + + it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => { + const reimbursement = buildReimbursementRecord({ + status: "draft", + workflow_instances: [], + }); + const { app } = await buildApp({ + reimbursement, + latestWorkflowInstance: null, + workflowHistoryRows: [], + }); + + const response = await app.inject({ + method: "GET", + url: `/api/reimbursements/${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.submittedAt).toBe(null); + expect(response.json().data.approvalHistory).toEqual([]); + + await app.close(); + }); +});