R58 A1-A6:报销详情页内嵌发票绑定与单据打印
这个提交包含在:
+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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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={(
|
||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||
<Link to="/reimbursements">返回列表</Link>
|
||||
</Button>
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
|
||||
<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="text-zinc-600">
|
||||
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
||||
<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.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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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_* 凭据被脚本自动跳过。"
|
||||
|
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户