Files
HoruxDespachosNuevo/apps/api/src/controllers/alertas.controller.ts
Horux Dev 46846200da feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva:
- Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva
- sat-parser.service.ts: extrae InformacionGlobal del XML
- sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05)
- metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas:
  reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h)
- Script recalc-metricas.ts para recalculo manual

Fallback datos fiscales tenant → contribuyente:
- contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant
  rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente
  tiene el mismo RFC que el tenant y sus campos estan vacios
- contribuyente.controller.ts y contribuyente-config.controller.ts:
  pasan req.user!.tenantId al servicio

Fix critico SAT sync:
- sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs
  (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global',
   causando fallo en 100% de inserciones de CFDI)
- determineChunkMonths: salta sondeo si existe job previo con requestIds
- MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes

Docs:
- docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
2026-05-22 15:52:10 +00:00

507 lines
18 KiB
TypeScript

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