Files
HoruxDespachosNuevo/apps/api/src/services/alertas-auto.service.ts

700 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
import { getRegimenesActivosClavesEfectivos } from './regimen.service.js';
import { contarTareasProximasVencer } from './tareas.service.js';
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
/** Sanitize a contribuyente UUID for safe inline SQL injection */
function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, '');
}
export interface AlertaAuto {
id: string;
tipo: string;
titulo: string;
mensaje: string;
prioridad: 'alta' | 'media' | 'baja';
detalle?: string; // ruta para drill-down
valor?: number;
}
/**
* CFDI con discrepancia: facturas recibidas donde regimen_fiscal_receptor
* no coincide con los regímenes activos del tenant.
*/
async function alertaDiscrepanciaRegimen(
pool: Pool,
tenantId: string,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId);
if (activos.length === 0) return null;
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT COUNT(*)::int as total
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}
`, [activos]);
const total = r?.total || 0;
if (total === 0) return null;
return {
id: 'discrepancia-regimen',
tipo: 'discrepancia',
titulo: 'CFDI con Discrepancia de Regimen',
mensaje: `${total} factura(s) recibida(s) con regimen fiscal del receptor que no coincide con los regimenes activos.`,
prioridad: 'alta',
detalle: '/alertas/discrepancia-regimen',
valor: total,
};
}
/**
* Calcula el Índice de Herfindahl-Hirschman (IHH).
* IHH = Σ (cuota_de_mercado_i)^2 × 10000
*/
async function calcularIHH(
pool: Pool,
type: 'EMITIDO' | 'RECIBIDO',
contribuyenteId?: string | null,
): Promise<number> {
const rfcField = type === 'EMITIDO' ? 'rfc_receptor' : 'rfc_emisor';
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT ${rfcField} as rfc, SUM(total_mxn) as total
FROM cfdis
WHERE type = $1 AND tipo_comprobante = 'I' AND ${VIGENTE}
AND total_mxn > 0
${cf}
GROUP BY ${rfcField}
`, [type]);
if (rows.length === 0) return 0;
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
if (totalGeneral === 0) return 0;
let ihh = 0;
for (const row of rows) {
const cuota = Number(row.total) / totalGeneral;
ihh += cuota * cuota;
}
return Math.round(ihh * 10000);
}
/**
* Concentración de clientes: IHH >= 2500 en facturas emitidas
*/
async function alertaConcentracionClientes(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const ihh = await calcularIHH(pool, 'EMITIDO', contribuyenteId);
if (ihh < 2500) return null;
return {
id: 'concentracion-clientes',
tipo: 'concentracion',
titulo: 'Concentracion de Clientes',
mensaje: `El indice HHI de clientes es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos clientes.`,
prioridad: ihh >= 5000 ? 'alta' : 'media',
detalle: '/alertas/concentracion-clientes',
valor: ihh,
};
}
/**
* Concentración de proveedores: IHH >= 2500 en facturas recibidas
*/
async function alertaConcentracionProveedores(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const ihh = await calcularIHH(pool, 'RECIBIDO', contribuyenteId);
if (ihh < 2500) return null;
return {
id: 'concentracion-proveedores',
tipo: 'concentracion',
titulo: 'Concentracion de Proveedores',
mensaje: `El indice HHI de proveedores es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos proveedores.`,
prioridad: ihh >= 5000 ? 'alta' : 'media',
detalle: '/alertas/concentracion-proveedores',
valor: ihh,
};
}
/**
* Riesgo cambiario: >10% de facturas en moneda != MXN
*/
async function alertaRiesgoCambiario(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT
COUNT(*)::int as total,
COUNT(CASE WHEN moneda IS NOT NULL AND moneda != 'MXN' THEN 1 END)::int as no_mxn
FROM cfdis
WHERE ${VIGENTE} AND tipo_comprobante = 'I'
${cf}
`);
const total = r?.total || 0;
const noMxn = r?.no_mxn || 0;
if (total === 0 || noMxn === 0) return null;
const porcentaje = Math.round((noMxn / total) * 10000) / 100;
if (porcentaje <= 10) return null;
return {
id: 'riesgo-cambiario',
tipo: 'riesgo',
titulo: 'Riesgo Cambiario',
mensaje: `${porcentaje}% de las facturas (${noMxn} de ${total}) estan en moneda extranjera. Exposicion a fluctuaciones del tipo de cambio.`,
prioridad: porcentaje > 30 ? 'alta' : 'media',
valor: porcentaje,
};
}
/**
* Riesgo de cancelaciones: >10% de facturas canceladas en últimos 5 años
*/
async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const hace5 = new Date();
hace5.setFullYear(hace5.getFullYear() - 5);
const fechaDesde = hace5.toISOString().split('T')[0];
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT
COUNT(*)::int as total,
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
FROM cfdis
WHERE (fecha_emision - interval '1 hour') >= $1::date
${cf}
`, [fechaDesde]);
const total = r?.total || 0;
const cancelados = r?.cancelados || 0;
if (total === 0 || cancelados === 0) return null;
const porcentaje = Math.round((cancelados / total) * 10000) / 100;
if (porcentaje <= 10) return null;
return {
id: 'riesgo-cancelaciones',
tipo: 'riesgo',
titulo: 'Riesgo de Cancelaciones',
mensaje: `${porcentaje}% de las facturas (${cancelados} de ${total}) en los ultimos 5 años han sido canceladas.`,
prioridad: porcentaje > 25 ? 'alta' : 'media',
detalle: '/alertas/cancelaciones',
valor: porcentaje,
};
}
/**
* Gastos recibidos pagados en efectivo > $2,000 — Art. 27 fracción III LISR.
* No son deducibles. Surface el total + conteo para que el contador entienda
* el impacto y, si aplica, gestione cambio de forma de pago con el proveedor.
*/
async function alertaRiesgoTransaccional(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT
COUNT(*)::int AS facturas,
COALESCE(SUM(COALESCE(total_mxn, 0)), 0)::numeric(14,2) AS monto
FROM cfdis
WHERE ${VIGENTE}
AND type = 'RECIBIDO'
AND tipo_comprobante = 'I'
AND metodo_pago = 'PUE'
AND forma_pago = '01'
AND COALESCE(total_mxn, 0) > 2000
${cf}
`);
const facturas = r?.facturas || 0;
const monto = Number(r?.monto || 0);
if (facturas === 0) return null;
return {
id: 'gastos-no-deducibles-efectivo',
tipo: 'riesgo',
titulo: 'Gastos no deducibles (efectivo > $2,000)',
mensaje: `${facturas} factura${facturas === 1 ? '' : 's'} recibida${facturas === 1 ? '' : 's'} por $${monto.toLocaleString('es-MX')} MXN se pagaron en efectivo (>$2,000). Art. 27 fracción III LISR las hace NO deducibles para ISR.`,
prioridad: monto > 50000 ? 'alta' : 'media',
detalle: '/impuestos',
valor: monto,
};
}
/**
* Estatus lista negra: si el RFC del tenant/contribuyente aparece en la lista negra
*/
async function alertaListaNegraPropia(
pool: Pool,
tenantId: string,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
let rfc: string | undefined;
if (contribuyenteId) {
const safeId = sanitizeUuid(contribuyenteId);
const { rows } = await pool.query(
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
[safeId],
);
rfc = rows[0]?.rfc;
} else {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
rfc = tenant?.rfc;
}
if (!rfc) return null;
const registro = await prisma.listaNegra.findUnique({
where: { rfc },
});
if (!registro) return null;
return {
id: 'lista-negra-propia',
tipo: 'lista-negra',
titulo: 'RFC en Lista Negra del SAT',
mensaje: `Tu RFC (${rfc}) aparece en la lista del Art. 69-B del CFF con situacion: ${registro.situacion}.`,
prioridad: 'alta',
};
}
/**
* Factura emitida a cliente en lista negra
*/
async function alertaClienteListaNegra(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
// Fallback: consultar directo si dblink no funciona
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true },
});
const rfcSet = new Set(listaRfcs.map(l => l.rfc));
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT DISTINCT rfc_receptor as rfc
FROM cfdis
WHERE type = 'EMITIDO' AND ${VIGENTE} AND tipo_comprobante = 'I'
${cf}
`);
const clientesEnLista = rows.filter((r: any) => rfcSet.has(r.rfc));
if (clientesEnLista.length === 0) return null;
return {
id: 'lista-negra-clientes',
tipo: 'lista-negra',
titulo: 'Facturas Emitidas a Clientes en Lista Negra',
mensaje: `${clientesEnLista.length} cliente(s) a los que has facturado aparecen en la lista negra del SAT (Art. 69-B).`,
prioridad: 'alta',
detalle: '/alertas/lista-negra-clientes',
valor: clientesEnLista.length,
};
}
/**
* Factura recibida de proveedor en lista negra
*/
async function alertaProveedorListaNegra(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true },
});
const rfcSet = new Set(listaRfcs.map(l => l.rfc));
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT DISTINCT rfc_emisor as rfc
FROM cfdis
WHERE type = 'RECIBIDO' AND ${VIGENTE} AND tipo_comprobante = 'I'
${cf}
`);
const proveedoresEnLista = rows.filter((r: any) => rfcSet.has(r.rfc));
if (proveedoresEnLista.length === 0) return null;
return {
id: 'lista-negra-proveedores',
tipo: 'lista-negra',
titulo: 'Facturas Recibidas de Proveedores en Lista Negra',
mensaje: `${proveedoresEnLista.length} proveedor(es) de los que has recibido facturas aparecen en la lista negra del SAT (Art. 69-B).`,
prioridad: 'alta',
detalle: '/alertas/lista-negra-proveedores',
valor: proveedoresEnLista.length,
};
}
/**
* Facturas de periodos anteriores canceladas este mes.
* Detecta CFDIs cuya fecha_cancelacion cae en el mes actual pero
* cuya fecha_emision es de un mes anterior.
*/
async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
const ahora = new Date();
const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`;
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT COUNT(*)::int as total,
COALESCE(SUM(COALESCE(total_mxn, 0)), 0) as monto
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
${cf}
`, [inicioMes]);
const total = r?.total || 0;
if (total === 0) return null;
const monto = Number(r.monto);
const montoFmt = monto.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' });
return {
id: 'cancelacion-periodo-anterior',
tipo: 'cancelacion-retroactiva',
titulo: 'Facturas de Periodos Anteriores Canceladas',
mensaje: `${total} factura(s) emitida(s) en meses anteriores fueron canceladas este mes por un total de ${montoFmt}. Esto puede afectar declaraciones ya presentadas.`,
prioridad: 'alta',
detalle: '/alertas/cancelaciones-periodo-anterior',
valor: total,
};
}
/**
* CFDIs tipo E (Egreso / nota de crédito) con cfdi_tipo_relacion != '07'
* cuya(s) referencia(s) en cfdis_relacionados también aparecen en otro CFDI
* con cfdi_tipo_relacion = '07'. Señal de que el emisor debió usar 07
* (aplicación de anticipo) pero puso 01/02/03/04: inflá gastos e IVA
* acreditable contra un anticipo ya consumido.
*
* La detección usa overlap de arrays (`&&`) sobre cfdis_relacionados
* pipe-separados — si X y Y comparten al menos un UUID referenciado y Y
* es 07, X es sospechoso.
*/
const SOSPECHOSA_TIPO_RELACION_WHERE = `
c.tipo_comprobante = 'E'
AND c.status NOT IN ('Cancelado', '0')
AND c.cfdi_tipo_relacion IS NOT NULL
AND c.cfdi_tipo_relacion <> '07'
AND c.cfdis_relacionados IS NOT NULL
AND c.cfdis_relacionados <> ''
AND EXISTS (
SELECT 1 FROM cfdis y
WHERE y.id <> c.id
AND y.cfdi_tipo_relacion = '07'
AND y.status NOT IN ('Cancelado', '0')
AND y.cfdis_relacionados IS NOT NULL
AND y.cfdis_relacionados <> ''
AND string_to_array(LOWER(y.cfdis_relacionados), '|')
&& string_to_array(LOWER(c.cfdis_relacionados), '|')
)
AND c.id NOT IN (
SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'tipo-relacion-sospechosa'
)
`;
async function alertaTipoRelacionSospechosa(
pool: Pool,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
const cf = contribuyenteId ? `AND c.contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows: [r] } = await pool.query(`
SELECT COUNT(*)::int AS total
FROM cfdis c
WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE}
${cf}
`);
const total = r?.total || 0;
if (total === 0) return null;
return {
id: 'tipo-relacion-sospechosa',
tipo: 'cfdi-inconsistente',
titulo: 'Nota de Crédito con Tipo de Relación sospechoso',
mensaje: `${total} CFDI(s) tipo E con TipoRelacion distinto de 07 referencian un CFDI tratado como anticipo por otra factura. Revisa si deberían haberse emitido como 07 (aplicación de anticipo).`,
prioridad: 'alta',
detalle: '/alertas/tipo-relacion-sospechosa',
valor: total,
};
}
/** Exportado para reutilizar en el controller de drill-down. */
export const SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT = SOSPECHOSA_TIPO_RELACION_WHERE;
/**
* Tareas operativas próximas a vencer (≤3 días). Solo aplica cuando hay un
* contribuyente seleccionado — sin contribuyente, no se puede contar
* porque las tareas son siempre per-contribuyente.
*/
async function alertaTareasProximasVencer(
pool: Pool,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
if (!contribuyenteId) return null;
const { total } = await contarTareasProximasVencer(pool, contribuyenteId);
if (total === 0) return null;
return {
id: 'tareas-proximas-vencer',
tipo: 'tareas',
titulo: 'Tareas próximas a vencer',
mensaje: `${total} tarea(s) operativa(s) vencen en los próximos 3 días.`,
prioridad: 'media',
detalle: '/configuracion/obligaciones',
valor: total,
};
}
/**
* RESICO PF cerca de salir del régimen por exceso de ingresos anuales.
*
* Art. 113-E LISR — el contribuyente PF en RESICO debe salir del régimen si
* sus ingresos del ejercicio exceden $3,500,000. **Importante:** el SAT
* considera ingresos acumulados de TODOS los regímenes del contribuyente, no
* solo los del 626. Por eso este query no filtra por `regimen_fiscal_emisor`.
*
* Umbrales:
* - $2,500,000 → alerta media (margen ~$1M)
* - $3,000,000 → alerta alta (margen ~$500k al límite)
* - $3,500,000 → alerta alta crítica ("ya superaste")
*
* Aplica solo cuando:
* 1. Hay un contribuyente seleccionado (per-tenant no tiene sentido — la
* alerta es por entidad fiscal individual)
* 2. RFC de 13 caracteres (Persona Física — RESICO PM no tiene este límite)
* 3. Régimen 626 está en su lista de regímenes activos
*
* Cálculo de ingresos: agregado de CFDIs emitidos vigentes del año en curso:
* + I PUE (cobradas al emitir)
* + P (complementos de pago — cobros de PPD anteriores)
* - E PUE (notas de crédito netan)
*
* Sin desglose por régimen ni filtro de conciliación. Se usa `total_mxn` como
* proxy de ingreso (incluye IVA — sobreestima ~16%, conservador para alerta).
*/
async function alertaResicoPfLimiteIngresos(
pool: Pool,
contribuyenteId?: string | null,
): Promise<AlertaAuto | null> {
if (!contribuyenteId) return null;
const safeId = sanitizeUuid(contribuyenteId);
// Verificar elegibilidad: PF (RFC 13) + régimen 626 activo
const { rows: contribRows } = await pool.query(
`SELECT rfc, regimen_fiscal FROM contribuyentes WHERE entidad_id = $1`,
[safeId],
);
const contrib = contribRows[0];
if (!contrib) return null;
const rfc: string = contrib.rfc || '';
if (rfc.length !== 13) return null; // PM no aplica
const regimenesCsv: string = contrib.regimen_fiscal || '';
const regimenes = regimenesCsv.split(',').map((s: string) => s.trim()).filter(Boolean);
if (!regimenes.includes('626')) return null;
// Suma ingresos del año en curso, agregado de TODOS los regímenes
const año = new Date().getFullYear();
const { rows: [r] } = await pool.query(`
SELECT COALESCE(SUM(
CASE
WHEN tipo_comprobante = 'I' AND metodo_pago = 'PUE' THEN COALESCE(total_mxn, 0)
WHEN tipo_comprobante = 'P' THEN COALESCE(monto_pago_mxn, 0)
WHEN tipo_comprobante = 'E' AND metodo_pago = 'PUE' THEN -COALESCE(total_mxn, 0)
ELSE 0
END
), 0)::numeric AS ingresos
FROM cfdis
WHERE type = 'EMITIDO'
AND status NOT IN ('Cancelado', '0')
AND EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')) = $1
AND contribuyente_id = $2
`, [año, safeId]);
const ingresos = Number(r?.ingresos || 0);
const UMBRAL_AVISO = 2_500_000;
const UMBRAL_ALTO = 3_000_000;
const LIMITE_LEGAL = 3_500_000; // Art. 113-E LISR
if (ingresos < UMBRAL_AVISO) return null;
const ingresosFmt = ingresos.toLocaleString('es-MX', {
style: 'currency', currency: 'MXN', maximumFractionDigits: 0,
});
let prioridad: 'alta' | 'media' = 'media';
let titulo = `RESICO PF cerca del límite anual`;
let mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Límite RESICO PF: $3,500,000 (Art. 113-E LISR). Se considera ingresos de TODOS los regímenes, no solo del 626.`;
if (ingresos >= LIMITE_LEGAL) {
prioridad = 'alta';
titulo = `RESICO PF: límite anual EXCEDIDO`;
mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Excede el límite de $3,500,000 del Art. 113-E LISR. El contribuyente debe salir de RESICO PF y tributar bajo régimen general (PF Empresarial).`;
} else if (ingresos >= UMBRAL_ALTO) {
prioridad = 'alta';
titulo = `RESICO PF: cerca del límite ($3M+)`;
}
return {
id: 'resico-pf-limite-ingresos',
tipo: 'limite-regimen',
titulo,
mensaje,
prioridad,
valor: ingresos,
};
}
/**
* Alerta si la última Opinión de Cumplimiento no es Positiva.
*/
async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
let rfcFilter = '';
if (contribuyenteId) {
const safeId = sanitizeUuid(contribuyenteId);
const { rows: rfcRows } = await pool.query(
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
[safeId],
);
const rfc: string | undefined = rfcRows[0]?.rfc;
if (rfc) rfcFilter = `WHERE rfc = '${rfc.replace(/'/g, "''")}'`;
}
const { rows } = await pool.query(`
SELECT estatus, fecha_consulta
FROM opiniones_cumplimiento
${rfcFilter}
ORDER BY fecha_consulta DESC
LIMIT 1
`);
if (rows.length === 0) return null;
const { estatus, fecha_consulta } = rows[0];
if (estatus === 'Positiva') return null;
const fecha = new Date(fecha_consulta).toLocaleDateString('es-MX');
return {
id: 'opinion-cumplimiento-negativa',
tipo: 'opinion-cumplimiento',
titulo: `Opinión de Cumplimiento: ${estatus}`,
mensaje: `Tu Opinión de Cumplimiento ante el SAT es ${estatus}. Última consulta: ${fecha}. Revisa tus obligaciones fiscales.`,
prioridad: 'alta',
valor: 1,
};
}
/**
* Genera todas las alertas automáticas para un tenant.
* Cada alerta se envuelve en try/catch para que un fallo en una no
* bloquee el resto (robustez ante timeouts o errores transitorios).
*/
export async function generarAlertasAutomaticas(
pool: Pool,
tenantId: string,
contribuyenteId?: string | null,
): Promise<AlertaAuto[]> {
const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
{ name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
{ name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
{ name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
{ name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
{ name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
{ name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
{ name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
{ name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
{ name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
{ name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
{ name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
{ name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
{ name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
{ name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) },
];
const alertas: AlertaAuto[] = [];
for (const g of generadores) {
try {
const a = await g.fn();
if (a) alertas.push(a);
} catch (err: any) {
console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err);
}
}
if (alertas.length > 0) {
console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`);
}
return alertas;
}
/**
* Breakdown mensual de discrepancias de régimen de los últimos N meses.
* Cuenta facturas RECIBIDAS donde regimen_fiscal_receptor no coincide con
* los regímenes activos del tenant. Útil para el correo semanal — el cliente
* ve cuántas facturas con error le emitieron mes por mes.
*/
export async function getDiscrepanciasPorMes(
pool: Pool,
tenantId: string,
monthsBack = 6,
contribuyenteId?: string | null,
): Promise<Array<{ año: number; mes: number; count: number; label: string }>> {
const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId);
if (activos.length === 0) return [];
const desde = new Date();
desde.setMonth(desde.getMonth() - (monthsBack - 1));
desde.setDate(1);
const desdeStr = desde.toISOString().split('T')[0];
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
const { rows } = await pool.query(`
SELECT
EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as año,
EXTRACT(MONTH FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as mes,
COUNT(*)::int as count
FROM cfdis
WHERE type = 'RECIBIDO' AND ${VIGENTE}
AND regimen_fiscal_receptor IS NOT NULL
AND regimen_fiscal_receptor != ALL($1)
AND fecha_emision >= $2::date
${cf}
GROUP BY año, mes
ORDER BY año DESC, mes DESC
`, [activos, desdeStr]);
const NOMBRES_MES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
return rows.map((r: any) => ({
año: r.año,
mes: r.mes,
count: r.count,
label: `${NOMBRES_MES[r.mes - 1]} ${r.año}`,
}));
}