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