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:
218
api-gateway/src/services/reportes.service.ts
Normal file
218
api-gateway/src/services/reportes.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user