R58 A1-A6:报销详情页内嵌发票绑定与单据打印

这个提交包含在:
2026-04-11 14:57:15 +08:00
父节点 88c987ae8f
当前提交 ba48e7d1d4
共修改 14 个文件,包含 1667 行新增和 12 行删除
+10
查看文件
@@ -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",
+1
查看文件
@@ -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",
+25
查看文件
@@ -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<string, string | number | undefined>) {
const params = new URLSearchParams();
@@ -139,6 +154,16 @@ export async function fetchInvoicePoolDetail(invoiceId: string) {
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}`);
}
export async function fetchBindableInvoices(query: {
reimbursementId: string;
search?: string;
limit?: number;
}) {
return apiRequestData<BindableInvoiceListResponse>(
`/api/invoice-pool/bindable${toQueryString(query as Record<string, string | number | undefined>)}`,
);
}
export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeId?: string) {
const formData = new FormData();
formData.append("file", file);
+20
查看文件
@@ -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[];
};
+481 -7
查看文件
@@ -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<string, string>;
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<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFile, setSelectedInvoiceFile] = useState<File | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [pendingOcrInvoiceId, setPendingOcrInvoiceId] = useState<string | null>(null);
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
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 (
<div className="card-std p-6">
@@ -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 (
<PageScaffold>
<EntityOverviewHero
@@ -464,9 +736,14 @@ export function ReimbursementDetailPage() {
</>
)}
actions={(
<div className="flex flex-wrap gap-2">
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">返回列表</Link>
</Button>
</div>
)}
/>
@@ -718,15 +995,69 @@ export function ReimbursementDetailPage() {
) : null}
</div>
<div className="space-y-2 text-sm" data-testid="reimbursement-invoices">
<div className="space-y-4 text-sm" data-testid="reimbursement-invoices">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
<p className="text-xs text-zinc-500">可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。</p>
</div>
{canOpenInvoiceBindDialog ? (
<Button
type="button"
variant="secondary"
onClick={() => handleInvoiceDialogOpenChange(true)}
>
添加发票
</Button>
) : canEditItems ? (
<p className="text-xs text-zinc-500">当前账号不可在此页绑定发票</p>
) : null}
</div>
{invoiceActionError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{invoiceActionError}
</p>
) : null}
{(detail.invoices ?? []).length ? (
<div className="space-y-2">
<div className="space-y-3">
{detail.invoices.map((item) => (
<div className="rounded-lg border border-zinc-200 px-3 py-2" key={item.id}>
<div className="font-medium text-zinc-900">{item.id}</div>
<div
className="rounded-lg border border-zinc-200 px-4 py-3"
key={item.id}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="font-medium text-zinc-900">
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
<div className="text-zinc-600">
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || item.id}
</div>
<div className="text-xs text-zinc-500">
绑定范围:{resolveLinkedItemLabel(item.linkedItemId)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{item.status}
</span>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(item.id)}
>
解绑
</Button>
) : null}
</div>
</div>
</div>
))}
@@ -891,6 +1222,149 @@ export function ReimbursementDetailPage() {
</div>
</div>
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>添加发票</DialogTitle>
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Input
placeholder="搜索发票号、发票代码或卖方名称"
value={invoiceSearchInput}
onChange={(event) => setInvoiceSearchInput(event.target.value)}
/>
<Button
type="button"
variant="secondary"
disabled={bindableInvoicesQuery.isFetching}
onClick={() => void bindableInvoicesQuery.refetch()}
>
{bindableInvoicesQuery.isFetching ? "刷新中..." : "刷新列表"}
</Button>
</div>
{invoiceActionError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{invoiceActionError}
</p>
) : null}
<div className="max-h-[360px] space-y-3 overflow-y-auto pr-1">
{bindableInvoicesQuery.isLoading ? (
<p className="text-sm text-zinc-500">正在加载可绑定发票...</p>
) : bindableInvoicesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可绑定发票失败")}
</p>
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
bindableInvoicesQuery.data?.items.map((item) => {
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
return (
<label
key={item.id}
className={`block rounded-lg border px-4 py-3 ${isSelected ? "border-primary-300 bg-primary-50/50" : "border-zinc-200"}`}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-zinc-300"
checked={isSelected}
onChange={(event) => toggleInvoiceSelection(item.id, event.target.checked)}
/>
<div className="space-y-1">
<div className="font-medium text-zinc-900">
发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
<div className="text-zinc-600">
{item.sellerName || "未识别卖方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || item.id} · 上传时间 {formatDateTime(item.createdAt)}
</div>
</div>
</div>
<div className="min-w-[220px] md:max-w-[240px]">
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
<select
className="select-std mt-1"
value={selectedInvoices[item.id] ?? ""}
disabled={!isSelected}
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
>
<option value="">不绑定具体明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? detailItem.expenseTypeId} · {detailItem.description ?? "未填写说明"}
</option>
))}
</select>
</div>
</div>
</label>
);
})
) : (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500">
当前没有可绑定发票,可直接在下方上传新发票。
</div>
)}
</div>
<div className="flex flex-col gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-600">已选 {selectedInvoiceEntries.length} 张发票</div>
<Button
type="button"
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
onClick={handleBindSelectedInvoices}
>
{bindInvoicesMutation.isPending ? "绑定中..." : "绑定选中发票"}
</Button>
</div>
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
<p className="text-xs text-zinc-500">上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。</p>
</div>
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
<label
htmlFor="reimbursement-inline-invoice-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedInvoiceFile ? selectedInvoiceFile.name : "选择发票文件"}
</label>
<input
id="reimbursement-inline-invoice-upload"
className="hidden"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
onChange={(event) => setSelectedInvoiceFile(event.target.files?.[0] ?? null)}
/>
<Button
type="button"
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
>
{uploadInvoiceMutation.isPending ? "上传中..." : "上传并等待 OCR"}
</Button>
</div>
{pendingOcrInvoiceId ? (
<p className="mt-3 text-xs text-zinc-500">
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
+325
查看文件
@@ -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<string, string> = {
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 (
<div className="card-std p-6">
<p className="text-sm text-red-600">缺少 reimbursementId 参数。</p>
</div>
);
}
if (detailQuery.isLoading) {
return <CardSkeleton lines={10} />;
}
if (detailQuery.isError || !detailQuery.data) {
const message = isApiError(detailQuery.error)
? `${detailQuery.error.code}: ${mapApiErrorMessage(
detailQuery.error.code,
detailQuery.error.details,
detailQuery.error.message,
)}`
: "加载打印视图失败";
return (
<div className="card-std space-y-3 p-6">
<p className="text-sm text-red-600">{message}</p>
<Button type="button" variant="secondary" onClick={() => navigate(`/reimbursements/${reimbursementId}`)}>
返回详情
</Button>
</div>
);
}
const detail = detailQuery.data;
const totalAmount = detail.totalAmount ?? "0.00";
const qrValue = `https://oa.hyinda.com/reimbursements/${detail.id}`;
return (
<div className="min-h-screen bg-zinc-100 print:bg-white">
<style>{`
@page {
size: A4;
margin: 2cm;
}
@media print {
body {
background: #fff;
}
}
`}</style>
<div className="sticky top-0 z-10 border-b border-zinc-200 bg-white/95 backdrop-blur print:hidden">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3">
<div>
<div className="text-sm font-semibold text-zinc-900">费用报销单打印视图</div>
<div className="text-xs text-zinc-500">浏览器可直接打印或另存为 PDF。</div>
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={() => navigate(`/reimbursements/${reimbursementId}`)}>
返回详情
</Button>
<Button type="button" onClick={() => window.print()}>
打印 / 保存为 PDF
</Button>
</div>
</div>
</div>
<div className="mx-auto max-w-5xl p-4 print:max-w-none print:p-0">
<div className="bg-white p-8 shadow-sm print:shadow-none">
<div className="flex items-start justify-between gap-6 border-b border-zinc-200 pb-6">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-zinc-300 text-sm font-semibold text-zinc-500">
LOGO
</div>
<div>
<h1 className="text-3xl font-semibold tracking-[0.2em] text-zinc-900">费用报销单</h1>
<p className="mt-2 text-sm text-zinc-500">标准打印稿,仅用于归档与线下签批留存。</p>
</div>
</div>
<div className="text-right">
<div className="text-sm text-zinc-500">表单号</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{detail.formNumber ?? detail.id}</div>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-zinc-200 p-4">
<div className="grid gap-3 text-sm">
<div className="flex justify-between gap-4">
<span className="text-zinc-500">报销人</span>
<span className="font-medium text-zinc-900">{detail.owner?.name ?? "-"}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-500">部门</span>
<span className="font-medium text-zinc-900">{detail.department?.name ?? "-"}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-500">报销日期</span>
<span className="font-medium text-zinc-900">{formatDate(detail.reimbursementDate)}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-500">提交日期</span>
<span className="font-medium text-zinc-900">{formatDateTime(detail.submittedAt)}</span>
</div>
</div>
</div>
<div className="rounded-xl border border-zinc-200 p-4">
<div className="grid gap-3 text-sm">
<div className="flex justify-between gap-4">
<span className="text-zinc-500">当前状态</span>
<span className="font-medium text-zinc-900">{detail.status}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-500">提交人</span>
<span className="font-medium text-zinc-900">{detail.submitter?.name ?? "-"}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-500">表单编号</span>
<span className="font-medium text-zinc-900">{detail.formNumber ?? detail.id}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-500">总金额</span>
<span className="text-2xl font-semibold text-zinc-900">¥{totalAmount}</span>
</div>
</div>
</div>
</div>
<section className="mt-8">
<div className="mb-3 text-base font-semibold text-zinc-900">报销明细</div>
<div className="overflow-hidden rounded-xl border border-zinc-200">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-zinc-50">
<tr>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">序号</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">费用日期</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">费用类型</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">说明</th>
<th className="border-b border-zinc-200 px-3 py-2 text-right font-medium">金额</th>
</tr>
</thead>
<tbody>
{detail.items.map((item, index) => (
<tr key={item.id}>
<td className="border-b border-zinc-100 px-3 py-2">{index + 1}</td>
<td className="border-b border-zinc-100 px-3 py-2">{formatDate(item.expenseDate)}</td>
<td className="border-b border-zinc-100 px-3 py-2">{item.expenseType?.name ?? item.expenseTypeId}</td>
<td className="border-b border-zinc-100 px-3 py-2">{item.description ?? "-"}</td>
<td className="border-b border-zinc-100 px-3 py-2 text-right">¥{item.amount}</td>
</tr>
))}
<tr className="bg-zinc-50">
<td className="px-3 py-2 font-medium" colSpan={4}>合计</td>
<td className="px-3 py-2 text-right font-semibold">¥{totalAmount}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section className="mt-8">
<div className="mb-3 text-base font-semibold text-zinc-900">发票清单</div>
<div className="overflow-hidden rounded-xl border border-zinc-200">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-zinc-50">
<tr>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">发票号</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">发票日期</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">卖方</th>
<th className="border-b border-zinc-200 px-3 py-2 text-right font-medium">金额</th>
</tr>
</thead>
<tbody>
{detail.invoices.length ? (
detail.invoices.map((invoice) => (
<tr key={invoice.id}>
<td className="border-b border-zinc-100 px-3 py-2">
{invoice.invoiceCode || "-"}-{invoice.invoiceNumber || "-"}
</td>
<td className="border-b border-zinc-100 px-3 py-2">{formatDate(invoice.invoiceDate)}</td>
<td className="border-b border-zinc-100 px-3 py-2">{invoice.sellerName || "-"}</td>
<td className="border-b border-zinc-100 px-3 py-2 text-right">¥{invoice.totalAmount ?? "-"}</td>
</tr>
))
) : (
<tr>
<td className="px-3 py-3 text-zinc-500" colSpan={4}>暂无关联发票</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="mt-8">
<div className="mb-3 text-base font-semibold text-zinc-900">审批记录</div>
<div className="overflow-hidden rounded-xl border border-zinc-200">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-zinc-50">
<tr>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">步骤</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">审批人</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">审批时间</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">状态</th>
<th className="border-b border-zinc-200 px-3 py-2 text-left font-medium">意见</th>
</tr>
</thead>
<tbody>
{detail.approvalHistory.length ? (
detail.approvalHistory.map((record) => (
<tr key={record.id}>
<td className="border-b border-zinc-100 px-3 py-2">{record.stepName ?? "-"}</td>
<td className="border-b border-zinc-100 px-3 py-2">{record.actorName || "-"}</td>
<td className="border-b border-zinc-100 px-3 py-2">{formatDateTime(record.createdAt)}</td>
<td className="border-b border-zinc-100 px-3 py-2">{formatActionLabel(record.action)}</td>
<td className="border-b border-zinc-100 px-3 py-2">{record.comment || "-"}</td>
</tr>
))
) : (
<tr>
<td className="px-3 py-3 text-zinc-500" colSpan={5}>暂无审批记录</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{(detail.linkedTravelRequest || detail.project) ? (
<section className="mt-8">
<div className="mb-3 text-base font-semibold text-zinc-900">关联信息</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-zinc-200 p-4 text-sm">
<div className="text-zinc-500">关联差旅申请</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest
? `${detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id} · ${detail.linkedTravelRequest.purpose ?? "未填写目的"}`
: "未关联"}
</div>
</div>
<div className="rounded-xl border border-zinc-200 p-4 text-sm">
<div className="text-zinc-500">关联项目</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</div>
</div>
</div>
</section>
) : null}
<section className="mt-8 flex items-end justify-between gap-6 border-t border-zinc-200 pt-6">
<div className="flex items-center gap-4">
<div className="rounded-xl border border-zinc-200 p-3">
<QRCodeSVG value={qrValue} size={96} includeMargin />
</div>
<div className="text-sm text-zinc-600">
<div className="font-medium text-zinc-900">扫码查看线上原件</div>
<div className="mt-1 break-all">{qrValue}</div>
</div>
</div>
<div className="text-right text-sm text-zinc-500">
<div>打印时间:{printTime}</div>
<div className="mt-1">打印人:{me?.name ?? "-"}</div>
</div>
</section>
</div>
</div>
</div>
);
}
export default ReimbursementPrintPage;
+10
查看文件
@@ -51,6 +51,7 @@ import {
PurchaseRequestsPage,
ReimbursementDetailPage,
ReimbursementDashboardPage,
ReimbursementPrintPage,
ReimbursementTemplatesPage,
ReimbursementsPage,
TravelRequestsPage,
@@ -177,6 +178,15 @@ export function AppRouter() {
}
/>
<Route
path="reimbursements/:reimbursementId/print"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<ReimbursementPrintPage />)}
</RequirePermission>
}
/>
<Route
path="leave-requests"
element={
@@ -0,0 +1 @@
export { ReimbursementPrintPage as default } from "../../pages/ReimbursementPrintPage";
+1
查看文件
@@ -42,6 +42,7 @@ export const ContractsRegistryPage = lazy(() => 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"));
@@ -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_* 凭据被脚本自动跳过。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 不影响业务。
3 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 路由与报销详情新增字段映射,恢复原详情响应。
4 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"] 回滚路径:恢复详情页只读票据展示,移除内嵌绑定弹窗与上传轮询逻辑。
5 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 依赖。
6 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 发现权限或数据泄露风险,停止提交并回退对应改动。
7 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_* 凭据被脚本自动跳过。
+117
查看文件
@@ -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",
{
+109
查看文件
@@ -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),
});
},
@@ -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();
});
});
@@ -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();
});
});