import type { Pool } from 'pg'; import { prisma } from '../config/database.js'; import { materializarPeriodos } from './tareas.service.js'; export interface ContribuyentesStats { totalContribuyentes: number; ultimaExtraccion: Date | null; /** % global del despacho de obligaciones+tareas del periodo seleccionado completadas vs total. */ progresoDelMes: number; /** Declaraciones cuyo `created_at` cae en el periodo seleccionado. */ declaracionesPresentadas: number; /** Subset del anterior con `pdf_pago` no nulo. */ declaracionesPagadas: number; /** Obligaciones de declaración pendientes de periodos anteriores al seleccionado. */ declaracionesAtrasadas: number; /** Tareas pendientes con fecha_limite anterior al inicio del periodo seleccionado. */ tareasAtrasadas: number; } /** * Métricas para la pestaña "Contribuyentes" del módulo Despacho (owner-only). * * Periodo: si se pasa `año`/`mes`, las métricas mensuales se calculan para * ese periodo. Default = mes en curso. * * - totalContribuyentes / ultimaExtraccion: independientes del periodo. * - progresoDelMes: % global obligaciones+tareas del periodo completadas. * - declaracionesPresentadas/pagadas: declaraciones con `created_at` en el * periodo seleccionado (presentación, no devengo). * - declaracionesAtrasadas: obligaciones-periodo NO completadas con periodo * anterior al seleccionado, donde la obligación sea de tipo declaración * (categoría contiene 'mensual'/'anual'/'declaración' — heurística laxa * ya que el catálogo no tiene un flag explícito). * - tareasAtrasadas: tareas-periodo NO completadas con fecha_limite anterior * al primer día del periodo seleccionado. */ export async function getContribuyentesStats( pool: Pool, tenantId: string, año?: number, mes?: number, ): Promise { const { rows: [{ count }] } = await pool.query<{ count: number }>( `SELECT COUNT(*)::int AS count FROM contribuyentes c JOIN entidades_gestionadas e ON e.id = c.entidad_id WHERE e.active = true`, ); const last = await prisma.satSyncJob.findFirst({ where: { tenantId, status: 'completed' }, orderBy: { completedAt: 'desc' }, select: { completedAt: true }, }); // Periodo: usa el filtrado o cae al mes en curso. const now = new Date(); const _año = año ?? now.getFullYear(); const _mes = mes ?? now.getMonth() + 1; const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`; const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; const { rows: [progresoRow] } = await pool.query<{ total: number; completadas: number }>( `SELECT (SELECT COUNT(*)::int FROM obligacion_periodos op JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id WHERE oc.activa = true AND op.periodo = $1) + (SELECT COUNT(*)::int FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tc.active = true AND tp.fecha_limite BETWEEN $2::date AND $3::date) AS total, (SELECT COUNT(*)::int FROM obligacion_periodos op JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id WHERE oc.activa = true AND op.periodo = $1 AND op.completada = true) + (SELECT COUNT(*)::int FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tc.active = true AND tp.fecha_limite BETWEEN $2::date AND $3::date AND tp.completada = true) AS completadas`, [periodoMes, inicioMes, finMes], ); const progresoDelMes = progresoRow.total > 0 ? Math.round((progresoRow.completadas / progresoRow.total) * 100) : 0; const { rows: [decRow] } = await pool.query<{ presentadas: number; pagadas: number }>( `SELECT COUNT(*)::int AS presentadas, COUNT(*) FILTER (WHERE pdf_pago IS NOT NULL)::int AS pagadas FROM declaraciones_provisionales WHERE created_at >= $1::date AND created_at < ($2::date + interval '1 day')`, [inicioMes, finMes], ); // Atrasadas de periodos anteriores al seleccionado. // Para declaraciones (obligaciones) usamos `op.periodo < periodoMes`. // Heurística "es declaración": categoría contiene 'mensual', 'anual', // 'declaración' o el nombre incluye 'declaración' (case insensitive). const { rows: [atrRow] } = await pool.query<{ decl_atr: number; tar_atr: number }>( `SELECT (SELECT COUNT(*)::int FROM obligacion_periodos op JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id WHERE oc.activa = true AND op.completada = false AND op.periodo < $1 AND ( LOWER(COALESCE(oc.categoria, '')) ~ 'mensual|anual|declarac' OR LOWER(oc.nombre) LIKE '%declarac%' ) ) AS decl_atr, (SELECT COUNT(*)::int 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 < $2::date ) AS tar_atr`, [periodoMes, inicioMes], ); return { totalContribuyentes: count, ultimaExtraccion: last?.completedAt ?? null, progresoDelMes, declaracionesPresentadas: decRow.presentadas, declaracionesPagadas: decRow.pagadas, declaracionesAtrasadas: atrRow.decl_atr, tareasAtrasadas: atrRow.tar_atr, }; } export interface ContribuyenteAsignado { contribuyenteId: string; rfc: string; nombre: string; carteraNombre: string | null; obligacionesPendientes: number; obligacionesAtrasadas: number; obligacionesCompletadas: number; tareasPendientes: number; tareasAtrasadas: number; tareasCompletadas: number; } /** * Resuelve los contribuyentes asignados al usuario actual según su rol y la * estructura de carteras: * * - **owner / cfo**: TODOS los contribuyentes del despacho. * - **supervisor**: contribuyentes que están en una cartera donde * `c.supervisor_user_id = userId` o en una subcartera de tales carteras. * - **auxiliar**: contribuyentes en carteras donde `c.auxiliar_user_id = userId`. * - **otros (contador, cliente, etc.)**: vacío. * * Las métricas se calculan usando el periodo `año`/`mes` como pivote: lo * "atrasado" es lo NO completado de periodos anteriores al filtrado. */ export async function getMisAsignados( pool: Pool, userId: string, userRole: string, año?: number, mes?: number, ): Promise { let baseFilter: string; const params: unknown[] = []; if (userRole === 'owner' || userRole === 'cfo') { baseFilter = `e.active = true`; } else if (userRole === 'supervisor') { params.push(userId); baseFilter = `e.active = true AND ce.cartera_id IN ( SELECT id FROM carteras WHERE supervisor_user_id = $1 UNION SELECT id FROM carteras WHERE parent_id IN ( SELECT id FROM carteras WHERE supervisor_user_id = $1 ) )`; } else if (userRole === 'auxiliar') { params.push(userId); baseFilter = `e.active = true AND ce.cartera_id IN ( SELECT id FROM carteras WHERE auxiliar_user_id = $1 )`; } else { return []; } const { rows } = await pool.query( `SELECT DISTINCT c.entidad_id AS contribuyente_id, c.rfc, e.nombre, (SELECT cart.nombre FROM carteras cart JOIN cartera_entidades cee ON cee.cartera_id = cart.id WHERE cee.entidad_id = c.entidad_id LIMIT 1) AS cartera_nombre FROM contribuyentes c JOIN entidades_gestionadas e ON e.id = c.entidad_id LEFT JOIN cartera_entidades ce ON ce.entidad_id = c.entidad_id WHERE ${baseFilter} ORDER BY e.nombre`, params, ); // Para cada contribuyente, contar pendientes/atrasados/completados de obligaciones+tareas. // Hacemos una sola query agregada por contribuyente con CTEs. const ids = rows.map(r => r.contribuyente_id); if (ids.length === 0) return []; // Periodo pivote: usa el filtrado o cae al mes en curso. const now = new Date(); const _año = año ?? now.getFullYear(); const _mes = mes ?? now.getMonth() + 1; const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`; const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; // Materializar periodos de tareas antes de contar (evita que tareas sin // registro en tarea_periodos aparezcan como 0). await Promise.all(ids.map(id => materializarPeriodos(pool, id).catch(() => {}))); const { rows: stats } = await pool.query( `WITH obligaciones_activas AS ( SELECT id, contribuyente_id FROM obligaciones_contribuyente WHERE contribuyente_id = ANY($4::uuid[]) AND activa = true ), op_actual AS ( SELECT obligacion_id, completada FROM obligacion_periodos WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo = $1 ), op_atrasadas AS ( SELECT obligacion_id, COUNT(*) as atrasadas FROM obligacion_periodos WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo < $1 AND completada = false GROUP BY obligacion_id ), obl AS ( SELECT oa.contribuyente_id, COUNT(*) FILTER (WHERE op_a.completada IS NULL OR op_a.completada = false)::int AS pendientes, COALESCE(SUM(op_atr.atrasadas), 0)::int AS atrasadas, COUNT(*) FILTER (WHERE op_a.completada = true)::int AS completadas FROM obligaciones_activas oa LEFT JOIN op_actual op_a ON op_a.obligacion_id = oa.id LEFT JOIN op_atrasadas op_atr ON op_atr.obligacion_id = oa.id GROUP BY oa.contribuyente_id ), tareas_activas AS ( SELECT id, contribuyente_id FROM tareas_catalogo WHERE contribuyente_id = ANY($4::uuid[]) AND active = true ), tar_actual AS ( SELECT tarea_id, completada FROM tarea_periodos WHERE tarea_id IN (SELECT id FROM tareas_activas) AND fecha_limite BETWEEN $2::date AND $3::date ), tar_atrasadas AS ( SELECT tarea_id, COUNT(*) as atrasadas FROM tarea_periodos WHERE tarea_id IN (SELECT id FROM tareas_activas) AND fecha_limite < $2::date AND completada = false GROUP BY tarea_id ), tar AS ( SELECT ta.contribuyente_id, COUNT(*) FILTER (WHERE tar_a.completada IS NULL OR tar_a.completada = false)::int AS pendientes, COALESCE(SUM(tar_atr.atrasadas), 0)::int AS atrasadas, COUNT(*) FILTER (WHERE tar_a.completada = true)::int AS completadas FROM tareas_activas ta LEFT JOIN tar_actual tar_a ON tar_a.tarea_id = ta.id LEFT JOIN tar_atrasadas tar_atr ON tar_atr.tarea_id = ta.id GROUP BY ta.contribuyente_id ) SELECT obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com, tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com FROM obl FULL OUTER JOIN tar ON tar.contribuyente_id = obl.contribuyente_id`, [periodoMes, inicioMes, finMes, ids], ); const statsMap = new Map(); for (const s of stats) { const id = s.obl_id || s.tar_id; if (!id) continue; statsMap.set(id, { obl: { pen: s.obl_pen ?? 0, atr: s.obl_atr ?? 0, com: s.obl_com ?? 0 }, tar: { pen: s.tar_pen ?? 0, atr: s.tar_atr ?? 0, com: s.tar_com ?? 0 }, }); } const result = rows.map(r => { const s = statsMap.get(r.contribuyente_id); return { contribuyenteId: r.contribuyente_id, rfc: r.rfc, nombre: r.nombre, carteraNombre: r.cartera_nombre, obligacionesPendientes: s?.obl.pen ?? 0, obligacionesAtrasadas: s?.obl.atr ?? 0, obligacionesCompletadas: s?.obl.com ?? 0, tareasPendientes: s?.tar.pen ?? 0, tareasAtrasadas: s?.tar.atr ?? 0, tareasCompletadas: s?.tar.com ?? 0, }; }); // Ordena por atrasos descendente — los más rezagados arriba. result.sort((a, b) => { const atrasoA = a.obligacionesAtrasadas + a.tareasAtrasadas; const atrasoB = b.obligacionesAtrasadas + b.tareasAtrasadas; if (atrasoA !== atrasoB) return atrasoB - atrasoA; return a.nombre.localeCompare(b.nombre); }); return result; } export interface MiembroEquipo { userId: string; nombre: string; email: string; rol: 'supervisor' | 'auxiliar'; contribuyentes: number; obligacionesAtrasadas: number; tareasAtrasadas: number; totalPendientes: number; /** completadas + pendientes del periodo filtrado (sin atrasos). */ totalPeriodo: number; completadasPeriodo: number; avancePct: number | null; } export interface SupervisorConAuxiliares extends MiembroEquipo { auxiliares: MiembroEquipo[]; } export interface EquipoStatsResponse { supervisores: SupervisorConAuxiliares[]; /** Auxiliares activos sin entrada en `auxiliar_supervisores`. Solo owner los ve. */ huerfanos: MiembroEquipo[]; } /** * Resumen de avance por miembro del equipo, en estructura jerárquica * supervisor → auxiliares + lista de auxiliares "huérfanos" (sin supervisor). * * - **owner / cfo**: ve TODOS los supervisores + auxiliares sin supervisor. * - **supervisor**: ve solo a sí mismo con sus auxiliares (sin huérfanos). * * Métricas calculadas por periodo (`año`/`mes` o mes en curso). */ export async function getEquipoStats( pool: Pool, userId: string, userRole: string, tenantId: string, año?: number, mes?: number, ): Promise { // 1. Construir mapa supervisor → auxiliares. // // La relación se infiere desde `carteras`: // - Si una cartera tiene `auxiliar_user_id` Y `supervisor_user_id` no nulos, // ese par directo cuenta. // - Si una subcartera (parent_id no nulo) tiene `auxiliar_user_id`, su // supervisor es el `supervisor_user_id` del parent. // // Fallback: tabla legacy `auxiliar_supervisores`. La unión con DISTINCT // evita duplicados si un auxiliar aparece en ambas fuentes. const { rows: paresRows } = await pool.query<{ supervisor_user_id: string; auxiliar_user_id: string }>( `SELECT DISTINCT supervisor_user_id, auxiliar_user_id FROM ( SELECT c.supervisor_user_id, c.auxiliar_user_id FROM carteras c WHERE c.auxiliar_user_id IS NOT NULL AND c.supervisor_user_id IS NOT NULL UNION SELECT p.supervisor_user_id, sub.auxiliar_user_id FROM carteras sub JOIN carteras p ON p.id = sub.parent_id WHERE sub.auxiliar_user_id IS NOT NULL AND p.supervisor_user_id IS NOT NULL UNION SELECT supervisor_user_id, auxiliar_user_id FROM auxiliar_supervisores ) t WHERE supervisor_user_id IS NOT NULL AND auxiliar_user_id IS NOT NULL`, ); let pares = paresRows.map(r => ({ supervisorId: r.supervisor_user_id, auxiliarId: r.auxiliar_user_id })); if (userRole === 'supervisor') { pares = pares.filter(p => p.supervisorId === userId); } else if (userRole !== 'owner' && userRole !== 'cfo') { return { supervisores: [], huerfanos: [] }; } // 2. Agrupar auxiliares por supervisor. const supervisorIds = [...new Set(pares.map(p => p.supervisorId))]; const auxiliaresPorSup = new Map(); for (const p of pares) { if (!auxiliaresPorSup.has(p.supervisorId)) auxiliaresPorSup.set(p.supervisorId, []); auxiliaresPorSup.get(p.supervisorId)!.push(p.auxiliarId); } // 3. Para cada user (supervisor o auxiliar), calcular su miembro. const result: SupervisorConAuxiliares[] = []; for (const supId of supervisorIds) { const supMiembro = await calcularMiembro(pool, supId, 'supervisor', año, mes); if (!supMiembro) continue; const auxiliares: MiembroEquipo[] = []; for (const auxId of auxiliaresPorSup.get(supId) ?? []) { const auxMiembro = await calcularMiembro(pool, auxId, 'auxiliar', año, mes); if (auxMiembro) auxiliares.push(auxMiembro); } auxiliares.sort((a, b) => b.totalPendientes - a.totalPendientes); result.push({ ...supMiembro, auxiliares }); } result.sort((a, b) => b.totalPendientes - a.totalPendientes); // 4. Auxiliares "huérfanos" (sin entrada en auxiliar_supervisores). Solo // el owner los ve para que pueda asignarles supervisor. let huerfanos: MiembroEquipo[] = []; if (userRole === 'owner' || userRole === 'cfo') { const auxiliaresMapeados = new Set(pares.map(p => p.auxiliarId)); const auxiliaresActivos = await prisma.tenantMembership.findMany({ where: { tenantId, active: true, rol: { nombre: 'auxiliar' } }, include: { user: { select: { id: true, active: true } } }, }); const huerfanosIds = auxiliaresActivos .filter(m => m.user.active && !auxiliaresMapeados.has(m.userId)) .map(m => m.userId); for (const auxId of huerfanosIds) { const aux = await calcularMiembro(pool, auxId, 'auxiliar', año, mes); if (aux) huerfanos.push(aux); } huerfanos.sort((a, b) => b.totalPendientes - a.totalPendientes); } return { supervisores: result, huerfanos }; } async function calcularMiembro( pool: Pool, uId: string, rol: 'supervisor' | 'auxiliar', año?: number, mes?: number, ): Promise { const userInfo = await prisma.user.findUnique({ where: { id: uId }, select: { nombre: true, email: true, active: true }, }); if (!userInfo || !userInfo.active) return null; const filter = rol === 'supervisor' ? `ce.cartera_id IN (SELECT id FROM carteras WHERE supervisor_user_id = $1 UNION SELECT id FROM carteras WHERE parent_id IN (SELECT id FROM carteras WHERE supervisor_user_id = $1))` : `ce.cartera_id IN (SELECT id FROM carteras WHERE auxiliar_user_id = $1)`; const { rows: contribRows } = await pool.query<{ entidad_id: string }>( `SELECT DISTINCT ce.entidad_id FROM cartera_entidades ce WHERE ${filter}`, [uId], ); const contribIds = contribRows.map(r => r.entidad_id); // Periodo pivote const now = new Date(); const _año = año ?? now.getFullYear(); const _mes = mes ?? now.getMonth() + 1; const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`; const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; let obl = 0, tar = 0, total = 0, completadas = 0; if (contribIds.length > 0) { const { rows: [agg] } = await pool.query<{ obl_atr: number; tar_atr: number; obl_pen: number; obl_com: number; tar_pen: number; tar_com: number; }>( `SELECT (SELECT COUNT(*)::int FROM obligacion_periodos op JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id WHERE oc.contribuyente_id = ANY($1::uuid[]) AND oc.activa = true AND op.completada = false AND op.periodo < $2) AS obl_atr, (SELECT COUNT(*)::int FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true AND tp.completada = false AND tp.fecha_limite < $3::date) AS tar_atr, (SELECT COUNT(*)::int FROM obligacion_periodos op JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id WHERE oc.contribuyente_id = ANY($1::uuid[]) AND oc.activa = true AND op.periodo = $2 AND op.completada = false) AS obl_pen, (SELECT COUNT(*)::int FROM obligacion_periodos op JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id WHERE oc.contribuyente_id = ANY($1::uuid[]) AND oc.activa = true AND op.periodo = $2 AND op.completada = true) AS obl_com, (SELECT COUNT(*)::int FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true AND tp.fecha_limite BETWEEN $3::date AND $4::date AND tp.completada = false) AS tar_pen, (SELECT COUNT(*)::int FROM tarea_periodos tp JOIN tareas_catalogo tc ON tc.id = tp.tarea_id WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true AND tp.fecha_limite BETWEEN $3::date AND $4::date AND tp.completada = true) AS tar_com`, [contribIds, periodoMes, inicioMes, finMes], ); obl = agg.obl_atr; tar = agg.tar_atr; completadas = agg.obl_com + agg.tar_com; total = completadas + agg.obl_pen + agg.tar_pen; } return { userId: uId, nombre: userInfo.nombre, email: userInfo.email, rol, contribuyentes: contribIds.length, obligacionesAtrasadas: obl, tareasAtrasadas: tar, totalPendientes: obl + tar, totalPeriodo: total, completadasPeriodo: completadas, avancePct: total > 0 ? Math.round((completadas / total) * 100) : null, }; }