feat(export): add Excel export for CFDIs and reports
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,8 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0",
|
||||||
|
"exceljs": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { authRoutes } from './routes/auth.routes.js';
|
|||||||
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||||
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
||||||
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
||||||
|
import { exportRoutes } from './routes/export.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ app.use('/api/auth', authRoutes);
|
|||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/cfdi', cfdiRoutes);
|
app.use('/api/cfdi', cfdiRoutes);
|
||||||
app.use('/api/impuestos', impuestosRoutes);
|
app.use('/api/impuestos', impuestosRoutes);
|
||||||
|
app.use('/api/export', exportRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
42
apps/api/src/controllers/export.controller.ts
Normal file
42
apps/api/src/controllers/export.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/routes/export.routes.ts
Normal file
14
apps/api/src/routes/export.routes.ts
Normal file
@@ -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 };
|
||||||
114
apps/api/src/services/export.service.ts
Normal file
114
apps/api/src/services/export.service.ts
Normal file
@@ -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<Buffer> {
|
||||||
|
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<any[]>(`
|
||||||
|
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<Buffer> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user