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
507 lines
18 KiB
TypeScript
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); }
|
|
}
|