feat: 完成 F17 报表导出(CSV/XLSX)\n\n- 后端新增 /api/exports/* 导出接口(含权限与审计记录)\n- CSV 采用流式生成,XLSX 使用 exceljs\n- 前端在报销/票据/审计页加入导出入口(下载 Blob)\n- 更新 P5 蓝图 v3 中 F17 状态为 DONE
这个提交包含在:
@@ -0,0 +1,45 @@
|
|||||||
|
import { apiRequestBlob, type ApiBlobResponse } from "./http";
|
||||||
|
|
||||||
|
export async function exportReimbursements(params: {
|
||||||
|
format: "csv" | "xlsx";
|
||||||
|
status?: string;
|
||||||
|
scope?: "own" | "all";
|
||||||
|
}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("format", params.format);
|
||||||
|
if (params.status) qs.set("status", params.status);
|
||||||
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
|
|
||||||
|
return apiRequestBlob(`/api/exports/reimbursements?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportInvoicePool(params: {
|
||||||
|
format: "csv" | "xlsx";
|
||||||
|
status?: string;
|
||||||
|
ownerScope?: "own" | "all";
|
||||||
|
}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("format", params.format);
|
||||||
|
if (params.status) qs.set("status", params.status);
|
||||||
|
if (params.ownerScope) qs.set("ownerScope", params.ownerScope);
|
||||||
|
|
||||||
|
return apiRequestBlob(`/api/exports/invoice-pool?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAuditLogs(params: {
|
||||||
|
format: "csv" | "xlsx";
|
||||||
|
action?: string;
|
||||||
|
targetType?: string;
|
||||||
|
actorId?: string;
|
||||||
|
}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("format", params.format);
|
||||||
|
if (params.action) qs.set("action", params.action);
|
||||||
|
if (params.targetType) qs.set("targetType", params.targetType);
|
||||||
|
if (params.actorId) qs.set("actorId", params.actorId);
|
||||||
|
|
||||||
|
return apiRequestBlob(`/api/exports/audit-logs?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ApiBlobResponse };
|
||||||
|
|
||||||
@@ -153,3 +153,77 @@ export async function apiRequestData<T>(
|
|||||||
const response = await apiRequest<T>(path, options);
|
const response = await apiRequest<T>(path, options);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseFilenameFromContentDisposition(headerValue: string | null): string | null {
|
||||||
|
if (!headerValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = headerValue.split(";").map((part) => part.trim());
|
||||||
|
const filenamePart = parts.find((part) => part.toLowerCase().startsWith("filename="));
|
||||||
|
if (!filenamePart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = filenamePart.slice("filename=".length).trim();
|
||||||
|
return raw.replace(/^\"|\"$/g, "") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiBlobResponse = {
|
||||||
|
requestId: string;
|
||||||
|
blob: Blob;
|
||||||
|
filename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function apiRequestBlob(
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions = {},
|
||||||
|
): Promise<ApiBlobResponse> {
|
||||||
|
const {
|
||||||
|
auth = true,
|
||||||
|
retryOn401 = true,
|
||||||
|
accessTokenOverride,
|
||||||
|
headers,
|
||||||
|
...rest
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const requestHeaders = new Headers(headers ?? {});
|
||||||
|
const token = accessTokenOverride ?? (auth ? getAccessToken() : null);
|
||||||
|
|
||||||
|
if (auth && token) {
|
||||||
|
requestHeaders.set("authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...rest,
|
||||||
|
credentials: "include",
|
||||||
|
headers: requestHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401 && auth && retryOn401 && refreshAccessToken) {
|
||||||
|
const nextToken = await getRefreshedToken();
|
||||||
|
if (nextToken) {
|
||||||
|
return apiRequestBlob(path, {
|
||||||
|
...options,
|
||||||
|
retryOn401: false,
|
||||||
|
accessTokenOverride: nextToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearSession) {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await safeParseJson(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw buildApiError(response, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return {
|
||||||
|
requestId: response.headers.get("x-request-id") ?? "",
|
||||||
|
blob,
|
||||||
|
filename: parseFilenameFromContentDisposition(response.headers.get("content-disposition")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.rel = "noreferrer";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
|
import { exportAuditLogs } from "../api/exports";
|
||||||
import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit";
|
import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
|
import { downloadBlob } from "../lib/download";
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ export function AuditLogsPage() {
|
|||||||
const [actionFilter, setActionFilter] = useState("");
|
const [actionFilter, setActionFilter] = useState("");
|
||||||
const [targetTypeFilter, setTargetTypeFilter] = useState("");
|
const [targetTypeFilter, setTargetTypeFilter] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [data, setData] = useState<AuditLogListResponse | null>(null);
|
const [data, setData] = useState<AuditLogListResponse | null>(null);
|
||||||
|
|
||||||
@@ -63,7 +68,71 @@ export function AuditLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="审计工作台" subtitle="只读查看系统关键操作审计记录。" />
|
<PageHeader
|
||||||
|
title="审计工作台"
|
||||||
|
subtitle="只读查看系统关键操作审计记录。"
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="secondary" type="button" disabled={exporting} data-testid="audit-export">
|
||||||
|
{exporting ? "导出中..." : "导出"}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="audit-export-csv"
|
||||||
|
onSelect={async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const response = await exportAuditLogs({
|
||||||
|
format: "csv",
|
||||||
|
action: actionFilter || undefined,
|
||||||
|
targetType: targetTypeFilter || undefined,
|
||||||
|
});
|
||||||
|
downloadBlob(response.blob, response.filename ?? "audit-logs.csv");
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
setError("导出审计日志失败,请稍后重试");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
导出 CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="audit-export-xlsx"
|
||||||
|
onSelect={async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const response = await exportAuditLogs({
|
||||||
|
format: "xlsx",
|
||||||
|
action: actionFilter || undefined,
|
||||||
|
targetType: targetTypeFilter || undefined,
|
||||||
|
});
|
||||||
|
downloadBlob(response.blob, response.filename ?? "audit-logs.xlsx");
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
setError("导出审计日志失败,请稍后重试");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
导出 Excel
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="card-std space-y-4">
|
<section className="card-std space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchMyDelegations } from "../api/delegations";
|
import { fetchMyDelegations } from "../api/delegations";
|
||||||
|
import { exportInvoicePool } from "../api/exports";
|
||||||
import {
|
import {
|
||||||
archiveInvoice,
|
archiveInvoice,
|
||||||
bindInvoice,
|
bindInvoice,
|
||||||
@@ -19,9 +20,11 @@ import {
|
|||||||
import { fetchReimbursements } from "../api/reimbursements";
|
import { fetchReimbursements } from "../api/reimbursements";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { downloadBlob } from "../lib/download";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
import { Card } from "../components/ui/card";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -267,6 +270,26 @@ export function InvoicePoolPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: (format: "csv" | "xlsx") =>
|
||||||
|
exportInvoicePool({
|
||||||
|
format,
|
||||||
|
status: status || undefined,
|
||||||
|
ownerScope,
|
||||||
|
}),
|
||||||
|
onSuccess: (response, format) => {
|
||||||
|
downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`);
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
} else {
|
||||||
|
setError("导出失败,请稍后重试");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const canEditDetail = Boolean(
|
const canEditDetail = Boolean(
|
||||||
@@ -281,6 +304,28 @@ export function InvoicePoolPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="票据中心"
|
title="票据中心"
|
||||||
subtitle="支持上传、OCR 状态轮询、绑定报销与签名预览。"
|
subtitle="支持上传、OCR 状态轮询、绑定报销与签名预览。"
|
||||||
|
actions={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
data-testid="invoice-export"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
>
|
||||||
|
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
|
||||||
|
导出 CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
|
||||||
|
导出 Excel
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchMyDelegations } from "../api/delegations";
|
import { fetchMyDelegations } from "../api/delegations";
|
||||||
|
import { exportReimbursements } from "../api/exports";
|
||||||
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { downloadBlob } from "../lib/download";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
import { Card } from "../components/ui/card";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
|
|
||||||
@@ -19,6 +22,7 @@ export function ReimbursementsPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { permissions, me } = useAuth();
|
const { permissions, me } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [exportError, setExportError] = useState("");
|
||||||
|
|
||||||
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
||||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
@@ -51,6 +55,21 @@ export function ReimbursementsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: (format: "csv" | "xlsx") => exportReimbursements({ format, status, scope }),
|
||||||
|
onSuccess: (response, format) => {
|
||||||
|
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
|
||||||
|
setExportError("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExportError(err instanceof Error ? err.message : "导出失败");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,6 +105,33 @@ export function ReimbursementsPage() {
|
|||||||
>
|
>
|
||||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursements-export"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
>
|
||||||
|
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="reimbursements-export-csv"
|
||||||
|
onSelect={() => exportMutation.mutate("csv")}
|
||||||
|
>
|
||||||
|
导出 CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="reimbursements-export-xlsx"
|
||||||
|
onSelect={() => exportMutation.mutate("xlsx")}
|
||||||
|
>
|
||||||
|
导出 Excel
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -101,6 +147,12 @@ export function ReimbursementsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{exportError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{exportError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card className="space-y-4 p-6">
|
<Card className="space-y-4 p-6">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ F13,signed-url审计与下载行为记录,"对 signed-url 签发与下载两类
|
|||||||
F14,站内通知基础设施(DB+API),"新增 notifications 表与 API(list/markRead),用于工作流与系统事件通知","可分页读取;可标记已读;权限隔离正确;最小索引满足试运行","cd server && npm run prisma:migrate:dev && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/prisma/schema.prisma | server/src/routes/notifications.js | server/src/app.js","[""F1""]","只做最小字段:title/body/type/targetId/readAt"
|
F14,站内通知基础设施(DB+API),"新增 notifications 表与 API(list/markRead),用于工作流与系统事件通知","可分页读取;可标记已读;权限隔离正确;最小索引满足试运行","cd server && npm run prisma:migrate:dev && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/prisma/schema.prisma | server/src/routes/notifications.js | server/src/app.js","[""F1""]","只做最小字段:title/body/type/targetId/readAt"
|
||||||
F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,DONE,DONE,DONE,"server/src/services/email/* | server/src/config.js | server/src/routes/auth.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/package.json | server/README.md","[""F14""]","优先复用成熟 nodemailer;写回滚说明"
|
F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,DONE,DONE,DONE,"server/src/services/email/* | server/src/config.js | server/src/routes/auth.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/package.json | server/README.md","[""F14""]","优先复用成熟 nodemailer;写回滚说明"
|
||||||
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
|
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
|
||||||
F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,TODO,TODO,TODO,"server/src/routes/exports.js | client/src/pages/* | server/package.json","[""F6"",""F16""]","推荐 exceljs 或 csv-stringify(二选一并写 Notes:用途/版本/回滚)"
|
F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,DONE,DONE,DONE,"server/src/routes/exports.js | server/src/app.js | server/package.json | server/package-lock.json | client/src/api/exports.ts | client/src/api/http.ts | client/src/lib/download.ts | client/src/pages/ReimbursementsPage.tsx | client/src/pages/InvoicePoolPage.tsx | client/src/pages/AuditLogsPage.tsx","[""F6"",""F16""]","选型:exceljs(XLSX),CSV 采用流式生成;回滚:删除 exportsRoutes 注册与依赖即可"
|
||||||
F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/routes/approvalRules.js | server/src/app.js | client/src/pages/ApprovalRulesPage.tsx | client/src/api/approvalRules.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx","[""F5""]","只做最小可运营;复杂可视化后置"
|
F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/routes/approvalRules.js | server/src/app.js | client/src/pages/ApprovalRulesPage.tsx | client/src/api/approvalRules.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx","[""F5""]","只做最小可运营;复杂可视化后置"
|
||||||
F19,作业系统升级(pg-boss 基础设施),"引入 pg-boss(Postgres 队列)作为作业基础设施:提供统一注册/执行框架与开关;保持 node-cron 作业可回滚;并文档化单机用法","pg-boss 可在单机/多进程下可靠运行;最小示例 job 可跑通;不影响现有回归","cd server && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/* | server/src/app.js | server/package.json | deploy/trialrun/* | server/README.md","[""F1""]","选择 pg-boss(成熟/无 Redis);回滚:关闭开关并切回 node-cron"
|
F19,作业系统升级(pg-boss 基础设施),"引入 pg-boss(Postgres 队列)作为作业基础设施:提供统一注册/执行框架与开关;保持 node-cron 作业可回滚;并文档化单机用法","pg-boss 可在单机/多进程下可靠运行;最小示例 job 可跑通;不影响现有回归","cd server && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/* | server/src/app.js | server/package.json | deploy/trialrun/* | server/README.md","[""F1""]","选择 pg-boss(成熟/无 Redis);回滚:关闭开关并切回 node-cron"
|
||||||
F19b,作业迁移(OCR -> pg-boss),"将 OCR 异步处理迁移到 pg-boss:保持并发锁语义、幂等与回归脚本不变","mock/tencent 两种 provider 下 OCR 仍可跑;并发下单条仅处理一次;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/ocrJob.js | server/src/services/ocr/* | server/README.md","[""F19""]","迁移后移除/停用对应 node-cron tick(保留回滚路径)"
|
F19b,作业迁移(OCR -> pg-boss),"将 OCR 异步处理迁移到 pg-boss:保持并发锁语义、幂等与回归脚本不变","mock/tencent 两种 provider 下 OCR 仍可跑;并发下单条仅处理一次;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/ocrJob.js | server/src/services/ocr/* | server/README.md","[""F19""]","迁移后移除/停用对应 node-cron tick(保留回滚路径)"
|
||||||
|
|||||||
|
+1023
文件差异内容过多而无法显示
加载差异
@@ -35,6 +35,7 @@
|
|||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const expenseTypesRoutes = require("./routes/expenseTypes");
|
|||||||
const configReadRoutes = require("./routes/configRead");
|
const configReadRoutes = require("./routes/configRead");
|
||||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||||
const notificationsRoutes = require("./routes/notifications");
|
const notificationsRoutes = require("./routes/notifications");
|
||||||
|
const exportsRoutes = require("./routes/exports");
|
||||||
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
||||||
const { startOcrJob } = require("./jobs/ocrJob");
|
const { startOcrJob } = require("./jobs/ocrJob");
|
||||||
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
|
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
|
||||||
@@ -204,6 +205,7 @@ async function buildApp() {
|
|||||||
await app.register(configReadRoutes, { prefix: "/api" });
|
await app.register(configReadRoutes, { prefix: "/api" });
|
||||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||||
|
await app.register(exportsRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
startDelegationRevokeJob(app);
|
startDelegationRevokeJob(app);
|
||||||
startOcrJob(app);
|
startOcrJob(app);
|
||||||
|
|||||||
@@ -0,0 +1,737 @@
|
|||||||
|
const { Readable } = require("node:stream");
|
||||||
|
|
||||||
|
const ExcelJS = require("exceljs");
|
||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
|
||||||
|
const EXPORT_FORMAT_SCHEMA = z.enum(["csv", "xlsx"]);
|
||||||
|
|
||||||
|
const REIMBURSEMENT_STATUS_SCHEMA = z.enum([
|
||||||
|
"draft",
|
||||||
|
"reviewing",
|
||||||
|
"approved",
|
||||||
|
"paid",
|
||||||
|
"rejected",
|
||||||
|
"cancelled",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REIMBURSEMENT_EXPORT_QUERY_SCHEMA = z.object({
|
||||||
|
format: EXPORT_FORMAT_SCHEMA.optional(),
|
||||||
|
scope: z.enum(["own", "all"]).optional(),
|
||||||
|
status: REIMBURSEMENT_STATUS_SCHEMA.optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(20000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const INVOICE_EXPORT_QUERY_SCHEMA = z.object({
|
||||||
|
format: EXPORT_FORMAT_SCHEMA.optional(),
|
||||||
|
ownerScope: z.enum(["own", "all"]).optional(),
|
||||||
|
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(20000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AUDIT_EXPORT_QUERY_SCHEMA = z.object({
|
||||||
|
format: EXPORT_FORMAT_SCHEMA.optional(),
|
||||||
|
actorId: z.string().uuid().optional(),
|
||||||
|
action: z.string().min(1).max(100).optional(),
|
||||||
|
targetType: z.string().min(1).max(100).optional(),
|
||||||
|
dateFrom: z.string().datetime().optional(),
|
||||||
|
dateTo: z.string().datetime().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(50000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function csvCell(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
|
||||||
|
const text = String(value);
|
||||||
|
if (/[",\n\r]/.test(text)) {
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvRow(values) {
|
||||||
|
return `${values.map(csvCell).join(",")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuery(schema, query) {
|
||||||
|
const parsed = schema.safeParse(query ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExportFormat(query) {
|
||||||
|
return query.format ?? "csv";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHasAnyPermission(fastify, request, permissionCodes) {
|
||||||
|
for (const code of permissionCodes) {
|
||||||
|
if (fastify.hasPermission(request.employee, code)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOwnReimbursementScopeWhere(employeeId) {
|
||||||
|
return {
|
||||||
|
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGlobalViewRole(roleCodes) {
|
||||||
|
return roleCodes.some((code) =>
|
||||||
|
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildViewAllScopeWhere(request) {
|
||||||
|
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ owner_id: request.employee.id },
|
||||||
|
{ submitter_id: request.employee.id },
|
||||||
|
{ owner: { direct_manager_id: request.employee.id } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFilename(prefix, format) {
|
||||||
|
const now = new Date();
|
||||||
|
const stamp = now.toISOString().slice(0, 10);
|
||||||
|
return `${prefix}-${stamp}.${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExportHeaders(reply, format, filename) {
|
||||||
|
if (format === "csv") {
|
||||||
|
reply.header("content-type", "text/csv; charset=utf-8");
|
||||||
|
} else {
|
||||||
|
reply.header(
|
||||||
|
"content-type",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header("content-disposition", `attachment; filename=\"${filename}\"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* streamReimbursementsCsv(prisma, where, limit) {
|
||||||
|
yield "\ufeff";
|
||||||
|
yield csvRow([
|
||||||
|
"formNumber",
|
||||||
|
"status",
|
||||||
|
"totalAmount",
|
||||||
|
"ownerName",
|
||||||
|
"submitterName",
|
||||||
|
"reimbursementDate",
|
||||||
|
"paymentVoucherNo",
|
||||||
|
"paymentDate",
|
||||||
|
"paymentChannel",
|
||||||
|
"paidAt",
|
||||||
|
"createdAt",
|
||||||
|
"id",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const batchSize = 500;
|
||||||
|
let remaining = limit;
|
||||||
|
let cursorCreatedAt = null;
|
||||||
|
let cursorId = null;
|
||||||
|
|
||||||
|
while (remaining > 0) {
|
||||||
|
const take = Math.min(batchSize, remaining);
|
||||||
|
const rows = await prisma.reimbursementForm.findMany({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
...(cursorCreatedAt
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ created_at: { lt: cursorCreatedAt } },
|
||||||
|
{ created_at: cursorCreatedAt, id: { lt: cursorId } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
form_number: true,
|
||||||
|
status: true,
|
||||||
|
total_amount: true,
|
||||||
|
reimbursement_date: true,
|
||||||
|
payment_voucher_no: true,
|
||||||
|
payment_date: true,
|
||||||
|
payment_channel: true,
|
||||||
|
paid_at: true,
|
||||||
|
created_at: true,
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
submitter: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of rows) {
|
||||||
|
yield csvRow([
|
||||||
|
item.form_number ?? "",
|
||||||
|
item.status,
|
||||||
|
item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
|
||||||
|
item.owner?.name ?? "",
|
||||||
|
item.submitter?.name ?? "",
|
||||||
|
item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||||
|
item.payment_voucher_no ?? "",
|
||||||
|
item.payment_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||||
|
item.payment_channel ?? "",
|
||||||
|
item.paid_at?.toISOString?.() ?? "",
|
||||||
|
item.created_at?.toISOString?.() ?? "",
|
||||||
|
item.id,
|
||||||
|
]);
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
cursorCreatedAt = last.created_at;
|
||||||
|
cursorId = last.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReimbursementsXlsx(prisma, where, limit) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet("reimbursements");
|
||||||
|
|
||||||
|
sheet.columns = [
|
||||||
|
{ header: "单号", key: "formNumber", width: 18 },
|
||||||
|
{ header: "状态", key: "status", width: 12 },
|
||||||
|
{ header: "金额", key: "totalAmount", width: 12 },
|
||||||
|
{ header: "报销人", key: "ownerName", width: 14 },
|
||||||
|
{ header: "提交人", key: "submitterName", width: 14 },
|
||||||
|
{ header: "报销日期", key: "reimbursementDate", width: 14 },
|
||||||
|
{ header: "付款凭证号", key: "paymentVoucherNo", width: 18 },
|
||||||
|
{ header: "付款日期", key: "paymentDate", width: 14 },
|
||||||
|
{ header: "付款渠道", key: "paymentChannel", width: 12 },
|
||||||
|
{ header: "付款时间", key: "paidAt", width: 20 },
|
||||||
|
{ header: "创建时间", key: "createdAt", width: 20 },
|
||||||
|
{ header: "ID", key: "id", width: 36 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = await prisma.reimbursementForm.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
form_number: true,
|
||||||
|
status: true,
|
||||||
|
total_amount: true,
|
||||||
|
reimbursement_date: true,
|
||||||
|
payment_voucher_no: true,
|
||||||
|
payment_date: true,
|
||||||
|
payment_channel: true,
|
||||||
|
paid_at: true,
|
||||||
|
created_at: true,
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
submitter: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of rows) {
|
||||||
|
sheet.addRow({
|
||||||
|
formNumber: item.form_number ?? "",
|
||||||
|
status: item.status,
|
||||||
|
totalAmount: item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
|
||||||
|
ownerName: item.owner?.name ?? "",
|
||||||
|
submitterName: item.submitter?.name ?? "",
|
||||||
|
reimbursementDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||||
|
paymentVoucherNo: item.payment_voucher_no ?? "",
|
||||||
|
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||||
|
paymentChannel: item.payment_channel ?? "",
|
||||||
|
paidAt: item.paid_at?.toISOString?.() ?? "",
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? "",
|
||||||
|
id: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return workbook.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* streamInvoicesCsv(prisma, where, limit) {
|
||||||
|
yield "\ufeff";
|
||||||
|
yield csvRow([
|
||||||
|
"status",
|
||||||
|
"ownerName",
|
||||||
|
"uploadedByName",
|
||||||
|
"invoiceCode",
|
||||||
|
"invoiceNumber",
|
||||||
|
"invoiceDate",
|
||||||
|
"totalAmount",
|
||||||
|
"archivedReason",
|
||||||
|
"linkedReimbursementId",
|
||||||
|
"ocrLastError",
|
||||||
|
"createdAt",
|
||||||
|
"id",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const batchSize = 500;
|
||||||
|
let remaining = limit;
|
||||||
|
let cursorCreatedAt = null;
|
||||||
|
let cursorId = null;
|
||||||
|
|
||||||
|
while (remaining > 0) {
|
||||||
|
const take = Math.min(batchSize, remaining);
|
||||||
|
const rows = await prisma.invoicePool.findMany({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
...(cursorCreatedAt
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ created_at: { lt: cursorCreatedAt } },
|
||||||
|
{ created_at: cursorCreatedAt, id: { lt: cursorId } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
archived_reason: true,
|
||||||
|
owner_employee: { select: { name: true } },
|
||||||
|
uploaded_by_employee: { select: { name: true } },
|
||||||
|
invoice_code: true,
|
||||||
|
invoice_number: true,
|
||||||
|
invoice_date: true,
|
||||||
|
total_amount: true,
|
||||||
|
linked_reimbursement_id: true,
|
||||||
|
ocr_last_error: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of rows) {
|
||||||
|
yield csvRow([
|
||||||
|
item.status,
|
||||||
|
item.owner_employee?.name ?? "",
|
||||||
|
item.uploaded_by_employee?.name ?? "",
|
||||||
|
item.invoice_code ?? "",
|
||||||
|
item.invoice_number ?? "",
|
||||||
|
item.invoice_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||||
|
item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
|
||||||
|
item.archived_reason ?? "",
|
||||||
|
item.linked_reimbursement_id ?? "",
|
||||||
|
item.ocr_last_error ?? "",
|
||||||
|
item.created_at?.toISOString?.() ?? "",
|
||||||
|
item.id,
|
||||||
|
]);
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
cursorCreatedAt = last.created_at;
|
||||||
|
cursorId = last.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildInvoicesXlsx(prisma, where, limit) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet("invoice_pool");
|
||||||
|
|
||||||
|
sheet.columns = [
|
||||||
|
{ header: "状态", key: "status", width: 12 },
|
||||||
|
{ header: "归属人", key: "ownerName", width: 14 },
|
||||||
|
{ header: "上传人", key: "uploadedByName", width: 14 },
|
||||||
|
{ header: "发票代码", key: "invoiceCode", width: 14 },
|
||||||
|
{ header: "发票号码", key: "invoiceNumber", width: 14 },
|
||||||
|
{ header: "开票日期", key: "invoiceDate", width: 14 },
|
||||||
|
{ header: "总金额", key: "totalAmount", width: 12 },
|
||||||
|
{ header: "归档原因", key: "archivedReason", width: 14 },
|
||||||
|
{ header: "关联报销单", key: "linkedReimbursementId", width: 36 },
|
||||||
|
{ header: "OCR错误", key: "ocrLastError", width: 18 },
|
||||||
|
{ header: "创建时间", key: "createdAt", width: 20 },
|
||||||
|
{ header: "ID", key: "id", width: 36 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = await prisma.invoicePool.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
archived_reason: true,
|
||||||
|
owner_employee: { select: { name: true } },
|
||||||
|
uploaded_by_employee: { select: { name: true } },
|
||||||
|
invoice_code: true,
|
||||||
|
invoice_number: true,
|
||||||
|
invoice_date: true,
|
||||||
|
total_amount: true,
|
||||||
|
linked_reimbursement_id: true,
|
||||||
|
ocr_last_error: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of rows) {
|
||||||
|
sheet.addRow({
|
||||||
|
status: item.status,
|
||||||
|
ownerName: item.owner_employee?.name ?? "",
|
||||||
|
uploadedByName: item.uploaded_by_employee?.name ?? "",
|
||||||
|
invoiceCode: item.invoice_code ?? "",
|
||||||
|
invoiceNumber: item.invoice_number ?? "",
|
||||||
|
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? "",
|
||||||
|
totalAmount: item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
|
||||||
|
archivedReason: item.archived_reason ?? "",
|
||||||
|
linkedReimbursementId: item.linked_reimbursement_id ?? "",
|
||||||
|
ocrLastError: item.ocr_last_error ?? "",
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? "",
|
||||||
|
id: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return workbook.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* streamAuditLogsCsv(prisma, where, limit) {
|
||||||
|
yield "\ufeff";
|
||||||
|
yield csvRow([
|
||||||
|
"createdAt",
|
||||||
|
"action",
|
||||||
|
"actorName",
|
||||||
|
"actorEmail",
|
||||||
|
"targetType",
|
||||||
|
"targetId",
|
||||||
|
"requestId",
|
||||||
|
"ipAddress",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const batchSize = 1000;
|
||||||
|
let remaining = limit;
|
||||||
|
let cursorCreatedAt = null;
|
||||||
|
let cursorId = null;
|
||||||
|
|
||||||
|
while (remaining > 0) {
|
||||||
|
const take = Math.min(batchSize, remaining);
|
||||||
|
const rows = await prisma.auditLog.findMany({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
...(cursorCreatedAt
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ created_at: { lt: cursorCreatedAt } },
|
||||||
|
{ created_at: cursorCreatedAt, id: { lt: cursorId } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_id: true,
|
||||||
|
action: true,
|
||||||
|
target_type: true,
|
||||||
|
target_id: true,
|
||||||
|
ip_address: true,
|
||||||
|
created_at: true,
|
||||||
|
actor: { select: { name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of rows) {
|
||||||
|
yield csvRow([
|
||||||
|
item.created_at?.toISOString?.() ?? "",
|
||||||
|
item.action,
|
||||||
|
item.actor?.name ?? "",
|
||||||
|
item.actor?.email ?? "",
|
||||||
|
item.target_type,
|
||||||
|
item.target_id ?? "",
|
||||||
|
item.request_id ?? "",
|
||||||
|
item.ip_address ?? "",
|
||||||
|
]);
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
cursorCreatedAt = last.created_at;
|
||||||
|
cursorId = last.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAuditLogsXlsx(prisma, where, limit) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet("audit_logs");
|
||||||
|
|
||||||
|
sheet.columns = [
|
||||||
|
{ header: "时间", key: "createdAt", width: 20 },
|
||||||
|
{ header: "Action", key: "action", width: 28 },
|
||||||
|
{ header: "Actor", key: "actorName", width: 14 },
|
||||||
|
{ header: "Actor Email", key: "actorEmail", width: 22 },
|
||||||
|
{ header: "Target Type", key: "targetType", width: 16 },
|
||||||
|
{ header: "Target ID", key: "targetId", width: 36 },
|
||||||
|
{ header: "Request ID", key: "requestId", width: 24 },
|
||||||
|
{ header: "IP", key: "ipAddress", width: 16 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = await prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_id: true,
|
||||||
|
action: true,
|
||||||
|
target_type: true,
|
||||||
|
target_id: true,
|
||||||
|
ip_address: true,
|
||||||
|
created_at: true,
|
||||||
|
actor: { select: { name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of rows) {
|
||||||
|
sheet.addRow({
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? "",
|
||||||
|
action: item.action,
|
||||||
|
actorName: item.actor?.name ?? "",
|
||||||
|
actorEmail: item.actor?.email ?? "",
|
||||||
|
targetType: item.target_type,
|
||||||
|
targetId: item.target_id ?? "",
|
||||||
|
requestId: item.request_id ?? "",
|
||||||
|
ipAddress: item.ip_address ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return workbook.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportsRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
"/exports/reimbursements",
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
ensureHasAnyPermission(fastify, request, ["expense.view_own", "expense.view_all"]);
|
||||||
|
const query = parseQuery(REIMBURSEMENT_EXPORT_QUERY_SCHEMA, request.query);
|
||||||
|
|
||||||
|
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||||
|
const scope = query.scope ?? (canViewAll ? "all" : "own");
|
||||||
|
if (scope === "all" && !canViewAll) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(scope === "all"
|
||||||
|
? buildViewAllScopeWhere(request)
|
||||||
|
: buildOwnReimbursementScopeWhere(request.employee.id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = resolveExportFormat(query);
|
||||||
|
const limit = query.limit ?? 5000;
|
||||||
|
const total = await fastify.prisma.reimbursementForm.count({ where });
|
||||||
|
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "EXPORT_REIMBURSEMENTS",
|
||||||
|
targetType: "export",
|
||||||
|
extra: {
|
||||||
|
format,
|
||||||
|
scope,
|
||||||
|
status: query.status ?? null,
|
||||||
|
limit,
|
||||||
|
matchedTotal: total,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = exportFilename("reimbursements", format);
|
||||||
|
setExportHeaders(reply, format, filename);
|
||||||
|
|
||||||
|
if (format === "xlsx") {
|
||||||
|
const buffer = await buildReimbursementsXlsx(fastify.prisma, where, limit);
|
||||||
|
return reply.send(Buffer.from(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = Readable.from(streamReimbursementsCsv(fastify.prisma, where, limit));
|
||||||
|
return reply.send(stream);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/exports/invoice-pool",
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
ensureHasAnyPermission(fastify, request, ["invoice.view_own", "invoice.manage"]);
|
||||||
|
const query = parseQuery(INVOICE_EXPORT_QUERY_SCHEMA, request.query);
|
||||||
|
|
||||||
|
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
|
||||||
|
const ownerScope = query.ownerScope ?? (canManage ? "all" : "own");
|
||||||
|
if (ownerScope === "all" && !canManage) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(ownerScope === "all"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ owner_employee_id: request.employee.id },
|
||||||
|
{ uploaded_by_employee_id: request.employee.id },
|
||||||
|
{
|
||||||
|
linked_reimbursement: {
|
||||||
|
OR: [
|
||||||
|
{ owner_id: request.employee.id },
|
||||||
|
{ submitter_id: request.employee.id },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = resolveExportFormat(query);
|
||||||
|
const limit = query.limit ?? 5000;
|
||||||
|
const total = await fastify.prisma.invoicePool.count({ where });
|
||||||
|
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "EXPORT_INVOICE_POOL",
|
||||||
|
targetType: "export",
|
||||||
|
extra: {
|
||||||
|
format,
|
||||||
|
ownerScope,
|
||||||
|
status: query.status ?? null,
|
||||||
|
limit,
|
||||||
|
matchedTotal: total,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = exportFilename("invoice-pool", format);
|
||||||
|
setExportHeaders(reply, format, filename);
|
||||||
|
|
||||||
|
if (format === "xlsx") {
|
||||||
|
const buffer = await buildInvoicesXlsx(fastify.prisma, where, limit);
|
||||||
|
return reply.send(Buffer.from(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = Readable.from(streamInvoicesCsv(fastify.prisma, where, limit));
|
||||||
|
return reply.send(stream);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/exports/audit-logs",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.audit.view"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseQuery(AUDIT_EXPORT_QUERY_SCHEMA, request.query);
|
||||||
|
|
||||||
|
const dateFrom = query.dateFrom ? new Date(query.dateFrom) : null;
|
||||||
|
const dateTo = query.dateTo ? new Date(query.dateTo) : null;
|
||||||
|
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { dateTo: "must_be_after_dateFrom" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(query.actorId ? { actor_id: query.actorId } : {}),
|
||||||
|
...(query.action ? { action: query.action } : {}),
|
||||||
|
...(query.targetType ? { target_type: query.targetType } : {}),
|
||||||
|
...(dateFrom || dateTo
|
||||||
|
? {
|
||||||
|
created_at: {
|
||||||
|
...(dateFrom ? { gte: dateFrom } : {}),
|
||||||
|
...(dateTo ? { lte: dateTo } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = resolveExportFormat(query);
|
||||||
|
const limit = query.limit ?? 20000;
|
||||||
|
const total = await fastify.prisma.auditLog.count({ where });
|
||||||
|
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "EXPORT_AUDIT_LOGS",
|
||||||
|
targetType: "export",
|
||||||
|
extra: {
|
||||||
|
format,
|
||||||
|
actorId: query.actorId ?? null,
|
||||||
|
actionFilter: query.action ?? null,
|
||||||
|
targetType: query.targetType ?? null,
|
||||||
|
dateFrom: dateFrom?.toISOString?.() ?? null,
|
||||||
|
dateTo: dateTo?.toISOString?.() ?? null,
|
||||||
|
limit,
|
||||||
|
matchedTotal: total,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = exportFilename("audit-logs", format);
|
||||||
|
setExportHeaders(reply, format, filename);
|
||||||
|
|
||||||
|
if (format === "xlsx") {
|
||||||
|
const buffer = await buildAuditLogsXlsx(fastify.prisma, where, limit);
|
||||||
|
return reply.send(Buffer.from(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = Readable.from(streamAuditLogsCsv(fastify.prisma, where, limit));
|
||||||
|
return reply.send(stream);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exportsRoutes;
|
||||||
|
|
||||||
在新工单中引用
屏蔽一个用户