908 行
26 KiB
JavaScript
908 行
26 KiB
JavaScript
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(),
|
|
});
|
|
|
|
const DASHBOARD_EXPORT_QUERY_SCHEMA = z.object({
|
|
format: EXPORT_FORMAT_SCHEMA.optional(),
|
|
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
departmentId: z.string().uuid().optional(),
|
|
limit: z.coerce.number().int().min(1).max(20000).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 toDateRange(query) {
|
|
const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null;
|
|
const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null;
|
|
if (dateFrom && dateTo && dateFrom > dateTo) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { dateTo: "must_be_after_dateFrom" },
|
|
});
|
|
}
|
|
return { dateFrom, dateTo };
|
|
}
|
|
|
|
function buildDashboardWhere(request, query) {
|
|
const { dateFrom, dateTo } = toDateRange(query);
|
|
|
|
const dateWhere =
|
|
dateFrom || dateTo
|
|
? {
|
|
reimbursement_date: {
|
|
...(dateFrom ? { gte: dateFrom } : {}),
|
|
...(dateTo ? { lte: dateTo } : {}),
|
|
},
|
|
}
|
|
: {};
|
|
|
|
const departmentWhere = query.departmentId ? { department_id: query.departmentId } : {};
|
|
|
|
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
|
return {
|
|
...dateWhere,
|
|
...departmentWhere,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...dateWhere,
|
|
...departmentWhere,
|
|
OR: [
|
|
{ owner_id: request.employee.id },
|
|
{ submitter_id: request.employee.id },
|
|
{ owner: { direct_manager_id: request.employee.id } },
|
|
],
|
|
};
|
|
}
|
|
|
|
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
|
|
yield "\ufeff";
|
|
yield csvRow([
|
|
"formNumber",
|
|
"status",
|
|
"departmentId",
|
|
"reimbursementDate",
|
|
"totalAmount",
|
|
"ownerName",
|
|
"submitterName",
|
|
"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,
|
|
department_id: true,
|
|
reimbursement_date: true,
|
|
total_amount: 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.department_id,
|
|
item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? "",
|
|
item.total_amount?.toString?.() ?? String(item.total_amount ?? ""),
|
|
item.owner?.name ?? "",
|
|
item.submitter?.name ?? "",
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/exports/dashboard/reimbursements",
|
|
{
|
|
preHandler: fastify.requirePermission("report.dashboard.view"),
|
|
},
|
|
async (request, reply) => {
|
|
const query = parseQuery(DASHBOARD_EXPORT_QUERY_SCHEMA, request.query);
|
|
|
|
const where = buildDashboardWhere(request, query);
|
|
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_DASHBOARD",
|
|
targetType: "export",
|
|
extra: {
|
|
format,
|
|
limit,
|
|
matchedTotal: total,
|
|
dateFrom: query.dateFrom ?? null,
|
|
dateTo: query.dateTo ?? null,
|
|
departmentId: query.departmentId ?? null,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const filename = exportFilename("dashboard-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(streamDashboardReimbursementsCsv(fastify.prisma, where, limit));
|
|
return reply.send(stream);
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = exportsRoutes;
|