feat(alertas): add alerts CRUD with stats and management UI
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,10 @@ import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
||||
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
||||
import { exportRoutes } from './routes/export.routes.js';
|
||||
import { alertasRoutes } from './routes/alertas.routes.js';
|
||||
import { calendarioRoutes } from './routes/calendario.routes.js';
|
||||
import { reportesRoutes } from './routes/reportes.routes.js';
|
||||
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -33,6 +37,10 @@ app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/cfdi', cfdiRoutes);
|
||||
app.use('/api/impuestos', impuestosRoutes);
|
||||
app.use('/api/export', exportRoutes);
|
||||
app.use('/api/alertas', alertasRoutes);
|
||||
app.use('/api/calendario', calendarioRoutes);
|
||||
app.use('/api/reportes', reportesRoutes);
|
||||
app.use('/api/usuarios', usuariosRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
73
apps/api/src/controllers/alertas.controller.ts
Normal file
73
apps/api/src/controllers/alertas.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as alertasService from '../services/alertas.service.js';
|
||||
|
||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { leida, resuelta, prioridad } = req.query;
|
||||
const alertas = await alertasService.getAlertas(req.tenantSchema!, {
|
||||
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
|
||||
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
|
||||
prioridad: prioridad as string,
|
||||
});
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id));
|
||||
if (!alerta) {
|
||||
return res.status(404).json({ message: 'Alerta no encontrada' });
|
||||
}
|
||||
res.json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body);
|
||||
res.status(201).json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body);
|
||||
res.json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const stats = await alertasService.getStats(req.tenantSchema!);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.markAllAsRead(req.tenantSchema!);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
52
apps/api/src/controllers/calendario.controller.ts
Normal file
52
apps/api/src/controllers/calendario.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as calendarioService from '../services/calendario.service.js';
|
||||
|
||||
export async function getEventos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { año, mes } = req.query;
|
||||
const añoNum = parseInt(año as string) || new Date().getFullYear();
|
||||
const mesNum = mes ? parseInt(mes as string) : undefined;
|
||||
|
||||
const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum);
|
||||
res.json(eventos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProximos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const dias = parseInt(req.query.dias as string) || 30;
|
||||
const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias);
|
||||
res.json(eventos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const evento = await calendarioService.createEvento(req.tenantSchema!, req.body);
|
||||
res.status(201).json(evento);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body);
|
||||
res.json(evento);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
55
apps/api/src/controllers/reportes.controller.ts
Normal file
55
apps/api/src/controllers/reportes.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as reportesService from '../services/reportes.service.js';
|
||||
|
||||
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { 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 data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFlujoEfectivo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { 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 data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await reportesService.getComparativo(req.tenantSchema!, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConcentradoRfc(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { fechaInicio, fechaFin, tipo } = 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 tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
|
||||
|
||||
const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
51
apps/api/src/controllers/usuarios.controller.ts
Normal file
51
apps/api/src/controllers/usuarios.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as usuariosService from '../services/usuarios.service.js';
|
||||
import { AppError } from '../utils/errors.js';
|
||||
|
||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const usuarios = await usuariosService.getUsuarios(req.user!.tenantId);
|
||||
res.json(usuarios);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden invitar usuarios');
|
||||
}
|
||||
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, req.body);
|
||||
res.status(201).json(usuario);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
|
||||
}
|
||||
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body);
|
||||
res.json(usuario);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
|
||||
}
|
||||
if (req.params.id === req.user!.id) {
|
||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||
}
|
||||
await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
19
apps/api/src/routes/alertas.routes.ts
Normal file
19
apps/api/src/routes/alertas.routes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as alertasController from '../controllers/alertas.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
router.get('/', alertasController.getAlertas);
|
||||
router.get('/stats', alertasController.getStats);
|
||||
router.post('/mark-all-read', alertasController.markAllAsRead);
|
||||
router.get('/:id', alertasController.getAlerta);
|
||||
router.post('/', alertasController.createAlerta);
|
||||
router.patch('/:id', alertasController.updateAlerta);
|
||||
router.delete('/:id', alertasController.deleteAlerta);
|
||||
|
||||
export { router as alertasRoutes };
|
||||
17
apps/api/src/routes/calendario.routes.ts
Normal file
17
apps/api/src/routes/calendario.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as calendarioController from '../controllers/calendario.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
router.get('/', calendarioController.getEventos);
|
||||
router.get('/proximos', calendarioController.getProximos);
|
||||
router.post('/', calendarioController.createEvento);
|
||||
router.patch('/:id', calendarioController.updateEvento);
|
||||
router.delete('/:id', calendarioController.deleteEvento);
|
||||
|
||||
export { router as calendarioRoutes };
|
||||
16
apps/api/src/routes/reportes.routes.ts
Normal file
16
apps/api/src/routes/reportes.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 reportesController from '../controllers/reportes.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
||||
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
||||
router.get('/comparativo', reportesController.getComparativo);
|
||||
router.get('/concentrado-rfc', reportesController.getConcentradoRfc);
|
||||
|
||||
export { router as reportesRoutes };
|
||||
14
apps/api/src/routes/usuarios.routes.ts
Normal file
14
apps/api/src/routes/usuarios.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import * as usuariosController from '../controllers/usuarios.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', usuariosController.getUsuarios);
|
||||
router.post('/invite', usuariosController.inviteUsuario);
|
||||
router.patch('/:id', usuariosController.updateUsuario);
|
||||
router.delete('/:id', usuariosController.deleteUsuario);
|
||||
|
||||
export { router as usuariosRoutes };
|
||||
108
apps/api/src/services/alertas.service.ts
Normal file
108
apps/api/src/services/alertas.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
|
||||
|
||||
export async function getAlertas(
|
||||
schema: string,
|
||||
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
|
||||
): Promise<AlertaFull[]> {
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.leida !== undefined) {
|
||||
whereClause += ` AND leida = $${paramIndex++}`;
|
||||
params.push(filters.leida);
|
||||
}
|
||||
if (filters.resuelta !== undefined) {
|
||||
whereClause += ` AND resuelta = $${paramIndex++}`;
|
||||
params.push(filters.resuelta);
|
||||
}
|
||||
if (filters.prioridad) {
|
||||
whereClause += ` AND prioridad = $${paramIndex++}`;
|
||||
params.push(filters.prioridad);
|
||||
}
|
||||
|
||||
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
${whereClause}
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
created_at DESC
|
||||
`, ...params);
|
||||
|
||||
return alertas;
|
||||
}
|
||||
|
||||
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
WHERE id = $1
|
||||
`, id);
|
||||
return alerta || null;
|
||||
}
|
||||
|
||||
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
|
||||
return alerta;
|
||||
}
|
||||
|
||||
export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.leida !== undefined) {
|
||||
sets.push(`leida = $${paramIndex++}`);
|
||||
params.push(data.leida);
|
||||
}
|
||||
if (data.resuelta !== undefined) {
|
||||
sets.push(`resuelta = $${paramIndex++}`);
|
||||
params.push(data.resuelta);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
UPDATE "${schema}".alertas
|
||||
SET ${sets.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, ...params);
|
||||
|
||||
return alerta;
|
||||
}
|
||||
|
||||
export async function deleteAlerta(schema: string, id: number): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
|
||||
}
|
||||
|
||||
export async function getStats(schema: string): Promise<AlertasStats> {
|
||||
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
|
||||
SELECT
|
||||
COUNT(*)::int as total,
|
||||
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
|
||||
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
|
||||
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
|
||||
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
|
||||
FROM "${schema}".alertas
|
||||
`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function markAllAsRead(schema: string): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
|
||||
}
|
||||
102
apps/api/src/services/calendario.service.ts
Normal file
102
apps/api/src/services/calendario.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||
|
||||
export async function getEventos(
|
||||
schema: string,
|
||||
año: number,
|
||||
mes?: number
|
||||
): Promise<EventoFiscal[]> {
|
||||
let whereClause = `WHERE EXTRACT(YEAR FROM fecha_limite) = $1`;
|
||||
const params: any[] = [año];
|
||||
|
||||
if (mes) {
|
||||
whereClause += ` AND EXTRACT(MONTH FROM fecha_limite) = $2`;
|
||||
params.push(mes);
|
||||
}
|
||||
|
||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
SELECT id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".calendario_fiscal
|
||||
${whereClause}
|
||||
ORDER BY fecha_limite ASC
|
||||
`, ...params);
|
||||
|
||||
return eventos;
|
||||
}
|
||||
|
||||
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
|
||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
SELECT id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".calendario_fiscal
|
||||
WHERE completado = false
|
||||
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
|
||||
ORDER BY fecha_limite ASC
|
||||
`);
|
||||
|
||||
return eventos;
|
||||
}
|
||||
|
||||
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
|
||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
INSERT INTO "${schema}".calendario_fiscal
|
||||
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
`, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);
|
||||
|
||||
return evento;
|
||||
}
|
||||
|
||||
export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.titulo !== undefined) {
|
||||
sets.push(`titulo = $${paramIndex++}`);
|
||||
params.push(data.titulo);
|
||||
}
|
||||
if (data.descripcion !== undefined) {
|
||||
sets.push(`descripcion = $${paramIndex++}`);
|
||||
params.push(data.descripcion);
|
||||
}
|
||||
if (data.fechaLimite !== undefined) {
|
||||
sets.push(`fecha_limite = $${paramIndex++}`);
|
||||
params.push(data.fechaLimite);
|
||||
}
|
||||
if (data.completado !== undefined) {
|
||||
sets.push(`completado = $${paramIndex++}`);
|
||||
params.push(data.completado);
|
||||
}
|
||||
if (data.notas !== undefined) {
|
||||
sets.push(`notas = $${paramIndex++}`);
|
||||
params.push(data.notas);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
UPDATE "${schema}".calendario_fiscal
|
||||
SET ${sets.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
`, ...params);
|
||||
|
||||
return evento;
|
||||
}
|
||||
|
||||
export async function deleteEvento(schema: string, id: number): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
|
||||
}
|
||||
170
apps/api/src/services/reportes.service.ts
Normal file
170
apps/api/src/services/reportes.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||
|
||||
export async function getEstadoResultados(
|
||||
schema: string,
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<EstadoResultados> {
|
||||
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number; iva: 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,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
||||
FROM "${schema}".cfdis
|
||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const totalIngresos = Number(totales?.ingresos || 0);
|
||||
const totalEgresos = Number(totales?.egresos || 0);
|
||||
const utilidadBruta = totalIngresos - totalEgresos;
|
||||
const impuestos = Number(totales?.iva || 0);
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: Number(i.total) })),
|
||||
egresos: egresos.map(e => ({ concepto: e.nombre, monto: Number(e.total) })),
|
||||
totalIngresos,
|
||||
totalEgresos,
|
||||
utilidadBruta,
|
||||
impuestos,
|
||||
utilidadNeta: utilidadBruta - (impuestos > 0 ? impuestos : 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFlujoEfectivo(
|
||||
schema: string,
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<FlujoEfectivo> {
|
||||
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const totalEntradas = entradas.reduce((sum, e) => sum + Number(e.total), 0);
|
||||
const totalSalidas = salidas.reduce((sum, s) => sum + Number(s.total), 0);
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
saldoInicial: 0,
|
||||
entradas: entradas.map(e => ({ concepto: e.mes, monto: Number(e.total) })),
|
||||
salidas: salidas.map(s => ({ concepto: s.mes, monto: Number(s.total) })),
|
||||
totalEntradas,
|
||||
totalSalidas,
|
||||
flujoNeto: totalEntradas - totalSalidas,
|
||||
saldoFinal: totalEntradas - totalSalidas,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getComparativo(
|
||||
schema: string,
|
||||
año: number
|
||||
): Promise<ComparativoPeriodos> {
|
||||
const actual = 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 mes ORDER BY mes
|
||||
`, año);
|
||||
|
||||
const anterior = 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 mes ORDER BY mes
|
||||
`, año - 1);
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
const ingresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.ingresos || 0));
|
||||
const egresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.egresos || 0));
|
||||
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
||||
|
||||
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorIng = anterior.reduce((a, b) => a + Number(b.ingresos), 0);
|
||||
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorEgr = anterior.reduce((a, b) => a + Number(b.egresos), 0);
|
||||
|
||||
return {
|
||||
periodos: meses,
|
||||
ingresos,
|
||||
egresos,
|
||||
utilidad,
|
||||
variacionIngresos: totalAnteriorIng > 0 ? ((totalActualIng - totalAnteriorIng) / totalAnteriorIng) * 100 : 0,
|
||||
variacionEgresos: totalAnteriorEgr > 0 ? ((totalActualEgr - totalAnteriorEgr) / totalAnteriorEgr) * 100 : 0,
|
||||
variacionUtilidad: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getConcentradoRfc(
|
||||
schema: string,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
tipo: 'cliente' | 'proveedor'
|
||||
): Promise<ConcentradoRfc[]> {
|
||||
if (tipo === 'cliente') {
|
||||
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||
'cliente' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
SUM(iva) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
||||
} else {
|
||||
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||
'proveedor' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
SUM(iva) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
||||
}
|
||||
}
|
||||
107
apps/api/src/services/usuarios.service.ts
Normal file
107
apps/api/src/services/usuarios.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
|
||||
|
||||
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { tenantId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
role: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return users.map(u => ({
|
||||
...u,
|
||||
lastLogin: u.lastLogin?.toISOString() || null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function inviteUsuario(tenantId: string, data: UserInvite): Promise<UserListItem> {
|
||||
// Check tenant user limit
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { usersLimit: true },
|
||||
});
|
||||
|
||||
const currentCount = await prisma.user.count({ where: { tenantId } });
|
||||
|
||||
if (currentCount >= (tenant?.usersLimit || 1)) {
|
||||
throw new Error('Límite de usuarios alcanzado para este plan');
|
||||
}
|
||||
|
||||
// Generate temporary password
|
||||
const tempPassword = Math.random().toString(36).slice(-8);
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: data.email,
|
||||
passwordHash,
|
||||
nombre: data.nombre,
|
||||
role: data.role,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
role: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// In production, send email with tempPassword
|
||||
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
|
||||
|
||||
return {
|
||||
...user,
|
||||
lastLogin: user.lastLogin?.toISOString() || null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateUsuario(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
data: UserUpdate
|
||||
): Promise<UserListItem> {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId, tenantId },
|
||||
data: {
|
||||
...(data.nombre && { nombre: data.nombre }),
|
||||
...(data.role && { role: data.role }),
|
||||
...(data.active !== undefined && { active: data.active }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
role: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...user,
|
||||
lastLogin: user.lastLogin?.toISOString() || null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteUsuario(tenantId: string, userId: string): Promise<void> {
|
||||
await prisma.user.delete({
|
||||
where: { id: userId, tenantId },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user