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;