feat(api): add dashboard API endpoints (kpis, ingresos-egresos, resumen-fiscal, alertas)

This commit is contained in:
Consultoria AS
2026-01-22 02:19:22 +00:00
parent 5bd5f9cef9
commit 4d0d23c642
4 changed files with 209 additions and 0 deletions

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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 };

View File

@@ -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<KpiData> {
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<IngresosEgresosData[]> {
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<ResumenFiscal> {
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<Alerta[]> {
const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
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;
}