Initial commit - Horux Despachos NL
This commit is contained in:
299
apps/api/src/services/alertas-manuales.service.ts
Normal file
299
apps/api/src/services/alertas-manuales.service.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { generarEventosFiscales } from './calendario-fiscal.service.js';
|
||||
import { isDespachoTenant } from '@horux/shared';
|
||||
|
||||
interface AlertaManualGenerada {
|
||||
tipo: string;
|
||||
titulo: string;
|
||||
mensaje: string;
|
||||
prioridad: 'alta' | 'media';
|
||||
fechaVencimiento: string;
|
||||
}
|
||||
|
||||
// Mapeo de eventos del calendario a tipos de alerta (legacy Horux360)
|
||||
const EVENTO_A_ALERTA: Record<string, { prefijo: string; prioridad: 'alta' | 'media' }> = {
|
||||
'Declaración mensual ISR': { prefijo: 'decl-isr', prioridad: 'alta' },
|
||||
'Declaración mensual IVA': { prefijo: 'decl-iva', prioridad: 'alta' },
|
||||
'Declaración mensual IEPS': { prefijo: 'decl-ieps', prioridad: 'media' },
|
||||
'Pago provisional ISR': { prefijo: 'pago-isr', prioridad: 'alta' },
|
||||
'Pago provisional IVA': { prefijo: 'pago-iva', prioridad: 'alta' },
|
||||
'Pago provisional IEPS': { prefijo: 'pago-ieps', prioridad: 'media' },
|
||||
'Declaración de sueldos y salarios': { prefijo: 'decl-sueldos', prioridad: 'media' },
|
||||
'DIOT': { prefijo: 'diot', prioridad: 'media' },
|
||||
'Contabilidad electrónica': { prefijo: 'contabilidad', prioridad: 'media' },
|
||||
'Declaración anual PM': { prefijo: 'decl-anual-pm', prioridad: 'alta' },
|
||||
'Declaración anual PF': { prefijo: 'decl-anual-pf', prioridad: 'alta' },
|
||||
'Informativa Sueldos y Salarios': { prefijo: 'inf-sueldos', prioridad: 'media' },
|
||||
};
|
||||
|
||||
/**
|
||||
* For despachos: generate alerts from the contribuyente's actual obligations
|
||||
* (obligaciones_contribuyente) instead of the static fiscal calendar.
|
||||
* Only generates alerts for obligations that the contribuyente actually has.
|
||||
*/
|
||||
async function sincronizarDesdeObligacionesContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<{ creadas: number; existentes: number }> {
|
||||
const hoy = new Date();
|
||||
const currentPeriodo = hoy.toISOString().substring(0, 7); // "2026-04"
|
||||
|
||||
// Get active obligations for this contribuyente
|
||||
const { rows: obligaciones } = await pool.query(`
|
||||
SELECT id, nombre, frecuencia, fecha_limite AS "fechaLimite", created_at AS "createdAt"
|
||||
FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = $1 AND activa = true
|
||||
`, [contribuyenteId]);
|
||||
|
||||
// Get existing completions
|
||||
const { rows: completions } = await pool.query(`
|
||||
SELECT op.obligacion_id, op.periodo
|
||||
FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.contribuyente_id = $1 AND op.completada = true
|
||||
`, [contribuyenteId]);
|
||||
const completionSet = new Set(completions.map(c => `${c.obligacion_id}:${c.periodo}`));
|
||||
|
||||
let creadas = 0;
|
||||
let existentes = 0;
|
||||
|
||||
for (const ob of obligaciones) {
|
||||
const obStartPeriodo = ob.createdAt
|
||||
? new Date(ob.createdAt).toISOString().substring(0, 7)
|
||||
: '2000-01';
|
||||
|
||||
// Check current and previous month
|
||||
for (let offset = 0; offset <= 1; offset++) {
|
||||
const d = new Date(hoy.getFullYear(), hoy.getMonth() - offset, 1);
|
||||
const periodo = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
if (periodo < obStartPeriodo) continue;
|
||||
if (!appliesToPeriod(ob.frecuencia, periodo)) continue;
|
||||
if (completionSet.has(`${ob.id}:${periodo}`)) continue;
|
||||
|
||||
// Generate alert type unique per obligation+period
|
||||
const tipoUnico = `ob-${ob.id}-${periodo}`;
|
||||
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT id FROM alertas WHERE tipo = $1`,
|
||||
[tipoUnico],
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
existentes++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine deadline (day 17 of next month for mensual)
|
||||
const [y, m] = periodo.split('-').map(Number);
|
||||
const nextMonth = m === 12 ? 1 : m + 1;
|
||||
const nextYear = m === 12 ? y + 1 : y;
|
||||
const fechaVencimiento = `${nextYear}-${String(nextMonth).padStart(2, '0')}-17`;
|
||||
|
||||
const deadlineDate = new Date(fechaVencimiento + 'T23:59:59');
|
||||
const isPastDue = deadlineDate < hoy;
|
||||
const prioridad = isPastDue ? 'alta' : 'media';
|
||||
const statusLabel = isPastDue ? 'Vencida' : 'Pendiente';
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [
|
||||
tipoUnico,
|
||||
`${ob.nombre} - ${statusLabel}`,
|
||||
`${ob.fechaLimite || 'Sin fecha límite especificada'}. Periodo: ${periodo}`,
|
||||
prioridad,
|
||||
fechaVencimiento,
|
||||
]);
|
||||
creadas++;
|
||||
}
|
||||
}
|
||||
|
||||
return { creadas, existentes };
|
||||
}
|
||||
|
||||
function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
||||
const [, month] = periodo.split('-').map(Number);
|
||||
switch (frecuencia) {
|
||||
case 'mensual': return true;
|
||||
case 'bimestral': return month % 2 === 1;
|
||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||
case 'anual': return month === 3 || month === 4;
|
||||
case 'eventual': return false;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas manuales para eventos fiscales vencidos que no han sido resueltos.
|
||||
* Para despachos: usa obligaciones per-contribuyente.
|
||||
* Para Horux360: usa el calendario fiscal estático (legacy).
|
||||
*/
|
||||
export async function sincronizarAlertasManuales(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<{ creadas: number; existentes: number }> {
|
||||
// Check if this is a despacho tenant
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { rfc: true, createdAt: true },
|
||||
});
|
||||
if (!tenant) return { creadas: 0, existentes: 0 };
|
||||
|
||||
// Despacho: use per-contribuyente obligations
|
||||
if (isDespachoTenant(tenant.rfc)) {
|
||||
if (contribuyenteId) {
|
||||
return sincronizarDesdeObligacionesContribuyente(pool, contribuyenteId);
|
||||
}
|
||||
// "Todos los RFCs": don't generate new alerts — individual contribuyente alerts already exist
|
||||
return { creadas: 0, existentes: 0 };
|
||||
}
|
||||
|
||||
// Legacy Horux360: use static fiscal calendar
|
||||
const hoy = new Date();
|
||||
const añoActual = hoy.getFullYear();
|
||||
const fechaCreacion = tenant.createdAt || hoy;
|
||||
|
||||
const eventosActual = await generarEventosFiscales(tenantId, añoActual);
|
||||
const eventosAnterior = await generarEventosFiscales(tenantId, añoActual - 1);
|
||||
const todosEventos = [...eventosAnterior, ...eventosActual];
|
||||
|
||||
const vencidos = todosEventos.filter(e => {
|
||||
const fecha = new Date(e.fechaLimite + 'T23:59:59');
|
||||
return fecha < hoy && fecha >= fechaCreacion;
|
||||
});
|
||||
|
||||
let creadas = 0;
|
||||
let existentes = 0;
|
||||
|
||||
for (const evento of vencidos) {
|
||||
const config = EVENTO_A_ALERTA[evento.titulo];
|
||||
if (!config) continue;
|
||||
|
||||
const tipoUnico = `${config.prefijo}-${evento.fechaLimite}`;
|
||||
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT id, resuelta FROM alertas WHERE tipo = $1`,
|
||||
[tipoUnico]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
existentes++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const esPago = evento.titulo.startsWith('Pago');
|
||||
const accion = esPago ? 'Pago pendiente' : 'No presentada';
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [
|
||||
tipoUnico,
|
||||
`${evento.titulo} - ${accion}`,
|
||||
`${evento.descripcion}. Fecha limite: ${new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })}`,
|
||||
config.prioridad,
|
||||
evento.fechaLimite,
|
||||
]);
|
||||
|
||||
creadas++;
|
||||
}
|
||||
|
||||
return { creadas, existentes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene alertas manuales pendientes (no resueltas).
|
||||
* Filters by contribuyente or by user's accessible contribuyentes (for clientes).
|
||||
*/
|
||||
export async function getAlertasManualesPendientes(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
userId?: string | null,
|
||||
role?: string | null,
|
||||
): Promise<any[]> {
|
||||
let contribuyenteFilter = '';
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (contribuyenteId) {
|
||||
// Specific contribuyente selected
|
||||
params.push(contribuyenteId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $${params.length}
|
||||
)
|
||||
)`;
|
||||
} else if (role === 'cliente' && userId) {
|
||||
// Client with "Todos los RFCs" — only their accessible contribuyentes
|
||||
params.push(userId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
||||
SELECT entidad_id FROM cliente_accesos WHERE user_id = $${params.length}
|
||||
)
|
||||
)
|
||||
)`;
|
||||
} else if (role === 'auxiliar' && userId) {
|
||||
// Auxiliar: only their subcarteras' contribuyentes
|
||||
params.push(userId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
||||
SELECT ce.entidad_id FROM cartera_entidades ce
|
||||
JOIN carteras c ON c.id = ce.cartera_id
|
||||
WHERE c.auxiliar_user_id = $${params.length}
|
||||
UNION
|
||||
SELECT ce.entidad_id FROM cartera_entidades ce
|
||||
JOIN cartera_auxiliares ca ON ca.cartera_id = ce.cartera_id
|
||||
WHERE ca.auxiliar_user_id = $${params.length}
|
||||
)
|
||||
)
|
||||
)`;
|
||||
} else if (role === 'supervisor' && userId) {
|
||||
// Supervisor: only their carteras' contribuyentes
|
||||
params.push(userId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
||||
SELECT ce.entidad_id FROM cartera_entidades ce
|
||||
JOIN carteras c ON c.id = ce.cartera_id AND c.parent_id IS NULL
|
||||
WHERE c.supervisor_user_id = $${params.length}
|
||||
)
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
// Exclude alerts for inactive obligations
|
||||
const inactiveFilter = `AND NOT (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE activa = false
|
||||
)
|
||||
)`;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM alertas
|
||||
WHERE resuelta = false
|
||||
${inactiveFilter}
|
||||
${contribuyenteFilter}
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
fecha_vencimiento ASC
|
||||
`, params);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca una alerta como resuelta (presentada/pagada).
|
||||
*/
|
||||
export async function resolverAlerta(pool: Pool, id: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE alertas SET resuelta = true, leida = true WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user