feat: automatizaciones ronda 2 + app de dashboards /dash

- Escalado: lead asignado sin contactar >48h genera actividad URGENTE
  al coordinador (LEAD_ESCALADO_HORAS, MONDAY_COORDINADOR_USER_ID)
- Oportunidad en Aparto crea item en Apartado (fecha límite +7d);
  en Ganado crea la Venta; ambos con dedup
- GET /api/reportes/resumen: leads, funnel, unidades por desarrollo,
  cobranza y actividades en una respuesta (cache 60s)
- App dashboards (/dash): 8 KPIs + 3 gráficas Recharts, con auth
- Landing: tarjeta Tableros; docs/AUTOMATIZACIONES-MONDAY.md con las
  recetas nativas para activar en la UI
- Tests: api 158, dashboards 7, landing 5 — todo verde
This commit is contained in:
2026-08-27 09:05:09 +00:00
parent de11af985e
commit 04568a5603
34 changed files with 6279 additions and 8 deletions

View File

@@ -0,0 +1,218 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
/**
* Agregador de reportes para la app de tableros (/dash/).
* En UNA respuesta junta: leads (Monday), oportunidades (Monday), unidades
* (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
* Cache en memoria de 60 s para no martillar la API de Monday.
*/
export const CACHE_REPORTES_MS = 60 * 1000
// Etapas del pipeline de Oportunidades en orden (para el funnel del dashboard)
export const ETAPAS_FUNNEL = [
'Afluencia',
'Cotizador',
'Agendo Cita',
'Aparto',
'Ganado',
] as const
/** Etapas que ya no cuentan como oportunidad activa */
const ETAPAS_CERRADAS = ['PERDIDO', 'ABANDONO']
export interface ResumenReportes {
generadoEn: string // ISO
leads: {
total: number
nuevosHoy: number
nuevosSemana: number // últimos 7 días naturales
porEstado: Record<string, number>
porOrigen: Record<string, number>
}
oportunidades: {
total: number
activas: number
porEtapa: Record<string, number>
/** Fracción 0..1: oportunidades que llegaron a Aparto (o más allá) sobre el total */
tasaAparto: number
/** Fracción 0..1: oportunidades Ganadas sobre el total */
tasaGanado: number
}
unidades: {
total: number
disponibles: number
porDesarrollo: { desarrollo: string; total: number; porStatus: Record<string, number> }[]
}
cobranza: {
planes: { activos: number; liquidados: number; cancelados: number }
planesAlCorriente: number
planesConVencidos: number
pagosVencidos: number
/** Suma de (monto - montoPagado) de los pagos vencidos de planes activos */
montoVencido: number
}
actividadesAbiertas: number
}
/** Datos crudos que necesita el agregador (inyectables en tests) */
export interface DatosResumen {
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
unidades: Unidad[]
planes: PlanPago[] // dominio de cobranza (con pagos)
actividadesAbiertas: number
}
function contarPor(items: readonly string[]): Record<string, number> {
const out: Record<string, number> = {}
for (const item of items) {
const clave = item.trim() === '' ? '(sin dato)' : item.trim()
out[clave] = (out[clave] ?? 0) + 1
}
return out
}
/** Inicio del día local de `ahora` (para "nuevos hoy") */
function inicioDelDia(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
/**
* Construye el resumen a partir de los datos crudos. Función pura: toda la
* lógica de conteo/tasas se prueba sin red ni base de datos.
*/
export function construirResumen(datos: DatosResumen, ahora: Date): ResumenReportes {
const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const creadosEn = (desde: Date): number =>
datos.leads.filter((l) => {
const t = Date.parse(l.createdAt)
return !Number.isNaN(t) && t >= desde.getTime()
}).length
const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa))
const totalOps = datos.oportunidades.length
const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
const apartadosOMas = datos.oportunidades.filter((o) =>
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
).length
const activas = datos.oportunidades.filter(
(o) =>
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
o.etapa.trim().toUpperCase() !== 'GANADO',
).length
const desarrollos = new Map<string, { total: number; porStatus: Record<string, number> }>()
for (const u of datos.unidades) {
const clave = u.desarrollo.trim() === '' ? '(sin desarrollo)' : u.desarrollo.trim()
const entrada = desarrollos.get(clave) ?? { total: 0, porStatus: {} }
entrada.total++
const status = u.status.trim() === '' ? '(sin status)' : u.status.trim().toUpperCase()
entrada.porStatus[status] = (entrada.porStatus[status] ?? 0) + 1
desarrollos.set(clave, entrada)
}
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 }
let planesAlCorriente = 0
let planesConVencidos = 0
let pagosVencidos = 0
let montoVencido = 0
for (const plan of datos.planes) {
const status = statusPlan(plan)
if (status === 'ACTIVO') planesPorStatus.activos++
else if (status === 'LIQUIDADO') planesPorStatus.liquidados++
else planesPorStatus.cancelados++
if (status !== 'ACTIVO') continue
const vencidos = plan.pagos.filter((p) => statusPago(p, ahora) === 'VENCIDO')
if (vencidos.length === 0) planesAlCorriente++
else planesConVencidos++
pagosVencidos += vencidos.length
montoVencido += vencidos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0)
}
return {
generadoEn: ahora.toISOString(),
leads: {
total: datos.leads.length,
nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.leads.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
},
oportunidades: {
total: totalOps,
activas,
porEtapa,
tasaAparto: totalOps > 0 ? apartadosOMas / totalOps : 0,
tasaGanado: totalOps > 0 ? ganadas / totalOps : 0,
},
unidades: {
total: datos.unidades.length,
disponibles: datos.unidades.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE').length,
porDesarrollo: [...desarrollos.entries()]
.map(([desarrollo, v]) => ({ desarrollo, total: v.total, porStatus: v.porStatus }))
.sort((a, b) => a.desarrollo.localeCompare(b.desarrollo, 'es')),
},
cobranza: {
planes: planesPorStatus,
planesAlCorriente,
planesConVencidos,
pagosVencidos,
montoVencido: Math.round(montoVencido * 100) / 100,
},
actividadesAbiertas: datos.actividadesAbiertas,
}
}
// ---------- Servicio con cache ----------
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
export interface OpcionesReportes {
/** Reloj inyectable para probar el cache (default Date.now) */
now?: () => number
/** TTL del cache en ms (default 60 s) */
cacheMs?: number
}
export class ReportesService {
private readonly now: () => number
private readonly cacheMs: number
private cache: { resumen: ResumenReportes; expira: number } | null = null
constructor(
private readonly monday: MondayReportes,
private readonly cargarPlanes: CargadorPlanes,
opciones: OpcionesReportes = {},
) {
this.now = opciones.now ?? Date.now
this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS
}
/** Invalida el cache (útil tras escrituras que cambian los números) */
invalidarCache(): void {
this.cache = null
}
async resumen(): Promise<ResumenReportes> {
const ahoraMs = this.now()
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen
}
}