feat(api): add dashboard API endpoints (kpis, ingresos-egresos, resumen-fiscal, alertas)
This commit is contained in:
@@ -4,6 +4,7 @@ import helmet from 'helmet';
|
|||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { errorMiddleware } from './middlewares/error.middleware.js';
|
import { errorMiddleware } from './middlewares/error.middleware.js';
|
||||||
import { authRoutes } from './routes/auth.routes.js';
|
import { authRoutes } from './routes/auth.routes.js';
|
||||||
|
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ app.get('/health', (req, res) => {
|
|||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
65
apps/api/src/controllers/dashboard.controller.ts
Normal file
65
apps/api/src/controllers/dashboard.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/api/src/routes/dashboard.routes.ts
Normal file
16
apps/api/src/routes/dashboard.routes.ts
Normal 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 };
|
||||||
126
apps/api/src/services/dashboard.service.ts
Normal file
126
apps/api/src/services/dashboard.service.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user