diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fa85ced..c3ec9d5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import helmet from 'helmet'; import { env } from './config/env.js'; import { errorMiddleware } from './middlewares/error.middleware.js'; import { authRoutes } from './routes/auth.routes.js'; +import { dashboardRoutes } from './routes/dashboard.routes.js'; const app = express(); @@ -25,6 +26,7 @@ app.get('/health', (req, res) => { // API Routes app.use('/api/auth', authRoutes); +app.use('/api/dashboard', dashboardRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/dashboard.controller.ts b/apps/api/src/controllers/dashboard.controller.ts new file mode 100644 index 0000000..c1fe61a --- /dev/null +++ b/apps/api/src/controllers/dashboard.controller.ts @@ -0,0 +1,65 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as dashboardService from '../services/dashboard.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +export async function getKpis(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1; + + const kpis = await dashboardService.getKpis(req.tenantSchema, año, mes); + res.json(kpis); + } catch (error) { + next(error); + } +} + +export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + + const data = await dashboardService.getIngresosEgresos(req.tenantSchema, año); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1; + + const resumen = await dashboardService.getResumenFiscal(req.tenantSchema, año, mes); + res.json(resumen); + } catch (error) { + next(error); + } +} + +export async function getAlertas(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantSchema) { + return next(new AppError(400, 'Schema no configurado')); + } + + const limit = parseInt(req.query.limit as string) || 5; + + const alertas = await dashboardService.getAlertas(req.tenantSchema, limit); + res.json(alertas); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/routes/dashboard.routes.ts b/apps/api/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..c9a8e7d --- /dev/null +++ b/apps/api/src/routes/dashboard.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as dashboardController from '../controllers/dashboard.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/kpis', dashboardController.getKpis); +router.get('/ingresos-egresos', dashboardController.getIngresosEgresos); +router.get('/resumen-fiscal', dashboardController.getResumenFiscal); +router.get('/alertas', dashboardController.getAlertas); + +export { router as dashboardRoutes }; diff --git a/apps/api/src/services/dashboard.service.ts b/apps/api/src/services/dashboard.service.ts new file mode 100644 index 0000000..9a011d0 --- /dev/null +++ b/apps/api/src/services/dashboard.service.ts @@ -0,0 +1,126 @@ +import { prisma } from '../config/database.js'; +import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared'; + +export async function getKpis(schema: string, año: number, mes: number): Promise { + const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(` + SELECT COALESCE(SUM(total), 0) as total + FROM "${schema}".cfdis + WHERE tipo = 'ingreso' + AND estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) = $2 + `, año, mes); + + const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(` + SELECT COALESCE(SUM(total), 0) as total + FROM "${schema}".cfdis + WHERE tipo = 'egreso' + AND estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) = $2 + `, año, mes); + + const [ivaData] = await prisma.$queryRawUnsafe<[{ trasladado: number; acreditable: number }]>(` + SELECT + COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado, + COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable + FROM "${schema}".cfdis + WHERE estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) = $2 + `, año, mes); + + const [counts] = await prisma.$queryRawUnsafe<[{ emitidos: number; recibidos: number }]>(` + SELECT + COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos, + COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos + FROM "${schema}".cfdis + WHERE estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND EXTRACT(MONTH FROM fecha_emision) = $2 + `, año, mes); + + const ingresosVal = Number(ingresos?.total || 0); + const egresosVal = Number(egresos?.total || 0); + const utilidad = ingresosVal - egresosVal; + const margen = ingresosVal > 0 ? (utilidad / ingresosVal) * 100 : 0; + const ivaBalance = Number(ivaData?.trasladado || 0) - Number(ivaData?.acreditable || 0); + + return { + ingresos: ingresosVal, + egresos: egresosVal, + utilidad, + margen: Math.round(margen * 100) / 100, + ivaBalance, + cfdisEmitidos: Number(counts?.emitidos || 0), + cfdisRecibidos: Number(counts?.recibidos || 0), + }; +} + +export async function getIngresosEgresos(schema: string, año: number): Promise { + const data = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(` + SELECT + EXTRACT(MONTH FROM fecha_emision)::int as mes, + COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos, + COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos + FROM "${schema}".cfdis + WHERE estado = 'vigente' + AND EXTRACT(YEAR FROM fecha_emision) = $1 + GROUP BY EXTRACT(MONTH FROM fecha_emision) + ORDER BY mes + `, año); + + const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; + + return meses.map((mes, index) => { + const found = data.find(d => d.mes === index + 1); + return { + mes, + ingresos: Number(found?.ingresos || 0), + egresos: Number(found?.egresos || 0), + }; + }); +} + +export async function getResumenFiscal(schema: string, año: number, mes: number): Promise { + const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(` + SELECT resultado, acumulado FROM "${schema}".iva_mensual + WHERE año = $1 AND mes = $2 + `, año, mes) || [{ resultado: 0, acumulado: 0 }]; + + const [pendientes] = await prisma.$queryRawUnsafe<[{ count: number }]>(` + SELECT COUNT(*) as count FROM "${schema}".iva_mensual + WHERE año = $1 AND estado = 'pendiente' + `, año); + + const resultado = Number(ivaResult?.resultado || 0); + const acumulado = Number(ivaResult?.acumulado || 0); + + return { + ivaPorPagar: resultado > 0 ? resultado : 0, + ivaAFavor: acumulado < 0 ? Math.abs(acumulado) : 0, + isrPorPagar: 0, + declaracionesPendientes: Number(pendientes?.count || 0), + proximaObligacion: { + titulo: 'Declaracion mensual IVA/ISR', + fecha: new Date(año, mes, 17).toISOString(), + }, + }; +} + +export async function getAlertas(schema: string, limit = 5): Promise { + const alertas = await prisma.$queryRawUnsafe(` + SELECT id, tipo, titulo, mensaje, prioridad, + fecha_vencimiento as "fechaVencimiento", + leida, resuelta, + created_at as "createdAt" + FROM "${schema}".alertas + WHERE resuelta = false + ORDER BY + CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END, + created_at DESC + LIMIT $1 + `, limit); + + return alertas; +}