const { Readable } = require("node:stream"); const ExcelJS = require("exceljs"); const { z } = require("zod"); const { CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA, buildContractRegistryWhere, formatContractRegistryAmount, formatContractRegistryDateOnly, parseContractRegistryFilters, resolveContractRegistryEffectiveStatus, resolveContractRegistryToday, } = require("../services/contractRegistryFilters"); const { EMPLOYMENT_STATUS_VALUES, buildEmployeeDirectoryWhere, parseEmployeeDirectoryFilters, } = require("../services/employeeDirectoryFilters"); const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { FINANCE_LEDGER_EXPORT_HEADERS, REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES, REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES, REIMBURSEMENT_LEDGER_STATUS_VALUES, listFinanceLedgerForExport, mapFinanceLedgerExportRow, } = require("../services/financeLedger"); const { HISTORY_STATUS_VALUES, buildPresenceValidationError, buildTeamPresenceHistory, resolvePresenceHistoryRange, } = require("../services/presenceHistory"); const { loadMonthlyPayslips, parsePayMonth } = require("../services/payroll"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const EXPORT_FORMAT_SCHEMA = z.enum(["csv", "xlsx"]); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); 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: DATE_ONLY_SCHEMA.optional(), dateTo: DATE_ONLY_SCHEMA.optional(), departmentId: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(20000).optional(), }); const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({ format: EXPORT_FORMAT_SCHEMA.optional(), status: z.enum(["active", "archived"]).optional(), departmentId: z.string().uuid().optional(), q: z.string().max(100).optional(), title: z.string().max(100).optional(), managerId: z.string().uuid().optional(), hireDateFrom: DATE_ONLY_SCHEMA.optional(), hireDateTo: DATE_ONLY_SCHEMA.optional(), employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(), limit: z.coerce.number().int().min(1).max(20000).optional(), }); const PAYSLIP_EXPORT_QUERY_SCHEMA = z.object({ format: EXPORT_FORMAT_SCHEMA.optional(), month: z.string().regex(/^\d{4}-\d{2}$/), }); const PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA = z.object({ format: EXPORT_FORMAT_SCHEMA.optional(), dateFrom: DATE_ONLY_SCHEMA.optional(), dateTo: DATE_ONLY_SCHEMA.optional(), departmentId: z.string().uuid().optional(), employeeId: z.string().uuid().optional(), status: z.enum(HISTORY_STATUS_VALUES).optional(), }); const CONTRACT_REGISTRY_EXPORT_BODY_SCHEMA = CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA.extend({ format: EXPORT_FORMAT_SCHEMA.optional(), }); const AP_OPERATIONS_EXPORT_BODY_SCHEMA = z.object({ format: EXPORT_FORMAT_SCHEMA.optional(), pendingEntityType: z.enum(["reimbursement", "travel_order"]).optional(), pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(), pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(), riskBucket: z.enum(["all", "exception", "budget_overspend"]).optional(), ledgerStatus: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(), ledgerPaymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(), ledgerInvoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(), ledgerDateFrom: DATE_ONLY_SCHEMA.optional(), ledgerDateTo: DATE_ONLY_SCHEMA.optional(), ledgerSearch: z.string().trim().max(100).optional(), }).strict(); const PRESENCE_HISTORY_EXPORT_LIMIT = 5000; const AP_PENDING_BATCH_STATUSES = ["draft", "reviewed", "failed"]; const AP_OPERATIONS_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage"]; 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 parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); 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 ensureApOperationsExportEnabled(fastify) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, strictDepartment: false, }); if (!gate.enabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, }); } } async function ensureContractsRegistryExportEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason }, }); } } function confidentialityLevelLabel(value) { if (value === "public") return "公开"; if (value === "internal") return "内部"; if (value === "confidential") return "机密"; return "限制"; } function contractRegistryTypeLabel(value) { if (value === "purchase") return "采购合同"; if (value === "sales") return "销售合同"; if (value === "service") return "服务合同"; if (value === "framework") return "框架协议"; return "其他"; } function contractRegistryStatusLabel(value) { if (value === "draft") return "草稿"; if (value === "active") return "生效中"; if (value === "expired") return "已过期"; if (value === "archived") return "已归档"; return value ?? ""; } function paymentBatchEntityTypeLabel(value) { if (value === "reimbursement") return "报销"; if (value === "travel_order") return "差旅"; return value ?? ""; } function paymentBatchStatusLabel(value) { if (value === "draft") return "待付款复核"; if (value === "reviewed") return "待财务付款"; if (value === "executed") return "已付款"; if (value === "failed") return "付款登记失败"; if (value === "cancelled") return "已取消"; return value ?? ""; } function riskBucketLabel(value) { if (value === "budget_overspend") return "预算超支"; return "异常风险"; } function riskFlagStatusLabel(value) { if (value === "open") return "待处理"; if (value === "resolved") return "已解决"; if (value === "dismissed") return "已驳回"; return value ?? ""; } function riskLevelLabel(value) { if (value === "high") return "高"; if (value === "medium") return "中"; if (value === "low") return "低"; return value ?? ""; } function reconciliationMatchStatusLabel(value) { if (value === "amount_mismatch") return "金额不匹配"; if (value === "currency_mismatch") return "币种不匹配"; if (value === "missing_transaction") return "缺少交易"; if (value === "duplicate_charge") return "重复扣款"; if (value === "orphan_transaction") return "孤儿交易"; if (value === "reversed_after_payment") return "付款后冲正"; if (value === "matched") return "已匹配"; return value ?? ""; } function reconciliationResolutionStatusLabel(value) { if (value === "pending") return "待确认"; if (value === "confirmed") return "已确认"; return value ?? ""; } function resolveApOperationsRiskBucket(riskCode) { return BUDGET_RISK_CODES.includes(riskCode) ? "budget_overspend" : "exception"; } function toDateOnlyRange(dateFrom, dateTo, fieldNames) { const from = dateFrom ? new Date(`${dateFrom}T00:00:00.000Z`) : null; const to = dateTo ? new Date(`${dateTo}T23:59:59.999Z`) : null; if (from && to && from > to) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { [fieldNames.to]: "must_be_after_or_equal_to_from" }, }); } return { from, to }; } function buildApOperationsPendingBatchWhere(filters) { const { from, to } = toDateOnlyRange( filters.pendingCreatedAtFrom, filters.pendingCreatedAtTo, { from: "pendingCreatedAtFrom", to: "pendingCreatedAtTo" }, ); return { status: { in: AP_PENDING_BATCH_STATUSES }, ...(filters.pendingEntityType ? { entity_type: filters.pendingEntityType } : {}), ...(from || to ? { created_at: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}), }, } : {}), }; } function buildApOperationsRiskWhere(filters) { if (filters.riskBucket === "budget_overspend") { return { status: "open", risk_code: { in: BUDGET_RISK_CODES }, }; } if (filters.riskBucket === "exception") { return { status: "open", risk_code: { notIn: BUDGET_RISK_CODES }, }; } return { status: "open" }; } function mapApPendingBatchExportRow(item) { const totalAmount = toMoneyString((item.items ?? []).reduce((sum, entry) => sum + toMoneyNumber(entry.amount), 0)); return [ item.batch_no, paymentBatchEntityTypeLabel(item.entity_type), item.name, paymentBatchStatusLabel(item.status), totalAmount, toMoneyNumber(totalAmount) >= 50000 ? "是" : "否", item.created_at?.toISOString?.() ?? "", ]; } function mapApFailedPaymentItemExportRow(item) { return [ item.payment_batch?.batch_no ?? item.payment_batch_id, paymentBatchEntityTypeLabel(item.payment_batch?.entity_type), item.entity_code, item.entity_title, toMoneyString(item.amount), item.currency_code, item.failure_code ?? "", item.failure_message ?? "", item.created_at?.toISOString?.() ?? "", ]; } function mapApRiskFlagExportRow(item) { return [ riskBucketLabel(resolveApOperationsRiskBucket(item.risk_code)), item.title, item.risk_code, riskLevelLabel(item.level), item.entity_type, item.entity_id, item.source, riskFlagStatusLabel(item.status), item.detail ?? "", item.created_at?.toISOString?.() ?? "", ]; } function mapApReconciliationAnomalyExportRow(item) { return [ item.batch?.batch_no ?? item.travel_reconciliation_batch_id, item.anomaly_code ?? "", reconciliationMatchStatusLabel(item.match_status), reconciliationResolutionStatusLabel(item.resolution_status), item.travel_order_id ?? "", item.travel_request_id ?? "", item.order_amount != null ? toMoneyString(item.order_amount) : "", item.transaction_amount != null ? toMoneyString(item.transaction_amount) : "", item.currency ?? "", item.created_at?.toISOString?.() ?? "", ]; } async function loadApOperationsExportDataset(prisma, filters) { const pendingWhere = buildApOperationsPendingBatchWhere(filters); const riskWhere = buildApOperationsRiskWhere(filters); const reconciliationWhere = { resolution_status: "pending", match_status: { not: "matched" }, }; const ledgerFilters = { status: filters.ledgerStatus, paymentStatus: filters.ledgerPaymentStatus, invoiceStatus: filters.ledgerInvoiceStatus, dateFrom: filters.ledgerDateFrom, dateTo: filters.ledgerDateTo, q: filters.ledgerSearch, }; const [ reimbursementLedger, pendingPaymentBatches, failedPaymentBatchItems, riskFlags, reconciliationAnomalies, ] = await Promise.all([ listFinanceLedgerForExport(prisma, ledgerFilters, 5000), prisma.paymentBatch.findMany({ where: pendingWhere, orderBy: [{ created_at: "desc" }, { id: "desc" }], include: { items: { select: { amount: true, }, }, }, }), prisma.paymentBatchItem.findMany({ where: { status: "failed" }, orderBy: [{ created_at: "desc" }, { id: "desc" }], include: { payment_batch: { select: { batch_no: true, entity_type: true, }, }, }, }), prisma.entityRiskFlag.findMany({ where: riskWhere, orderBy: [{ created_at: "desc" }, { id: "desc" }], }), prisma.travelReconciliationItem.findMany({ where: reconciliationWhere, orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 50, include: { batch: { select: { batch_no: true, }, }, }, }), ]); return { reimbursementLedger, pendingWhere, riskWhere, reconciliationWhere, pendingPaymentBatches, failedPaymentBatchItems, riskFlags, reconciliationAnomalies, }; } async function* streamApOperationsCsv(dataset) { yield "\ufeff"; yield csvRow(["财务台账"]); yield csvRow(FINANCE_LEDGER_EXPORT_HEADERS); for (const item of dataset.reimbursementLedger) { yield csvRow(mapFinanceLedgerExportRow(item)); } yield csvRow([]); yield csvRow(["待付款批次"]); yield csvRow(["批次编号", "实体类型", "批次名称", "状态", "总金额", "高风险", "创建时间"]); for (const item of dataset.pendingPaymentBatches) { yield csvRow(mapApPendingBatchExportRow(item)); } yield csvRow([]); yield csvRow(["异常付款项"]); yield csvRow(["批次编号", "实体类型", "单据编号", "单据标题", "金额", "币种", "失败码", "失败原因", "创建时间"]); for (const item of dataset.failedPaymentBatchItems) { yield csvRow(mapApFailedPaymentItemExportRow(item)); } yield csvRow([]); yield csvRow(["风险标记"]); yield csvRow(["风险分组", "标题", "风险编码", "等级", "实体类型", "实体 ID", "来源", "状态", "明细", "创建时间"]); for (const item of dataset.riskFlags) { yield csvRow(mapApRiskFlagExportRow(item)); } yield csvRow([]); yield csvRow(["对账异常"]); yield csvRow(["批次编号", "异常编码", "匹配状态", "确认状态", "差旅订单 ID", "差旅申请 ID", "订单金额", "交易金额", "币种", "创建时间"]); for (const item of dataset.reconciliationAnomalies) { yield csvRow(mapApReconciliationAnomalyExportRow(item)); } } async function buildApOperationsXlsx(dataset) { const workbook = new ExcelJS.Workbook(); const ledgerSheet = workbook.addWorksheet("财务台账"); ledgerSheet.columns = FINANCE_LEDGER_EXPORT_HEADERS.map((header, index) => ({ header, key: `col${index}`, width: index === 0 ? 24 : index === 14 ? 24 : 16, })); for (const item of dataset.reimbursementLedger) { const row = mapFinanceLedgerExportRow(item); ledgerSheet.addRow(Object.fromEntries(row.map((value, index) => [`col${index}`, value]))); } const pendingSheet = workbook.addWorksheet("待付款批次"); pendingSheet.columns = [ { header: "批次编号", key: "batchNo", width: 24 }, { header: "实体类型", key: "entityType", width: 14 }, { header: "批次名称", key: "name", width: 30 }, { header: "状态", key: "status", width: 14 }, { header: "总金额", key: "totalAmount", width: 14 }, { header: "高风险", key: "highRisk", width: 10 }, { header: "创建时间", key: "createdAt", width: 24 }, ]; for (const item of dataset.pendingPaymentBatches) { const row = mapApPendingBatchExportRow(item); pendingSheet.addRow({ batchNo: row[0], entityType: row[1], name: row[2], status: row[3], totalAmount: row[4], highRisk: row[5], createdAt: row[6], }); } const failedSheet = workbook.addWorksheet("异常付款项"); failedSheet.columns = [ { header: "批次编号", key: "batchNo", width: 24 }, { header: "实体类型", key: "entityType", width: 14 }, { header: "单据编号", key: "entityCode", width: 24 }, { header: "单据标题", key: "entityTitle", width: 28 }, { header: "金额", key: "amount", width: 14 }, { header: "币种", key: "currencyCode", width: 10 }, { header: "失败码", key: "failureCode", width: 20 }, { header: "失败原因", key: "failureMessage", width: 32 }, { header: "创建时间", key: "createdAt", width: 24 }, ]; for (const item of dataset.failedPaymentBatchItems) { const row = mapApFailedPaymentItemExportRow(item); failedSheet.addRow({ batchNo: row[0], entityType: row[1], entityCode: row[2], entityTitle: row[3], amount: row[4], currencyCode: row[5], failureCode: row[6], failureMessage: row[7], createdAt: row[8], }); } const riskSheet = workbook.addWorksheet("风险标记"); riskSheet.columns = [ { header: "风险分组", key: "bucket", width: 14 }, { header: "标题", key: "title", width: 28 }, { header: "风险编码", key: "riskCode", width: 24 }, { header: "等级", key: "level", width: 10 }, { header: "实体类型", key: "entityType", width: 16 }, { header: "实体 ID", key: "entityId", width: 38 }, { header: "来源", key: "source", width: 14 }, { header: "状态", key: "status", width: 12 }, { header: "明细", key: "detail", width: 40 }, { header: "创建时间", key: "createdAt", width: 24 }, ]; for (const item of dataset.riskFlags) { const row = mapApRiskFlagExportRow(item); riskSheet.addRow({ bucket: row[0], title: row[1], riskCode: row[2], level: row[3], entityType: row[4], entityId: row[5], source: row[6], status: row[7], detail: row[8], createdAt: row[9], }); } const reconciliationSheet = workbook.addWorksheet("对账异常"); reconciliationSheet.columns = [ { header: "批次编号", key: "batchNo", width: 24 }, { header: "异常编码", key: "anomalyCode", width: 20 }, { header: "匹配状态", key: "matchStatus", width: 16 }, { header: "确认状态", key: "resolutionStatus", width: 14 }, { header: "差旅订单 ID", key: "travelOrderId", width: 38 }, { header: "差旅申请 ID", key: "travelRequestId", width: 38 }, { header: "订单金额", key: "orderAmount", width: 14 }, { header: "交易金额", key: "transactionAmount", width: 14 }, { header: "币种", key: "currency", width: 10 }, { header: "创建时间", key: "createdAt", width: 24 }, ]; for (const item of dataset.reconciliationAnomalies) { const row = mapApReconciliationAnomalyExportRow(item); reconciliationSheet.addRow({ batchNo: row[0], anomalyCode: row[1], matchStatus: row[2], resolutionStatus: row[3], travelOrderId: row[4], travelRequestId: row[5], orderAmount: row[6], transactionAmount: row[7], currency: row[8], createdAt: row[9], }); } return workbook.xlsx.writeBuffer(); } function mapContractRegistryExportRow(item, today) { const effectiveStatus = resolveContractRegistryEffectiveStatus(item, today); return [ item.contract_no, contractRegistryTypeLabel(item.contract_type), item.counterparty_name, formatContractRegistryAmount(item.amount), item.currency, contractRegistryStatusLabel(effectiveStatus), formatContractRegistryDateOnly(item.signed_date), formatContractRegistryDateOnly(item.effective_date), formatContractRegistryDateOnly(item.expiry_date), item.project ? `${item.project.project_no} · ${item.project.name}` : "", item.owner?.name ?? "", confidentialityLevelLabel(item.confidentiality_level), ]; } async function* streamContractsRegistryCsv(prisma, where, today) { yield "\ufeff"; yield csvRow([ "合同编号", "类型", "相对方", "金额", "币种", "状态", "签署日期", "生效日期", "到期日期", "项目", "责任人", "密级", ]); const batchSize = 500; let cursorUpdatedAt = null; let cursorId = null; while (true) { const rows = await prisma.contractRegistry.findMany({ where: { ...where, ...(cursorUpdatedAt ? { OR: [ { updated_at: { lt: cursorUpdatedAt } }, { updated_at: cursorUpdatedAt, id: { lt: cursorId } }, ], } : {}), }, orderBy: [{ updated_at: "desc" }, { id: "desc" }], take: batchSize, include: { owner: { select: { name: true } }, project: { select: { project_no: true, name: true } }, }, }); if (!rows.length) { break; } for (const item of rows) { yield csvRow(mapContractRegistryExportRow(item, today)); } const last = rows[rows.length - 1]; cursorUpdatedAt = last.updated_at; cursorId = last.id; } } async function buildContractsRegistryXlsx(prisma, where, today) { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("contracts_registry"); sheet.columns = [ { header: "合同编号", key: "contractNo", width: 22 }, { header: "类型", key: "contractType", width: 16 }, { header: "相对方", key: "counterpartyName", width: 24 }, { header: "金额", key: "amount", width: 16 }, { header: "币种", key: "currency", width: 12 }, { header: "状态", key: "status", width: 12 }, { header: "签署日期", key: "signedDate", width: 14 }, { header: "生效日期", key: "effectiveDate", width: 14 }, { header: "到期日期", key: "expiryDate", width: 14 }, { header: "项目", key: "projectName", width: 24 }, { header: "责任人", key: "ownerName", width: 16 }, { header: "密级", key: "confidentialityLevel", width: 12 }, ]; const rows = await prisma.contractRegistry.findMany({ where, orderBy: [{ updated_at: "desc" }, { id: "desc" }], include: { owner: { select: { name: true } }, project: { select: { project_no: true, name: true } }, }, }); for (const item of rows) { sheet.addRow({ contractNo: item.contract_no, contractType: contractRegistryTypeLabel(item.contract_type), counterpartyName: item.counterparty_name, amount: formatContractRegistryAmount(item.amount), currency: item.currency, status: contractRegistryStatusLabel(resolveContractRegistryEffectiveStatus(item, today)), signedDate: formatContractRegistryDateOnly(item.signed_date), effectiveDate: formatContractRegistryDateOnly(item.effective_date), expiryDate: formatContractRegistryDateOnly(item.expiry_date), projectName: item.project ? `${item.project.project_no} · ${item.project.name}` : "", ownerName: item.owner?.name ?? "", confidentialityLevel: confidentialityLevelLabel(item.confidentiality_level), }); } return workbook.xlsx.writeBuffer(); } 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* streamEmployeesCsv(prisma, where, limit) { yield "\ufeff"; yield csvRow(["工号", "姓名", "部门", "职位", "手机", "邮箱", "账号状态", "入职日期"]); 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.employee.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, employee_no: true, name: true, phone: true, email: true, status: true, title: true, hire_date: true, created_at: true, department: { select: { name: true } }, }, }); if (!rows.length) { break; } for (const item of rows) { yield csvRow([ item.employee_no, item.name, item.department?.name ?? "", item.title ?? "", item.phone ?? "", item.email ?? "", item.status, item.hire_date?.toISOString?.()?.slice(0, 10) ?? "", ]); remaining -= 1; if (remaining <= 0) break; } const last = rows[rows.length - 1]; cursorCreatedAt = last.created_at; cursorId = last.id; } } async function buildEmployeesXlsx(prisma, where, limit) { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("employees"); sheet.columns = [ { header: "工号", key: "employeeNo", width: 16 }, { header: "姓名", key: "name", width: 14 }, { header: "部门", key: "departmentName", width: 16 }, { header: "职位", key: "title", width: 16 }, { header: "手机", key: "phone", width: 16 }, { header: "邮箱", key: "email", width: 24 }, { header: "账号状态", key: "status", width: 12 }, { header: "入职日期", key: "hireDate", width: 14 }, ]; const rows = await prisma.employee.findMany({ where, orderBy: [{ created_at: "desc" }, { id: "desc" }], take: limit, select: { employee_no: true, name: true, phone: true, email: true, status: true, title: true, hire_date: true, department: { select: { name: true } }, }, }); for (const item of rows) { sheet.addRow({ employeeNo: item.employee_no, name: item.name, departmentName: item.department?.name ?? "", title: item.title ?? "", phone: item.phone ?? "", email: item.email ?? "", status: item.status, hireDate: item.hire_date?.toISOString?.()?.slice(0, 10) ?? "", }); } return workbook.xlsx.writeBuffer(); } function buildPayslipExportHeader(salaryItems) { return [ "工号", "姓名", "部门", ...salaryItems.map((item) => item.name), "应发合计", "应扣合计", "实发合计", ]; } function buildPayslipExportRow(payslip, salaryItems) { const amountByItemId = new Map((payslip.items ?? []).map((item) => [item.salaryItemId, item.amount])); return [ payslip.employee?.employeeNo ?? "", payslip.employee?.name ?? "", payslip.employee?.department?.name ?? "", ...salaryItems.map((item) => String(amountByItemId.get(item.id) ?? 0)), String(payslip.grossAmount ?? 0), String(payslip.deductionAmount ?? 0), String(payslip.netAmount ?? 0), ]; } function buildPayslipsCsv(dataset) { const rows = ["\ufeff", csvRow(buildPayslipExportHeader(dataset.salaryItems))]; for (const payslip of dataset.payslips) { rows.push(csvRow(buildPayslipExportRow(payslip, dataset.salaryItems))); } return rows.join(""); } async function buildPayslipsXlsx(dataset) { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("payslips"); sheet.columns = buildPayslipExportHeader(dataset.salaryItems).map((header) => ({ header, key: header, width: header.length > 8 ? 18 : 14, })); for (const payslip of dataset.payslips) { const row = buildPayslipExportRow(payslip, dataset.salaryItems); const rowObject = {}; buildPayslipExportHeader(dataset.salaryItems).forEach((header, index) => { rowObject[header] = row[index]; }); sheet.addRow(rowObject); } return workbook.xlsx.writeBuffer(); } function presenceStatusLabel(status) { if (status === "office") return "在岗"; if (status === "remote") return "居家"; if (status === "out") return "外出"; if (status === "travel") return "出差"; if (status === "leave") return "请假"; return "未更新"; } function presenceTimeRangeLabel(timeRange) { if (timeRange === "morning") return "上午"; if (timeRange === "afternoon") return "下午"; if (timeRange === "full_day") return "全天"; return "-"; } function presenceSourceLabel(source) { if (source === "manual") return "手工"; if (source === "leave_sync") return "审批同步"; if (source === "admin_override") return "管理员修正"; return "-"; } function mapPresenceHistoryExportRow(item) { return [ item.date, item.employee.employeeNo, item.employee.name, item.employee.department?.name ?? "", item.employee.title ?? "", presenceStatusLabel(item.presence?.status ?? null), presenceTimeRangeLabel(item.presence?.timeRange), presenceSourceLabel(item.presence?.source), item.presence?.note ?? "-", ]; } function buildPresenceHistoryCsv(items) { const rows = [ "\ufeff", csvRow(["日期", "工号", "姓名", "部门", "岗位", "状态", "时间段", "来源", "备注"]), ]; for (const item of items) { rows.push(csvRow(mapPresenceHistoryExportRow(item))); } return rows.join(""); } async function buildPresenceHistoryXlsx(items) { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("presence_history"); sheet.columns = [ { header: "日期", key: "date", width: 14 }, { header: "工号", key: "employeeNo", width: 14 }, { header: "姓名", key: "name", width: 14 }, { header: "部门", key: "departmentName", width: 18 }, { header: "岗位", key: "title", width: 18 }, { header: "状态", key: "status", width: 12 }, { header: "时间段", key: "timeRange", width: 12 }, { header: "来源", key: "source", width: 14 }, { header: "备注", key: "note", width: 32 }, ]; for (const item of items) { sheet.addRow({ date: item.date, employeeNo: item.employee.employeeNo, name: item.employee.name, departmentName: item.employee.department?.name ?? "", title: item.employee.title ?? "", status: presenceStatusLabel(item.presence?.status ?? null), timeRange: presenceTimeRangeLabel(item.presence?.timeRange), source: presenceSourceLabel(item.presence?.source), note: item.presence?.note ?? "-", }); } return workbook.xlsx.writeBuffer(); } async function exportsRoutes(fastify) { fastify.post( "/exports/contracts-registry", { preHandler: fastify.authenticate, }, async (request, reply) => { await ensureContractsRegistryExportEnabled(fastify, request); const parsedBody = parseBody(CONTRACT_REGISTRY_EXPORT_BODY_SCHEMA, request.body); const format = parsedBody.format ?? "csv"; const filters = parseContractRegistryFilters({ projectId: parsedBody.projectId, status: parsedBody.status, contractType: parsedBody.contractType, counterpartyName: parsedBody.counterpartyName, expiryDateFrom: parsedBody.expiryDateFrom, expiryDateTo: parsedBody.expiryDateTo, }); const where = buildContractRegistryWhere({ filters, employee: request.employee, hasPermission: fastify.hasPermission, timeZone: fastify.config.appTimezone, }); const matchedTotal = await fastify.prisma.contractRegistry.count({ where }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPORT_CONTRACTS_REGISTRY", targetType: "export", extra: { format, projectId: filters.projectId ?? null, status: filters.status ?? null, contractType: filters.contractType ?? null, counterpartyName: filters.counterpartyName ?? null, expiryDateFrom: filters.expiryDateFrom ?? null, expiryDateTo: filters.expiryDateTo ?? null, matchedTotal, }, }), }); const filename = exportFilename("contracts-registry", format); const today = resolveContractRegistryToday(fastify.config.appTimezone); setExportHeaders(reply, format, filename); if (format === "xlsx") { const buffer = await buildContractsRegistryXlsx(fastify.prisma, where, today); return reply.send(Buffer.from(buffer)); } const stream = Readable.from(streamContractsRegistryCsv(fastify.prisma, where, today)); return reply.send(stream); }, ); fastify.post( "/exports/ap-operations", { preHandler: fastify.authenticate, }, async (request, reply) => { await ensureApOperationsExportEnabled(fastify); ensureHasAnyPermission(fastify, request, AP_OPERATIONS_PERMISSION_CODES); const parsedBody = parseBody(AP_OPERATIONS_EXPORT_BODY_SCHEMA, request.body); const format = parsedBody.format ?? "csv"; const filters = { pendingEntityType: parsedBody.pendingEntityType, pendingCreatedAtFrom: parsedBody.pendingCreatedAtFrom, pendingCreatedAtTo: parsedBody.pendingCreatedAtTo, riskBucket: parsedBody.riskBucket ?? "all", ledgerStatus: parsedBody.ledgerStatus, ledgerPaymentStatus: parsedBody.ledgerPaymentStatus, ledgerInvoiceStatus: parsedBody.ledgerInvoiceStatus, ledgerDateFrom: parsedBody.ledgerDateFrom, ledgerDateTo: parsedBody.ledgerDateTo, ledgerSearch: parsedBody.ledgerSearch, }; const dataset = await loadApOperationsExportDataset(fastify.prisma, filters); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPORT_AP_OPERATIONS", targetType: "export", extra: { format, pendingEntityType: filters.pendingEntityType ?? null, pendingCreatedAtFrom: filters.pendingCreatedAtFrom ?? null, pendingCreatedAtTo: filters.pendingCreatedAtTo ?? null, riskBucket: filters.riskBucket, pendingPaymentBatchTotal: dataset.pendingPaymentBatches.length, failedPaymentItemTotal: dataset.failedPaymentBatchItems.length, openRiskFlagTotal: dataset.riskFlags.length, reconciliationAnomalyTotal: dataset.reconciliationAnomalies.length, financeLedgerTotal: dataset.reimbursementLedger.length, ledgerStatus: filters.ledgerStatus ?? null, ledgerPaymentStatus: filters.ledgerPaymentStatus ?? null, ledgerInvoiceStatus: filters.ledgerInvoiceStatus ?? null, ledgerDateFrom: filters.ledgerDateFrom ?? null, ledgerDateTo: filters.ledgerDateTo ?? null, }, }), }); const filename = exportFilename("ap-operations", format); setExportHeaders(reply, format, filename); if (format === "xlsx") { const buffer = await buildApOperationsXlsx(dataset); return reply.send(Buffer.from(buffer)); } const stream = Readable.from(streamApOperationsCsv(dataset)); return reply.send(stream); }, ); fastify.get( "/exports/employees", { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query); const filters = parseEmployeeDirectoryFilters(query, { allowDepartmentFilter: true }); const where = buildEmployeeDirectoryWhere(filters); const format = resolveExportFormat(query); const limit = query.limit ?? 5000; const total = await fastify.prisma.employee.count({ where }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPORT_EMPLOYEES", targetType: "export", extra: { format, status: query.status ?? null, departmentId: query.departmentId ?? null, q: query.q?.trim() || null, title: query.title?.trim() || null, managerId: query.managerId ?? null, hireDateFrom: query.hireDateFrom ?? null, hireDateTo: query.hireDateTo ?? null, employmentStatus: query.employmentStatus ?? null, limit, matchedTotal: total, }, }), }); const filename = exportFilename("employees", format); setExportHeaders(reply, format, filename); if (format === "xlsx") { const buffer = await buildEmployeesXlsx(fastify.prisma, where, limit); return reply.send(Buffer.from(buffer)); } const stream = Readable.from(streamEmployeesCsv(fastify.prisma, where, limit)); return reply.send(stream); }, ); fastify.get( "/exports/payslips", { preHandler: fastify.requirePermission("payroll.manage"), }, async (request, reply) => { const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query); const month = parsePayMonth(query.month); if (!month) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { month: "invalid_month" }, }); } const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPORT_PAYSLIPS", targetType: "export", extra: { format: resolveExportFormat(query), month: query.month, matchedTotal: dataset.payslips.length, }, }), }); const format = resolveExportFormat(query); const filename = exportFilename(`payslips-${query.month}`, format); setExportHeaders(reply, format, filename); if (format === "xlsx") { const buffer = await buildPayslipsXlsx(dataset); return reply.send(Buffer.from(buffer)); } return reply.send(buildPayslipsCsv(dataset)); }, ); fastify.get( "/exports/presence-history", { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const query = parseQuery(PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA, request.query); const format = resolveExportFormat(query); const range = resolvePresenceHistoryRange(query, fastify.config.appTimezone); const history = await buildTeamPresenceHistory(fastify.prisma, { dateStrings: range.dateStrings, departmentId: query.departmentId, employeeId: query.employeeId, status: query.status, }); if (history.items.length > PRESENCE_HISTORY_EXPORT_LIMIT) { throw buildPresenceValidationError({ export: "too_large" }, 409); } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPORT_PRESENCE_HISTORY", targetType: "export", extra: { format, dateFrom: range.dateFrom, dateTo: range.dateTo, departmentId: query.departmentId ?? null, employeeId: query.employeeId ?? null, status: query.status ?? null, matchedTotal: history.items.length, }, }), }); const filename = exportFilename("presence-history", format); setExportHeaders(reply, format, filename); if (format === "xlsx") { const buffer = await buildPresenceHistoryXlsx(history.items); return reply.send(Buffer.from(buffer)); } return reply.send(buildPresenceHistoryCsv(history.items)); }, ); 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;