From 6d59c8d842c536c7ccc4d4ea19a22b0593c00286 Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Thu, 22 Jan 2026 02:58:19 +0000 Subject: [PATCH] feat(export): add Excel export for CFDIs and reports Co-Authored-By: Claude Opus 4.5 --- apps/api/package.json | 3 +- apps/api/src/app.ts | 2 + apps/api/src/controllers/export.controller.ts | 42 +++++++ apps/api/src/routes/export.routes.ts | 14 +++ apps/api/src/services/export.service.ts | 114 ++++++++++++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/controllers/export.controller.ts create mode 100644 apps/api/src/routes/export.routes.ts create mode 100644 apps/api/src/services/export.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index ea58ac9..c1a5963 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,7 +21,8 @@ "express": "^4.21.0", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", - "zod": "^3.23.0" + "zod": "^3.23.0", + "exceljs": "^4.4.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 893da43..9cbdcfc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { authRoutes } from './routes/auth.routes.js'; import { dashboardRoutes } from './routes/dashboard.routes.js'; import { cfdiRoutes } from './routes/cfdi.routes.js'; import { impuestosRoutes } from './routes/impuestos.routes.js'; +import { exportRoutes } from './routes/export.routes.js'; const app = express(); @@ -31,6 +32,7 @@ app.use('/api/auth', authRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/cfdi', cfdiRoutes); app.use('/api/impuestos', impuestosRoutes); +app.use('/api/export', exportRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts new file mode 100644 index 0000000..2a8e832 --- /dev/null +++ b/apps/api/src/controllers/export.controller.ts @@ -0,0 +1,42 @@ +import { Request, Response, NextFunction } from 'express'; +import * as exportService from '../services/export.service.js'; + +export async function exportCfdis(req: Request, res: Response, next: NextFunction) { + try { + const { tipo, estado, fechaInicio, fechaFin } = req.query; + const buffer = await exportService.exportCfdisToExcel(req.tenantSchema!, { + tipo: tipo as string, + estado: estado as string, + fechaInicio: fechaInicio as string, + fechaFin: fechaFin as string, + }); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=cfdis-${Date.now()}.xlsx`); + res.send(buffer); + } catch (error) { + next(error); + } +} + +export async function exportReporte(req: Request, res: Response, next: NextFunction) { + try { + const { tipo, fechaInicio, fechaFin } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const buffer = await exportService.exportReporteToExcel( + req.tenantSchema!, + tipo as 'estado-resultados' | 'flujo-efectivo', + inicio, + fin + ); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=${tipo}-${Date.now()}.xlsx`); + res.send(buffer); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/routes/export.routes.ts b/apps/api/src/routes/export.routes.ts new file mode 100644 index 0000000..6745283 --- /dev/null +++ b/apps/api/src/routes/export.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as exportController from '../controllers/export.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/cfdis', exportController.exportCfdis); +router.get('/reporte', exportController.exportReporte); + +export { router as exportRoutes }; diff --git a/apps/api/src/services/export.service.ts b/apps/api/src/services/export.service.ts new file mode 100644 index 0000000..9a84ab4 --- /dev/null +++ b/apps/api/src/services/export.service.ts @@ -0,0 +1,114 @@ +import ExcelJS from 'exceljs'; +import { prisma } from '../config/database.js'; + +export async function exportCfdisToExcel( + schema: string, + filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string } +): Promise { + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + if (filters.tipo) { + whereClause += ` AND tipo = $${paramIndex++}`; + params.push(filters.tipo); + } + if (filters.estado) { + whereClause += ` AND estado = $${paramIndex++}`; + params.push(filters.estado); + } + if (filters.fechaInicio) { + whereClause += ` AND fecha_emision >= $${paramIndex++}`; + params.push(filters.fechaInicio); + } + if (filters.fechaFin) { + whereClause += ` AND fecha_emision <= $${paramIndex++}`; + params.push(filters.fechaFin); + } + + const cfdis = await prisma.$queryRawUnsafe(` + SELECT uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado, + rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, + subtotal, descuento, iva, isr_retenido, iva_retenido, total, + moneda, metodo_pago, forma_pago, uso_cfdi, estado + FROM "${schema}".cfdis + ${whereClause} + ORDER BY fecha_emision DESC + `, ...params); + + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('CFDIs'); + + sheet.columns = [ + { header: 'UUID', key: 'uuid_fiscal', width: 40 }, + { header: 'Tipo', key: 'tipo', width: 10 }, + { header: 'Serie', key: 'serie', width: 10 }, + { header: 'Folio', key: 'folio', width: 10 }, + { header: 'Fecha Emisión', key: 'fecha_emision', width: 15 }, + { header: 'RFC Emisor', key: 'rfc_emisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombre_emisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfc_receptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombre_receptor', width: 30 }, + { header: 'Subtotal', key: 'subtotal', width: 15 }, + { header: 'IVA', key: 'iva', width: 15 }, + { header: 'Total', key: 'total', width: 15 }, + { header: 'Estado', key: 'estado', width: 12 }, + ]; + + sheet.getRow(1).font = { bold: true }; + sheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; + + cfdis.forEach(cfdi => { + sheet.addRow({ + ...cfdi, + fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'), + subtotal: Number(cfdi.subtotal), + iva: Number(cfdi.iva), + total: Number(cfdi.total), + }); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + +export async function exportReporteToExcel( + schema: string, + tipo: 'estado-resultados' | 'flujo-efectivo', + fechaInicio: string, + fechaFin: string +): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo'); + + if (tipo === 'estado-resultados') { + const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number }]>(` + SELECT + COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos, + COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos + FROM "${schema}".cfdis + WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2 + `, fechaInicio, fechaFin); + + sheet.columns = [ + { header: 'Concepto', key: 'concepto', width: 40 }, + { header: 'Monto', key: 'monto', width: 20 }, + ]; + + sheet.addRow({ concepto: 'INGRESOS', monto: '' }); + sheet.addRow({ concepto: 'Total Ingresos', monto: Number(totales?.ingresos || 0) }); + sheet.addRow({ concepto: '', monto: '' }); + sheet.addRow({ concepto: 'EGRESOS', monto: '' }); + sheet.addRow({ concepto: 'Total Egresos', monto: Number(totales?.egresos || 0) }); + sheet.addRow({ concepto: '', monto: '' }); + sheet.addRow({ concepto: 'UTILIDAD NETA', monto: Number(totales?.ingresos || 0) - Number(totales?.egresos || 0) }); + } + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +}