import type { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import * as alertasService from '../services/alertas.service.js'; import { generarAlertasAutomaticas, SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT } from '../services/alertas-auto.service.js'; import { sincronizarAlertasManuales, getAlertasManualesPendientes, resolverAlerta } from '../services/alertas-manuales.service.js'; import { getRegimenesActivosClavesEfectivos } from '../services/regimen.service.js'; import { prisma } from '../config/database.js'; import { AppError } from '../middlewares/error.middleware.js'; const createAlertaSchema = z.object({ tipo: z.enum(['vencimiento', 'discrepancia', 'iva_favor', 'declaracion', 'limite_cfdi', 'custom']), titulo: z.string().min(1).max(200), mensaje: z.string().min(1).max(2000), prioridad: z.enum(['alta', 'media', 'baja']), fechaVencimiento: z.string().optional(), }); const updateAlertaSchema = z.object({ leida: z.boolean().optional(), resuelta: z.boolean().optional(), }); export async function getAlertas(req: Request, res: Response, next: NextFunction) { try { const { leida, resuelta, prioridad } = req.query; const alertas = await alertasService.getAlertas(req.tenantPool!, { 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.tenantPool!, parseInt(String(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 data = createAlertaSchema.parse(req.body); const alerta = await alertasService.createAlerta(req.tenantPool!, data); res.status(201).json(alerta); } catch (error) { if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); next(error); } } export async function updateAlerta(req: Request, res: Response, next: NextFunction) { try { const data = updateAlertaSchema.parse(req.body); const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), data); res.json(alerta); } catch (error) { if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); next(error); } } export async function deleteAlerta(req: Request, res: Response, next: NextFunction) { try { await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(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.tenantPool!); res.json(stats); } catch (error) { next(error); } } export async function markAllAsRead(req: Request, res: Response, next: NextFunction) { try { await alertasService.markAllAsRead(req.tenantPool!); res.json({ success: true }); } catch (error) { next(error); } } export async function getManualesPendientes(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; // Sincronizar primero (crear alertas para eventos vencidos nuevos) await sincronizarAlertasManuales(req.tenantPool!, req.user!.tenantId, contribuyenteId || null); // Devolver pendientes (filtered by contribuyente or user role) const alertas = await getAlertasManualesPendientes( req.tenantPool!, contribuyenteId || null, req.user!.userId, req.user!.role, ); res.json(alertas); } catch (error) { next(error); } } export async function resolverAlertaManual(req: Request, res: Response, next: NextFunction) { try { await resolverAlerta(req.tenantPool!, String(req.params.id)); res.json({ success: true }); } catch (error) { next(error); } } export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null); res.json(alertas); } catch (error) { next(error); } } // Drill-down: Clientes en lista negra export async function getListaNegraClientes(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const listaRfcs = await prisma.listaNegra.findMany({ where: { situacion: { in: ['Definitivo', 'Presunto'] } }, select: { rfc: true, nombre: true, situacion: true }, }); const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l])); const { rows } = await req.tenantPool!.query(` SELECT rfc_receptor as rfc, nombre_receptor as nombre, COUNT(*)::int as cantidad, SUM(total_mxn) as total FROM cfdis WHERE type = 'EMITIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I' ${cf} GROUP BY rfc_receptor, nombre_receptor ORDER BY total DESC `); const result = rows .filter((r: any) => rfcMap.has(r.rfc)) .map((r: any) => ({ rfc: r.rfc, nombre: r.nombre, cantidad: r.cantidad, total: Number(r.total), situacionSat: rfcMap.get(r.rfc)!.situacion, })); res.json(result); } catch (error) { next(error); } } // Drill-down: Proveedores en lista negra export async function getListaNegraProveedores(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const listaRfcs = await prisma.listaNegra.findMany({ where: { situacion: { in: ['Definitivo', 'Presunto'] } }, select: { rfc: true, nombre: true, situacion: true }, }); const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l])); const { rows } = await req.tenantPool!.query(` SELECT rfc_emisor as rfc, nombre_emisor as nombre, COUNT(*)::int as cantidad, SUM(total_mxn) as total FROM cfdis WHERE type = 'RECIBIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I' ${cf} GROUP BY rfc_emisor, nombre_emisor ORDER BY total DESC `); const result = rows .filter((r: any) => rfcMap.has(r.rfc)) .map((r: any) => ({ rfc: r.rfc, nombre: r.nombre, cantidad: r.cantidad, total: Number(r.total), situacionSat: rfcMap.get(r.rfc)!.situacion, })); res.json(result); } catch (error) { next(error); } } // Drill-down: Concentración de clientes export async function getConcentracionClientes(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const { rows } = await req.tenantPool!.query(` SELECT rfc_receptor as rfc, nombre_receptor as nombre, COUNT(*)::int as cantidad, SUM(total_mxn) as total FROM cfdis WHERE type = 'EMITIDO' AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0') AND total_mxn > 0 ${cf} GROUP BY rfc_receptor, nombre_receptor ORDER BY total DESC `); const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0); const result = rows.map((r: any) => ({ rfc: r.rfc, nombre: r.nombre, cantidad: r.cantidad, total: Number(r.total), participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0, })); res.json(result); } catch (error) { next(error); } } // Drill-down: Concentración de proveedores export async function getConcentracionProveedores(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const { rows } = await req.tenantPool!.query(` SELECT rfc_emisor as rfc, nombre_emisor as nombre, COUNT(*)::int as cantidad, SUM(total_mxn) as total FROM cfdis WHERE type = 'RECIBIDO' AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0') AND total_mxn > 0 ${cf} GROUP BY rfc_emisor, nombre_emisor ORDER BY total DESC `); const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0); const result = rows.map((r: any) => ({ rfc: r.rfc, nombre: r.nombre, cantidad: r.cantidad, total: Number(r.total), participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0, })); res.json(result); } catch (error) { next(error); } } // Drill-down: CFDIs con discrepancia de régimen export async function getDiscrepanciaRegimen(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const activos = await getRegimenesActivosClavesEfectivos(req.user!.tenantId, req.tenantPool!, contribuyenteId); if (activos.length === 0) return res.json([]); const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const { rows } = await req.tenantPool!.query(` SELECT id, uuid, type, fecha_emision as "fechaEmision", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", total_mxn as "totalMxn", regimen_fiscal_receptor as "regimenReceptor" FROM cfdis WHERE type = 'RECIBIDO' AND status = 'Vigente' AND fecha_cancelacion IS NULL AND regimen_fiscal_receptor IS NOT NULL AND regimen_fiscal_receptor != ALL($1) AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen') ${cf} ORDER BY fecha_emision DESC `, [activos]); res.json(rows); } catch (error) { next(error); } } // Drill-down: CFDIs cancelados export async function getCancelados(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const hace5 = new Date(); hace5.setFullYear(hace5.getFullYear() - 5); const { rows } = await req.tenantPool!.query(` SELECT id, uuid, type, fecha_emision as "fechaEmision", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion" FROM cfdis WHERE status IN ('Cancelado', '0') AND (fecha_emision - interval '1 hour') >= $1::date ${cf} ORDER BY fecha_emision DESC `, [hace5.toISOString().split('T')[0]]); res.json(rows); } catch (error) { next(error); } } // Drill-down: Facturas de periodos anteriores canceladas este mes export async function getCancelacionesPeriodoAnterior(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const ahora = new Date(); const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`; const { rows } = await req.tenantPool!.query(` SELECT id, uuid, type, fecha_emision as "fechaEmision", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", total_mxn as "totalMxn", tipo_comprobante as "tipoComprobante", fecha_cancelacion as "fechaCancelacion" FROM cfdis WHERE status IN ('Cancelado', '0') AND fecha_cancelacion >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date ${cf} ORDER BY fecha_cancelacion DESC `, [inicioMes]); res.json(rows); } catch (error) { next(error); } } // Drill-down: CFDIs con pago en efectivo export async function getEfectivo(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const { rows } = await req.tenantPool!.query(` SELECT id, uuid, type, fecha_emision as "fechaEmision", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", total_mxn as "totalMxn", forma_pago as "formaPago" FROM cfdis WHERE status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I' AND forma_pago = '01' ${cf} ORDER BY fecha_emision DESC `); res.json(rows); } catch (error) { next(error); } } // Drill-down: CFDIs tipo E con TipoRelacion sospechoso (debería ser 07) export async function getTipoRelacionSospechosa(req: Request, res: Response, next: NextFunction) { try { const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; const { rows } = await req.tenantPool!.query(` SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision", c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor", c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor", c.total_mxn AS "totalMxn", c.tipo_comprobante AS "tipoComprobante", c.cfdi_tipo_relacion AS "cfdiTipoRelacion", c.cfdis_relacionados AS "cfdisRelacionados" FROM cfdis c WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT} ${cf} ORDER BY c.fecha_emision DESC `); res.json(rows); } catch (error) { next(error); } } // ── Descarte de CFDIs de alertas ── export async function descartarCfdis(req: Request, res: Response, next: NextFunction) { try { const { cfdiIds, tipoAlerta } = z.object({ cfdiIds: z.array(z.number().int()), tipoAlerta: z.string().min(1), }).parse(req.body); for (const cfdiId of cfdiIds) { await req.tenantPool!.query( `INSERT INTO cfdi_descartados (cfdi_id, tipo_alerta, descartado_por) VALUES ($1, $2, $3) ON CONFLICT (cfdi_id, tipo_alerta) DO NOTHING`, [cfdiId, tipoAlerta, req.user!.email], ); } res.json({ descartados: cfdiIds.length }); } catch (error) { if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); next(error); } } export async function restaurarDescartados(req: Request, res: Response, next: NextFunction) { try { const { cfdiIds, tipoAlerta } = z.object({ cfdiIds: z.array(z.number().int()).optional(), tipoAlerta: z.string().min(1), }).parse(req.body); if (cfdiIds && cfdiIds.length > 0) { await req.tenantPool!.query( `DELETE FROM cfdi_descartados WHERE tipo_alerta = $1 AND cfdi_id = ANY($2)`, [tipoAlerta, cfdiIds], ); } else { await req.tenantPool!.query( `DELETE FROM cfdi_descartados WHERE tipo_alerta = $1`, [tipoAlerta], ); } res.json({ success: true }); } catch (error) { if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); next(error); } } export async function getDescartados(req: Request, res: Response, next: NextFunction) { try { const tipoAlerta = req.query.tipoAlerta as string; if (!tipoAlerta) return next(new AppError(400, 'tipoAlerta requerido')); const contribuyenteId = req.query.contribuyenteId as string | undefined; const cf = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` : ''; // JOIN con cfdis para devolver datos completos (mismo shape que el // drill-down activo, para que el frontend pueda reutilizar el componente). const { rows } = await req.tenantPool!.query(` SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision", c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor", c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor", c.total_mxn AS "totalMxn", c.regimen_fiscal_receptor AS "regimenReceptor", d.descartado_por AS "descartadoPor", d.created_at AS "descartadoEn" FROM cfdi_descartados d JOIN cfdis c ON c.id = d.cfdi_id WHERE d.tipo_alerta = $1 ${cf} ORDER BY d.created_at DESC `, [tipoAlerta]); res.json({ data: rows }); } catch (error) { next(error); } }