Files
HoruxDespachosNuevo/apps/api/src/services/despacho-stats.service.ts
Horux Dev 441ec20059 fix(despacho-stats): contar obligaciones y tareas pendientes correctamente
- Obligaciones: las obligaciones activas sin registro en obligacion_periodos
  para el periodo actual ahora se cuentan como pendientes (antes daban 0)
- Tareas: se materializan los periodos antes de contar para que las tareas
  sin registro previo aparezcan como pendientes
- Usa CTEs separadas para obligaciones y tareas evitando producto cartesiano
2026-05-25 23:40:17 +00:00

521 lines
21 KiB
TypeScript

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<ContribuyentesStats> {
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<ContribuyenteAsignado[]> {
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<string, {
obl: { pen: number; atr: number; com: number };
tar: { pen: number; atr: number; com: number };
}>();
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<EquipoStatsResponse> {
// 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<string, string[]>();
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<MiembroEquipo | null> {
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,
};
}