import type { Pool } from 'pg'; export type Recurrencia = | 'semanal' | 'quincenal' | 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual'; export interface TareaCatalogo { id: string; contribuyenteId: string; nombre: string; descripcion: string | null; recurrencia: Recurrencia; diaSemana: number | null; diaMes: number | null; soloSupervisorCompleta: boolean; esDefault: boolean; active: boolean; orden: number; createdAt: Date; auxiliarAsignadoId?: string | null; auxiliarAsignadoNombre?: string | null; } export interface TareaPeriodo { id: string; tareaId: string; periodo: string; fechaLimite: Date; completada: boolean; completadaAt: Date | null; completadaPor: string | null; notas: string | null; } export interface TareaConPeriodo extends TareaCatalogo { periodoActual: TareaPeriodo | null; } const ROW_TO_TAREA = (r: any): TareaCatalogo => ({ id: r.id, contribuyenteId: r.contribuyente_id, nombre: r.nombre, descripcion: r.descripcion, recurrencia: r.recurrencia, diaSemana: r.dia_semana, diaMes: r.dia_mes, soloSupervisorCompleta: r.solo_supervisor_completa, esDefault: r.es_default, active: r.active, orden: r.orden, createdAt: r.created_at, auxiliarAsignadoId: r.auxiliarAsignadoId ?? null, auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null, }); const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({ id: r.id, tareaId: r.tarea_id, periodo: r.periodo, fechaLimite: r.fecha_limite, completada: r.completada, completadaAt: r.completada_at, completadaPor: r.completada_por, notas: r.notas, }); function sanitizeUuid(id: string): string { return id.replace(/[^a-f0-9-]/gi, ''); } // ─── Catálogo CRUD ─── export async function listTareas(pool: Pool, contribuyenteId: string): Promise { const { rows } = await pool.query( `SELECT tc.*, ta.auxiliar_user_id AS "auxiliarAsignadoId" FROM tareas_catalogo tc LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id WHERE tc.contribuyente_id = $1 AND tc.active = true ORDER BY tc.orden, tc.nombre`, [sanitizeUuid(contribuyenteId)], ); return rows.map(ROW_TO_TAREA); } export interface TareaInput { nombre: string; descripcion?: string | null; recurrencia: Recurrencia; diaSemana?: number | null; diaMes?: number | null; soloSupervisorCompleta?: boolean; orden?: number; } export async function createTarea( pool: Pool, contribuyenteId: string, data: TareaInput, ): Promise { const { rows: [r] } = await pool.query( `INSERT INTO tareas_catalogo (contribuyente_id, nombre, descripcion, recurrencia, dia_semana, dia_mes, solo_supervisor_completa, orden) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ sanitizeUuid(contribuyenteId), data.nombre, data.descripcion ?? null, data.recurrencia, data.diaSemana ?? null, data.diaMes ?? null, data.soloSupervisorCompleta ?? false, data.orden ?? 0, ], ); return ROW_TO_TAREA(r); } export async function updateTarea( pool: Pool, tareaId: string, data: Partial, ): Promise { const fields: string[] = []; const values: unknown[] = []; let i = 1; if (data.nombre !== undefined) { fields.push(`nombre = $${i++}`); values.push(data.nombre); } if (data.descripcion !== undefined) { fields.push(`descripcion = $${i++}`); values.push(data.descripcion); } if (data.recurrencia !== undefined) { fields.push(`recurrencia = $${i++}`); values.push(data.recurrencia); } if (data.diaSemana !== undefined) { fields.push(`dia_semana = $${i++}`); values.push(data.diaSemana); } if (data.diaMes !== undefined) { fields.push(`dia_mes = $${i++}`); values.push(data.diaMes); } if (data.soloSupervisorCompleta !== undefined) { fields.push(`solo_supervisor_completa = $${i++}`); values.push(data.soloSupervisorCompleta); } if (data.orden !== undefined) { fields.push(`orden = $${i++}`); values.push(data.orden); } if (fields.length === 0) return null; values.push(sanitizeUuid(tareaId)); const { rows: [r] } = await pool.query( `UPDATE tareas_catalogo SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`, values, ); return r ? ROW_TO_TAREA(r) : null; } export async function deleteTarea(pool: Pool, tareaId: string): Promise { const { rowCount } = await pool.query( `UPDATE tareas_catalogo SET active = false WHERE id = $1`, [sanitizeUuid(tareaId)], ); return (rowCount ?? 0) > 0; } // ─── Materialización de periodos ─── /** Convierte una fecha a su `periodo` según recurrencia. */ function periodoForDate(date: Date, recurrencia: Recurrencia): string { const año = date.getFullYear(); const mes = date.getMonth() + 1; if (recurrencia === 'semanal' || recurrencia === 'quincenal') { return `${año}-W${String(isoWeek(date)).padStart(2, '0')}`; } if (recurrencia === 'mensual') return `${año}-${String(mes).padStart(2, '0')}`; if (recurrencia === 'bimestral') return `${año}-B${Math.ceil(mes / 2)}`; if (recurrencia === 'trimestral') return `${año}-Q${Math.ceil(mes / 3)}`; if (recurrencia === 'semestral') return `${año}-S${Math.ceil(mes / 6)}`; return `${año}`; } function isoWeek(date: Date): number { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); } /** * Calcula la fecha límite del próximo periodo de una tarea, basado en * `recurrencia`, `dia_semana`/`dia_mes` y un punto de referencia. * * Para semanal/quincenal: primera ocurrencia del dia_semana en/después de * `fromDate`. (Quincenal alterna con el mismo dia_semana cada 14 días.) * Para mensual+: dia_mes del periodo en curso de `fromDate`. Si el dia_mes * excede el último día del mes, se clampa al último día. */ function computeFechaLimite( recurrencia: Recurrencia, diaSemana: number | null, diaMes: number | null, fromDate: Date, ): Date { if (recurrencia === 'semanal' || recurrencia === 'quincenal') { const target = (diaSemana ?? 5); const d = new Date(fromDate); const current = d.getDay() === 0 ? 7 : d.getDay(); const diff = (target - current + 7) % 7; d.setDate(d.getDate() + diff); return d; } // mensual a anual: usar el mes "ancla" del periodo const año = fromDate.getFullYear(); let mesAncla = fromDate.getMonth() + 1; if (recurrencia === 'bimestral') mesAncla = Math.ceil(mesAncla / 2) * 2; else if (recurrencia === 'trimestral') mesAncla = Math.ceil(mesAncla / 3) * 3; else if (recurrencia === 'semestral') mesAncla = Math.ceil(mesAncla / 6) * 6; else if (recurrencia === 'anual') mesAncla = 12; const lastDay = new Date(año, mesAncla, 0).getDate(); const dia = Math.min(diaMes ?? lastDay, lastDay); return new Date(año, mesAncla - 1, dia); } /** * Asegura que existan los periodos vigentes (presente + futuros próximos) * para todas las tareas activas del contribuyente. Solo crea periodos * cuya `fecha_limite >= today` (no retroactivos, igual que obligaciones). * * Para cada tarea genera el periodo CURRENT (donde cae hoy) si su fecha * límite aún no ha pasado, o el NEXT si ya pasó. */ export async function materializarPeriodos( pool: Pool, contribuyenteId: string, ): Promise { const tareas = await listTareas(pool, contribuyenteId); const today = new Date(); today.setHours(0, 0, 0, 0); for (const t of tareas) { let fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, today); // Si la fecha límite calculada para "hoy" ya pasó, salta al siguiente periodo while (fechaLimite < today) { const next = new Date(fechaLimite); const recurrenciaIncrement: Record void> = { semanal: () => next.setDate(next.getDate() + 7), quincenal: () => next.setDate(next.getDate() + 14), mensual: () => next.setMonth(next.getMonth() + 1), bimestral: () => next.setMonth(next.getMonth() + 2), trimestral: () => next.setMonth(next.getMonth() + 3), semestral: () => next.setMonth(next.getMonth() + 6), anual: () => next.setFullYear(next.getFullYear() + 1), }; recurrenciaIncrement[t.recurrencia](); fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, next); } const periodo = periodoForDate(fechaLimite, t.recurrencia); await pool.query( `INSERT INTO tarea_periodos (tarea_id, periodo, fecha_limite) VALUES ($1, $2, $3) ON CONFLICT (tarea_id, periodo) DO NOTHING`, [t.id, periodo, fechaLimite.toISOString().split('T')[0]], ); } } /** * Lee tareas activas del contribuyente con su periodo más cercano (vigente * o pendiente). Materializa los faltantes antes de leer. */ export async function listTareasConPeriodoActual( pool: Pool, contribuyenteId: string, ): Promise { await materializarPeriodos(pool, contribuyenteId); const tareas = await listTareas(pool, contribuyenteId); if (tareas.length === 0) return []; const ids = tareas.map(t => t.id); const today = new Date().toISOString().split('T')[0]; const { rows } = await pool.query( `SELECT DISTINCT ON (tarea_id) * FROM tarea_periodos WHERE tarea_id = ANY($1::uuid[]) AND (completada = false OR fecha_limite >= $2::date) ORDER BY tarea_id, fecha_limite ASC`, [ids, today], ); const periodos = new Map(rows.map(r => [r.tarea_id, ROW_TO_PERIODO(r)])); return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null })); } export interface TareaConContribuyente extends TareaConPeriodo { contribuyenteRfc: string; contribuyenteRazonSocial: string; } /** * Lee tareas activas con periodo actual para una lista de contribuyentes. * Útil para la vista "Mis Tareas". */ export async function listTareasConPeriodoPorContribuyentes( pool: Pool, contribuyenteIds: string[], ): Promise { if (contribuyenteIds.length === 0) return []; // Materializar periodos para cada contribuyente en paralelo await Promise.all(contribuyenteIds.map(id => materializarPeriodos(pool, id))); const { rows: tareasRows } = await pool.query( `SELECT tc.*, c.entidad_id AS "contribuyenteId", c.rfc AS "contribuyenteRfc", COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial" FROM tareas_catalogo tc JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true ORDER BY c.rfc, tc.orden, tc.nombre`, [contribuyenteIds], ); if (tareasRows.length === 0) return []; const tareaIds = tareasRows.map((r: any) => r.id); const today = new Date().toISOString().split('T')[0]; const { rows: periodoRows } = await pool.query( `SELECT DISTINCT ON (tarea_id) * FROM tarea_periodos WHERE tarea_id = ANY($1::uuid[]) AND (completada = false OR fecha_limite >= $2::date) ORDER BY tarea_id, fecha_limite ASC`, [tareaIds, today], ); const periodos = new Map(periodoRows.map((r: any) => [r.tarea_id, ROW_TO_PERIODO(r)])); return tareasRows.map((r: any) => ({ ...ROW_TO_TAREA(r), contribuyenteId: r.contribuyenteId, contribuyenteRfc: r.contribuyenteRfc, contribuyenteRazonSocial: r.contribuyenteRazonSocial, periodoActual: periodos.get(r.id) ?? null, })); } // ─── Completar / descompletar periodo ─── const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']); /** * Marca un periodo como completado. Si la tarea tiene * `solo_supervisor_completa=true`, valida que el rol del usuario sea * owner/cfo/supervisor. Devuelve el periodo actualizado y la tarea * (para que el caller pueda disparar notificaciones). */ export async function completarPeriodo( pool: Pool, periodoId: string, userId: string, userRole: string, notas: string | null = null, ): Promise<{ periodo: TareaPeriodo; tarea: TareaCatalogo } | null> { const { rows: [pRow] } = await pool.query( `SELECT tp.*, tc.solo_supervisor_completa FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tp.id = $1`, [sanitizeUuid(periodoId)], ); if (!pRow) return null; if (pRow.solo_supervisor_completa && !ROLES_SUPERVISOR.has(userRole)) { throw new Error('Solo supervisor o owner pueden marcar esta tarea como completada'); } const { rows: [updated] } = await pool.query( `UPDATE tarea_periodos SET completada = true, completada_at = NOW(), completada_por = $2, notas = $3 WHERE id = $1 RETURNING *`, [sanitizeUuid(periodoId), userId, notas], ); const { rows: [tareaRow] } = await pool.query( `SELECT * FROM tareas_catalogo WHERE id = $1`, [pRow.tarea_id], ); return { periodo: ROW_TO_PERIODO(updated), tarea: ROW_TO_TAREA(tareaRow) }; } export async function descompletarPeriodo( pool: Pool, periodoId: string, ): Promise { const { rowCount } = await pool.query( `UPDATE tarea_periodos SET completada = false, completada_at = NULL, completada_por = NULL WHERE id = $1`, [sanitizeUuid(periodoId)], ); return (rowCount ?? 0) > 0; } // ─── Calendario / alertas ─── export interface TareaEventoCalendario { titulo: string; descripcion: string; tipo: 'tarea'; fechaLimite: string; recurrencia: string; completado: boolean; notas: string | null; contribuyenteId: string; tareaId: string; periodoId: string; } /** * Lee tareas + sus periodos del año para mostrar en /calendario. Materializa * los faltantes antes de leer (mismo patrón que listTareasConPeriodoActual). * Si no hay contribuyenteId, no retorna nada (las tareas son siempre por * contribuyente). */ export async function getEventosTareasParaCalendario( pool: Pool, contribuyenteId: string, año: number, ): Promise { await materializarPeriodos(pool, contribuyenteId); const { rows } = await pool.query( `SELECT tp.id AS periodo_id, tp.tarea_id, tp.fecha_limite, tp.completada, tp.notas, tc.nombre, tc.descripcion, tc.recurrencia, tc.contribuyente_id FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tc.contribuyente_id = $1 AND tc.active = true AND EXTRACT(YEAR FROM tp.fecha_limite) = $2`, [sanitizeUuid(contribuyenteId), año], ); return rows.map(r => ({ titulo: r.nombre, descripcion: r.descripcion ?? '', tipo: 'tarea' as const, fechaLimite: r.fecha_limite instanceof Date ? r.fecha_limite.toISOString().split('T')[0] : String(r.fecha_limite), recurrencia: r.recurrencia, completado: r.completada, notas: r.notas, contribuyenteId: r.contribuyente_id, tareaId: r.tarea_id, periodoId: r.periodo_id, })); } /** * Cuenta tareas próximas a vencer (≤3 días) para alertas auto. Solo cuenta * los periodos pendientes del contribuyente filtrado o de todo el tenant. */ export async function contarTareasProximasVencer( pool: Pool, contribuyenteId: string | null | undefined, ): Promise<{ total: number; monto?: number }> { const safeId = contribuyenteId ? sanitizeUuid(contribuyenteId) : null; if (safeId) await materializarPeriodos(pool, safeId); const cf = safeId ? `AND tc.contribuyente_id = '${safeId}'` : ''; const { rows: [r] } = await pool.query( `SELECT COUNT(*)::int AS total FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tc.active = true AND tp.completada = false AND tp.fecha_limite >= CURRENT_DATE AND tp.fecha_limite <= CURRENT_DATE + INTERVAL '3 days' ${cf}`, ); return { total: r?.total || 0 }; } /** * Resuelve el auxiliar asignado a la cartera del contribuyente (si existe). * Busca primero en subcarteras (más específico) y luego en la top-level. * Retorna null si el contribuyente no está en ninguna cartera o ninguna * tiene auxiliar asignado. */ export async function getAuxiliarUserIdDeContribuyente( pool: Pool, contribuyenteId: string, ): Promise { const { rows } = await pool.query<{ auxiliar_user_id: string | null; parent_id: string | null }>( `SELECT c.auxiliar_user_id, c.parent_id FROM cartera_entidades ce JOIN carteras c ON c.id = ce.cartera_id WHERE ce.entidad_id = $1 ORDER BY (c.parent_id IS NOT NULL) DESC, c.created_at DESC`, [sanitizeUuid(contribuyenteId)], ); for (const row of rows) { if (row.auxiliar_user_id) return row.auxiliar_user_id; } return null; } // ─── Seed de tareas default ─── const TAREAS_DEFAULT: TareaInput[] = [ { nombre: 'Solicitar estados de cuenta', descripcion: 'Pedir al cliente los estados de cuenta bancarios del mes', recurrencia: 'mensual', diaMes: 5, orden: 1 }, { nombre: 'Realizar conciliación bancaria', descripcion: 'Conciliar los movimientos bancarios contra los CFDIs', recurrencia: 'mensual', diaMes: 10, orden: 2 }, { nombre: 'Realizar contabilización', descripcion: 'Registrar los CFDIs y movimientos en la contabilidad', recurrencia: 'mensual', diaMes: 14, orden: 3 }, { nombre: 'Revisión fiscal preliminar', descripcion: 'Revisar declaración antes de presentación (solo supervisor/owner)', recurrencia: 'mensual', diaMes: 15, soloSupervisorCompleta: true, orden: 4 }, ]; export async function seedTareasDefault( pool: Pool, contribuyenteId: string, ): Promise { const existentes = await pool.query( `SELECT 1 FROM tareas_catalogo WHERE contribuyente_id = $1 AND es_default = true LIMIT 1`, [sanitizeUuid(contribuyenteId)], ); if (existentes.rowCount && existentes.rowCount > 0) return 0; let count = 0; for (const t of TAREAS_DEFAULT) { await pool.query( `INSERT INTO tareas_catalogo (contribuyente_id, nombre, descripcion, recurrencia, dia_mes, solo_supervisor_completa, es_default, orden) VALUES ($1, $2, $3, $4, $5, $6, true, $7)`, [ sanitizeUuid(contribuyenteId), t.nombre, t.descripcion ?? null, t.recurrencia, t.diaMes ?? null, t.soloSupervisorCompleta ?? false, t.orden ?? 0, ], ); count++; } return count; }