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

@@ -41,6 +41,10 @@ export const config = {
// IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace
rrUserIds: env('MONDAY_RR_USER_IDS', ''),
inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')),
// Horas que un lead asignado puede seguir en NUEVO/Intento de contacto antes de escalar
escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')),
// User ID de Monday del coordinador que recibe los escalados; vacío = al vendedor asignado
coordinadorUserId: Number(env('MONDAY_COORDINADOR_USER_ID', '0')),
pollCron: env('POLL_CRON', '*/5 * * * *'),
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
},

View File

@@ -36,7 +36,14 @@ type PlanCompleto = PlanPago & {
notas: NotaPlan[]
}
function aDominio(plan: PlanCompleto): PlanPagoDominio {
/** Todos los planes en forma de dominio (lo que necesita el agregador de reportes) */
export async function cargarPlanesDominio(): Promise<PlanPagoDominio[]> {
const planes = await prisma.planPago.findMany({ include: includeCompleto })
return planes.map(aDominio)
}
// Exportado también para reportes.controller (agregador de cobranza)
export function aDominio(plan: PlanCompleto): PlanPagoDominio {
return {
id: plan.id,
ventaRef: plan.ventaRef,

View File

@@ -0,0 +1,12 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { ReportesService } from '../services/reportes.service.js'
import { cargarPlanesDominio } from './planes.controller.js'
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
res.json(await reportesService.resumen())
}

View File

@@ -8,7 +8,9 @@ const app = crearApp()
// Crons de respaldo (el webhook público aún no existe: no hay SSL ni DNS).
// Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false.
if (config.automatizaciones.enabled) {
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada,
// escala leads sin contactar y crea Apartados/Ventas al moverse Oportunidades.
// Cada regla tiene su propio catch: un fallo de Monday no rompe las demás.
cron.schedule(config.automatizaciones.pollCron, () => {
automatizacionesService
.runRoundRobin()
@@ -19,6 +21,29 @@ if (config.automatizaciones.enabled) {
}
})
.catch((err: unknown) => console.error('[cron poll] error en round-robin:', err))
automatizacionesService
.escaneoEscalado()
.then((r) => {
if (r.escalados > 0) {
// eslint-disable-next-line no-console
console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`)
}
})
.catch((err: unknown) => console.error('[cron poll] error en escalado:', err))
automatizacionesService
.escaneoOportunidades()
.then((r) => {
if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) {
// eslint-disable-next-line no-console
console.log(
`[cron poll] oportunidades: ${r.apartadosCreados.length} apartado(s), ` +
`${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`,
)
}
})
.catch((err: unknown) => console.error('[cron poll] error en oportunidades:', err))
})
// Escaneo diario de leads inactivos (recordatorio vía update en el item)

View File

@@ -39,6 +39,7 @@ import {
subirDocumento,
} from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes } from '../controllers/reportes.controller.js'
export const apiRouter = Router()
@@ -70,6 +71,8 @@ apiRouter.patch('/planes/:id/monday', ligarMondayItem)
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)
apiRouter.post('/webhooks/monday', webhookMonday)
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)

View File

@@ -1,14 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { config } from '../config/index.js'
import {
AutomatizacionesService,
claveApartadoCreado,
claveEscalado,
claveRecordatorio,
claveVentaCreada,
esEstadoTerminal,
esEtapa,
leadsInactivos,
leadsSinContactar,
parseRrUserIds,
vendedorEnTurno,
type StateStore,
} from './automatizaciones.service.js'
import type { LeadMonday, UsuarioMonday } from './monday.service.js'
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
// ---------- Fixtures ----------
@@ -22,13 +28,27 @@ function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-01T09:00:00Z',
updatedAt: '2026-08-01T10:00:00Z',
estado: 'NUEVO',
origen: '',
vendedorIds: [],
...overrides,
}
}
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
nombre: `Cliente ${id}`,
createdAt: '2026-08-01T09:00:00Z',
etapa: 'Cotizador',
unidadCodigo: 'U-101',
precioFinal: 2500000,
...overrides,
}
}
/** StateStore en memoria para tests (sin Prisma) */
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
const mapa = new Map<string, string>()
@@ -44,7 +64,7 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
}
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[]) {
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) {
return {
asignaciones: [] as { itemId: string; userId: number }[],
updates: [] as { itemId: string; body: string }[],
@@ -56,9 +76,14 @@ function mondayMock(leads: LeadMonday[]) {
estadoLabel: string
leadItemId: string
}[],
apartados: [] as Record<string, unknown>[],
ventas: [] as Record<string, unknown>[],
async getLeads() {
return leads
},
async getOportunidades() {
return oportunidades
},
async asignarVendedorALead(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
@@ -77,6 +102,14 @@ function mondayMock(leads: LeadMonday[]) {
this.actividades.push(datos)
return 'act-1'
},
async crearApartado(datos: Record<string, unknown>) {
this.apartados.push(datos)
return `apt-${this.apartados.length}`
},
async crearVenta(datos: Record<string, unknown>) {
this.ventas.push(datos)
return `vta-${this.ventas.length}`
},
async getUsuariosWorkspace() {
return VENDEDORES
},
@@ -339,3 +372,208 @@ describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
})
})
// ---------- Ronda 2: escalado de leads sin contactar ----------
describe('leadsSinContactar (regla de escalado por horas)', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
it('incluye leads asignados en NUEVO / Intento de contacto que superan el umbral', () => {
const leads = [
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
lead('2', { vendedorIds: [102], estado: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
lead('3', { vendedorIds: [103], estado: 'CONTACTADO', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactado
lead('4', { estado: 'NUEVO', updatedAt: '2026-08-01T12:00:00Z' }), // sin vendedor → no aplica
lead('5', { vendedorIds: [101], estado: 'DESCARTADO', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
]
expect(leadsSinContactar(leads, ahora, 48).map((l) => l.id)).toEqual(['1'])
})
it('un lead con exactamente N horas aún no supera el umbral', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
})
it('ignora fechas inválidas', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
})
})
describe('claves de dedup de ronda 2', () => {
it('una clave por lead/oportunidad', () => {
expect(claveEscalado('123')).toBe('escalado:123')
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
expect(claveVentaCreada('456')).toBe('venta-creada:456')
})
})
describe('esEtapa (detección de transición de etapa)', () => {
it('compara tolerando mayúsculas y espacios', () => {
expect(esEtapa('Aparto', 'APARTO')).toBe(true)
expect(esEtapa(' ganado ', 'Ganado')).toBe(true)
expect(esEtapa('Cotizador', 'Aparto')).toBe(false)
expect(esEtapa('', 'Ganado')).toBe(false)
})
})
describe('AutomatizacionesService.escaneoEscalado', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
const configAuto = config.automatizaciones as Record<string, unknown>
it('escala al vendedor asignado cuando no hay coordinador configurado', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1)
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
expect(monday.actividades[0]).toMatchObject({
nombre: 'URGENTE: lead sin contactar — Lead 1',
responsableId: 102,
estadoLabel: 'Abierto',
leadItemId: '1',
})
expect(monday.updates[0]?.body).toContain('72 h')
expect(state.mapa.get('escalado:1')).toBeDefined()
expect(state.mapa.get('contador:escalados')).toBe('1')
} finally {
configAuto['coordinadorUserId'] = anterior
}
})
it('escala al coordinador cuando MONDAY_COORDINADOR_USER_ID está configurado', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 999
try {
const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1)
expect(monday.actividades[0]?.responsableId).toBe(999)
expect(r.candidatos[0]?.responsableId).toBe(999)
} finally {
configAuto['coordinadorUserId'] = anterior
}
})
it('dedup: no vuelve a escalar un lead ya escalado', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
const r1 = await svc.escaneoEscalado({ ahora })
expect(r1.escalados).toBe(1)
const r2 = await svc.escaneoEscalado({ ahora: new Date('2026-08-23T12:00:00Z') })
expect(r2.escalados).toBe(0)
expect(r2.candidatos[0]?.yaEscalado).toBe(true)
expect(monday.actividades).toHaveLength(1)
expect(state.mapa.get('contador:escalados')).toBe('1')
} finally {
configAuto['coordinadorUserId'] = anterior
}
})
it('un fallo de Monday en un lead no detiene el escalado de los demás', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const monday = mondayMock([
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
lead('2', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
])
vi.spyOn(monday, 'crearUpdateEnItem')
.mockRejectedValueOnce(new Error('Monday caído'))
.mockResolvedValue('upd-ok')
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1) // el segundo lead sí se escaló
expect(r.candidatos).toHaveLength(2)
} finally {
configAuto['coordinadorUserId'] = anterior
spyError.mockRestore()
}
})
})
// ---------- Ronda 2: Oportunidad → Aparto / Ganado ----------
describe('AutomatizacionesService.escaneoOportunidades', () => {
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.apartadosCreados).toEqual([{ oportunidadId: '10', nombre: 'Cliente 10', apartadoItemId: 'apt-1' }])
expect(monday.apartados[0]).toMatchObject({
clienteNombre: 'Cliente 10',
unidadCodigo: 'U-101',
estadoLabel: 'Pago Apartado',
})
expect(state.mapa.get('apartado-creado:10')).toBe('apt-1')
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
})
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.ventasCreadas).toEqual([{ oportunidadId: '11', nombre: 'Cliente 11', ventaItemId: 'vta-1' }])
expect(monday.ventas[0]).toMatchObject({ clienteNombre: 'Cliente 11', unidadCodigo: 'U-101', valorVenta: 2500000 })
expect(state.mapa.get('venta-creada:11')).toBe('vta-1')
})
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
await svc.escaneoOportunidades()
const r2 = await svc.escaneoOportunidades()
expect(r2.apartadosCreados).toEqual([])
expect(r2.ventasCreadas).toEqual([])
expect(monday.apartados).toHaveLength(1)
expect(monday.ventas).toHaveLength(1)
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
expect(state.mapa.get('contador:ventasCreadas')).toBe('1')
})
it('ignora oportunidades en otras etapas', async () => {
const monday = mondayMock([], [
oportunidad('10', { etapa: 'Cotizador' }),
oportunidad('11', { etapa: 'Perdido' }),
oportunidad('12', { etapa: 'Afluencia' }),
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.escaneoOportunidades()
expect(r.apartadosCreados).toEqual([])
expect(r.ventasCreadas).toEqual([])
expect(monday.apartados).toEqual([])
expect(monday.ventas).toEqual([])
})
it('un fallo de Monday en una oportunidad no detiene a las demás', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.errores).toHaveLength(1)
expect(r.errores[0]?.oportunidadId).toBe('10')
expect(r.ventasCreadas).toHaveLength(1) // la segunda oportunidad sí se procesó
expect(state.mapa.get('apartado-creado:10')).toBeUndefined() // sin dedup: se reintenta en la próxima pasada
})
})

View File

@@ -18,12 +18,26 @@ import { prisma } from './prisma.js'
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
// Estados en los que un lead asignado aún no ha sido contactado de verdad;
// si permanece así más de LEAD_ESCALADO_HORAS horas se escala al coordinador.
export const ESTADOS_SIN_CONTACTAR = ['NUEVO', 'INTENTO DE CONTACTO'] as const
// Etapas del tablero Oportunidades que disparan las reglas de ronda 2
export const ETAPA_APARTO = 'APARTO'
export const ETAPA_GANADO = 'GANADO'
const CLAVE_RR_INDICE = 'rr:indice'
const CLAVE_RR_VENDEDORES = 'rr:vendedores'
const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada'
const CLAVE_INACTIVIDAD_ULTIMO_ESCANEO = 'inactividad:ultimoEscaneo'
const CLAVE_ESCALADO_ULTIMO_ESCANEO = 'escalado:ultimoEscaneo'
const CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO = 'oportunidades:ultimoEscaneo'
const CLAVE_CONTADOR_ESCALADOS = 'contador:escalados'
const CLAVE_CONTADOR_APARTADOS = 'contador:apartadosCreados'
const CLAVE_CONTADOR_VENTAS = 'contador:ventasCreadas'
const DIA_MS = 24 * 60 * 60 * 1000
const HORA_MS = 60 * 60 * 1000
/** Store clave-valor inyectable (Prisma en prod, Map en tests) */
export interface StateStore {
@@ -82,11 +96,55 @@ export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS)
}
/**
* Leads ASIGNADOS (con vendedor) que siguen en estado NUEVO / Intento de contacto
* y llevan más de `horasUmbral` horas sin actualizarse: candidatos a escalar.
* (El round-robin actualiza el item al asignar, así que el reloj arranca ahí.)
*/
export function leadsSinContactar(leads: readonly LeadMonday[], ahora: Date, horasUmbral: number): LeadMonday[] {
const limiteMs = horasUmbral * HORA_MS
return leads.filter((lead) => {
if (lead.vendedorIds.length === 0) return false
if (esEstadoTerminal(lead.estado)) return false
if (!ESTADOS_SIN_CONTACTAR.some((e) => e === lead.estado.trim().toUpperCase())) return false
const t = Date.parse(lead.updatedAt)
if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs
})
}
/** Clave de dedup del escalado: un escalado por lead (una sola vez) */
export function claveEscalado(itemId: string): string {
return `escalado:${itemId}`
}
/** Dedup: un item en Apartado por oportunidad que entra a 'Aparto' */
export function claveApartadoCreado(oportunidadId: string): string {
return `apartado-creado:${oportunidadId}`
}
/** Dedup: un item en Ventas por oportunidad que entra a 'Ganado' */
export function claveVentaCreada(oportunidadId: string): string {
return `venta-creada:${oportunidadId}`
}
/** Comparación de etapa tolerante a mayúsculas y espacios ('Aparto' === 'APARTO') */
export function esEtapa(etapa: string, esperada: string): boolean {
return etapa.trim().toUpperCase() === esperada.trim().toUpperCase()
}
// ---------- Servicio ----------
type MondayLeads = Pick<
MondayService,
'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace' | 'crearActividad'
| 'getLeads'
| 'getOportunidades'
| 'asignarVendedorALead'
| 'crearUpdateEnItem'
| 'getUsuariosWorkspace'
| 'crearActividad'
| 'crearApartado'
| 'crearVenta'
>
export interface OpcionesAutomatizaciones {
@@ -122,6 +180,27 @@ export interface ResultadoInactividad {
recordatoriosEnviados: number
}
export interface CandidatoEscalado {
itemId: string
lead: string
estado: string
horasSinContactar: number
responsableId: number | null // coordinador o vendedor asignado; null si no hay a quién asignar
yaEscalado: boolean
}
export interface ResultadoEscalado {
horasUmbral: number
candidatos: CandidatoEscalado[]
escalados: number
}
export interface ResultadoOportunidades {
apartadosCreados: { oportunidadId: string; nombre: string; apartadoItemId: string }[]
ventasCreadas: { oportunidadId: string; nombre: string; ventaItemId: string }[]
errores: { oportunidadId: string; mensaje: string }[]
}
export class AutomatizacionesService {
private readonly actividadesEnabled: boolean
@@ -274,6 +353,127 @@ export class AutomatizacionesService {
return { preview: opciones.preview, diasUmbral, candidatos, recordatoriosEnviados }
}
/** Suma `n` a un contador acumulado en el estado (para el endpoint admin) */
private async incrementarContador(clave: string, n: number): Promise<void> {
if (n === 0) return
const actual = Number((await this.state.get(clave)) ?? '0')
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
}
/** Responsable del escalado: coordinador (env) o, si no hay, el vendedor asignado */
private responsableEscalado(lead: LeadMonday): number | null {
const coordinador = config.automatizaciones.coordinadorUserId
if (Number.isInteger(coordinador) && coordinador > 0) return coordinador
return lead.vendedorIds[0] ?? null
}
/**
* Escalado de leads sin contactar: un lead asignado que sigue en
* NUEVO / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
* actividad URGENTE al coordinador (o al vendedor) + update en el item.
* Un escalado por lead (dedup `escalado:<itemId>`); un fallo de Monday en
* un lead no detiene a los demás.
*/
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
const ahora = opciones.ahora ?? new Date()
const horasUmbral = config.automatizaciones.escaladoHoras
const leads = await this.monday.getLeads()
const pendientes = leadsSinContactar(leads, ahora, horasUmbral)
const candidatos: CandidatoEscalado[] = []
let escalados = 0
for (const lead of pendientes) {
const responsableId = this.responsableEscalado(lead)
const horas = Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / HORA_MS)
const yaEscalado = (await this.state.get(claveEscalado(lead.id))) !== null
candidatos.push({
itemId: lead.id,
lead: lead.nombre,
estado: lead.estado,
horasSinContactar: horas,
responsableId,
yaEscalado,
})
if (yaEscalado) continue
try {
if (this.actividadesEnabled && responsableId !== null) {
await this.monday.crearActividad({
nombre: `URGENTE: lead sin contactar — ${lead.nombre}`,
tipoLabel: 'Llamada',
responsableId,
fecha: AutomatizacionesService.fechaHoyMas(0),
estadoLabel: 'Abierto',
leadItemId: lead.id,
})
}
await this.monday.crearUpdateEnItem(
lead.id,
`🚨 Escalado automático: el lead lleva ${horas} h asignado en estado "${lead.estado}" sin contacto. ` +
`Se notificó a ${responsableId !== null && responsableId !== lead.vendedorIds[0] ? 'la coordinación' : 'el vendedor asignado'}.`,
)
await this.state.set(claveEscalado(lead.id), ahora.toISOString())
escalados++
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[escalado] falló el escalado del lead ${lead.id} (${lead.nombre}):`, err)
}
}
await this.incrementarContador(CLAVE_CONTADOR_ESCALADOS, escalados)
await this.state.set(CLAVE_ESCALADO_ULTIMO_ESCANEO, ahora.toISOString())
return { horasUmbral, candidatos, escalados }
}
/**
* Oportunidades → Apartado / Ventas (polling):
* - etapa 'Aparto' sin item en Apartado → crea el apartado (estado inicial
* 'Pago Apartado', vigencia de 7 días). Dedup `apartado-creado:<id>`.
* - etapa 'Ganado' sin item en Ventas → crea la venta con unidad y valor.
* Dedup `venta-creada:<id>`.
* Un fallo de Monday en una oportunidad no detiene a las demás.
*/
async escaneoOportunidades(): Promise<ResultadoOportunidades> {
const oportunidades = await this.monday.getOportunidades()
const resultado: ResultadoOportunidades = { apartadosCreados: [], ventasCreadas: [], errores: [] }
for (const op of oportunidades) {
if (esEtapa(op.etapa, ETAPA_APARTO) && (await this.state.get(claveApartadoCreado(op.id))) === null) {
try {
const apartadoItemId = await this.monday.crearApartado({
clienteNombre: op.nombre,
unidadCodigo: op.unidadCodigo,
fechaApartado: AutomatizacionesService.fechaHoyMas(0),
fechaLimite: AutomatizacionesService.fechaHoyMas(7),
estadoLabel: 'Pago Apartado',
})
await this.state.set(claveApartadoCreado(op.id), apartadoItemId)
resultado.apartadosCreados.push({ oportunidadId: op.id, nombre: op.nombre, apartadoItemId })
} catch (err) {
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
}
continue // una oportunidad no puede estar en dos etapas a la vez
}
if (esEtapa(op.etapa, ETAPA_GANADO) && (await this.state.get(claveVentaCreada(op.id))) === null) {
try {
const ventaItemId = await this.monday.crearVenta({
clienteNombre: op.nombre,
...(op.unidadCodigo !== '' ? { unidadCodigo: op.unidadCodigo } : {}),
...(op.precioFinal > 0 ? { valorVenta: op.precioFinal } : {}),
})
await this.state.set(claveVentaCreada(op.id), ventaItemId)
resultado.ventasCreadas.push({ oportunidadId: op.id, nombre: op.nombre, ventaItemId })
} catch (err) {
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
}
}
}
await this.incrementarContador(CLAVE_CONTADOR_APARTADOS, resultado.apartadosCreados.length)
await this.incrementarContador(CLAVE_CONTADOR_VENTAS, resultado.ventasCreadas.length)
await this.state.set(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO, new Date().toISOString())
return resultado
}
/** Estado actual para el endpoint admin */
async estado(): Promise<Record<string, unknown>> {
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
@@ -292,6 +492,17 @@ export class AutomatizacionesService {
diasUmbral: config.automatizaciones.inactividadDias,
ultimoEscaneo: await this.state.get(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO),
},
escalado: {
horasUmbral: config.automatizaciones.escaladoHoras,
coordinadorUserId: config.automatizaciones.coordinadorUserId > 0 ? config.automatizaciones.coordinadorUserId : null,
totalEscalados: Number((await this.state.get(CLAVE_CONTADOR_ESCALADOS)) ?? '0'),
ultimoEscaneo: await this.state.get(CLAVE_ESCALADO_ULTIMO_ESCANEO),
},
oportunidades: {
apartadosCreados: Number((await this.state.get(CLAVE_CONTADOR_APARTADOS)) ?? '0'),
ventasCreadas: Number((await this.state.get(CLAVE_CONTADOR_VENTAS)) ?? '0'),
ultimoEscaneo: await this.state.get(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO),
},
crons: {
habilitados: config.automatizaciones.enabled,
pollCron: config.automatizaciones.pollCron,

View File

@@ -32,11 +32,23 @@ interface ItemConFechas extends ItemMonday {
export interface LeadMonday {
id: string
nombre: string
createdAt: string // ISO (created_at del item en Monday)
updatedAt: string // ISO (updated_at del item en Monday)
estado: string // label de la columna status 'Estado' ('' si no tiene)
origen: string // label de la columna status 'Origen' ('' si no tiene)
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
}
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
export interface OportunidadMonday {
id: string
nombre: string // nombre del cliente
createdAt: string // ISO
etapa: string // label de la columna status 'Estado'
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
export interface UsuarioMonday {
id: number
@@ -521,16 +533,131 @@ export class MondayService {
vendedorIds = []
}
}
const colOrigen = cols.get('Origen')
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
vendedorIds,
}
})
}
/**
* Items del tablero Oportunidades con etapa, unidad y precio final
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
*/
async getOportunidades(): Promise<OportunidadMonday[]> {
const boardId = config.monday.boards.oportunidades
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
const colUnidad = cols.get('Unidad')
const colPrecio = cols.get('Precio final')
const items: ItemConFechas[] = []
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 200) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 200, cursor: $cursor) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
items.push(...page.items)
cursor = page.cursor
} while (cursor !== null)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
etapa: porId.get(colEstado.id)?.text ?? '',
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
precioFinal: Number.isFinite(precio) ? precio : 0,
}
})
}
/** Item en el tablero Ventas (se crea cuando una oportunidad pasa a 'Ganado') */
async crearVenta(datos: DatosVenta): Promise<string> {
const boardId = config.monday.boards.ventas
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Valor venta': datos.valorVenta,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
/**
* Número de actividades con Estado 'Abierto' en el tablero Actividades.
* Si el tablero no está configurado devuelve 0 (los reportes no deben caer por eso).
*/
async getActividadesAbiertas(): Promise<number> {
const boardId = config.monday.boards.actividades
if (boardId === '') return 0
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) return 0
let abiertas = 0
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 500) { cursor items { id column_values { id text } } }
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 500, cursor: $cursor) { cursor items { id column_values { id text } } }
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
for (const item of page.items) {
const estado = item.column_values.find((cv) => cv.id === colEstado.id)?.text ?? ''
if (estado.trim().toUpperCase() === 'ABIERTO') abiertas++
}
cursor = page.cursor
} while (cursor !== null)
return abiertas
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads
@@ -615,6 +742,12 @@ export interface DatosApartado {
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
}
export interface DatosVenta {
clienteNombre: string
unidadCodigo?: string
valorVenta?: number
}
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA

View File

@@ -0,0 +1,238 @@
import { describe, expect, it, vi } from 'vitest'
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ----------
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z',
estado: 'NUEVO',
origen: 'Facebook',
vendedorIds: [],
...overrides,
}
}
function op(id: string, etapa: string): OportunidadMonday {
return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101', precioFinal: 0 }
}
function unidad(codigo: string, desarrollo: string, status: string): Unidad {
return {
codigo,
modelo: 'M1',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: 0,
precioConAcabados: 0,
status,
desarrollo,
mesesParaFirma: null,
}
}
function plan(id: string, overrides: Partial<PlanPago> = {}): PlanPago {
return {
id,
clienteNombre: `Cliente ${id}`,
unidadCodigo: 'U-101',
proyecto: 'ATELIER',
fechaInicio: '2026-07-01',
engancheTotal: 100000,
numPagos: 2,
mensualidad: 50000,
status: 'ACTIVO',
pagos: [],
notas: [],
createdAt: '2026-07-01T00:00:00.000Z',
...overrides,
}
}
function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] {
return { id, numPago: 1, fechaVencimiento, monto, montoPagado, aplicaciones: [] }
}
const AHORA = new Date('2026-08-22T12:00:00') // mediodía local
const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
// ---------- construirResumen (puro) ----------
describe('construirResumen', () => {
it('cuenta leads por estado y por origen', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { estado: 'NUEVO', origen: 'Facebook' }),
lead('2', { estado: 'NUEVO', origen: 'Facebook' }),
lead('3', { estado: 'CONTACTADO', origen: 'Referido' }),
lead('4', { estado: '', origen: '' }),
],
},
AHORA,
)
expect(r.leads.total).toBe(4)
expect(r.leads.porEstado).toEqual({ NUEVO: 2, CONTACTADO: 1, '(sin dato)': 1 })
expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 })
})
it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
],
},
AHORA,
)
expect(r.leads.nuevosHoy).toBe(1)
expect(r.leads.nuevosSemana).toBe(2)
})
it('agrega oportunidades por etapa y calcula tasas de Aparto y Ganado', () => {
const r = construirResumen(
{
...DATOS_BASE,
oportunidades: [
op('1', 'Afluencia'),
op('2', 'Cotizador'),
op('3', 'Aparto'),
op('4', 'Ganado'),
op('5', 'Perdido'),
],
},
AHORA,
)
expect(r.oportunidades.total).toBe(5)
expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1, Cotizador: 1, Aparto: 1, Ganado: 1, Perdido: 1 })
expect(r.oportunidades.activas).toBe(3) // Afluencia, Cotizador, Aparto (ni Ganado ni cerradas)
expect(r.oportunidades.tasaAparto).toBeCloseTo(2 / 5) // Aparto + Ganado
expect(r.oportunidades.tasaGanado).toBeCloseTo(1 / 5)
})
it('sin oportunidades las tasas son 0 (no divide entre cero)', () => {
const r = construirResumen(DATOS_BASE, AHORA)
expect(r.oportunidades.tasaAparto).toBe(0)
expect(r.oportunidades.tasaGanado).toBe(0)
})
it('agrega unidades por desarrollo × status y cuenta disponibles', () => {
const r = construirResumen(
{
...DATOS_BASE,
unidades: [
unidad('U-101', 'ATELIER', 'DISPONIBLE'),
unidad('U-102', 'ATELIER', 'VENDIDO'),
unidad('U-201', 'SOLANA', 'DISPONIBLE'),
unidad('U-202', 'SOLANA', 'apartado'), // status se normaliza a mayúsculas
],
},
AHORA,
)
expect(r.unidades.total).toBe(4)
expect(r.unidades.disponibles).toBe(2)
expect(r.unidades.porDesarrollo).toEqual([
{ desarrollo: 'ATELIER', total: 2, porStatus: { DISPONIBLE: 1, VENDIDO: 1 } },
{ desarrollo: 'SOLANA', total: 2, porStatus: { APARTADO: 1, DISPONIBLE: 1 } },
])
})
it('cobra: planes por status, pagos vencidos y monto en mora de planes activos', () => {
const r = construirResumen(
{
...DATOS_BASE,
planes: [
plan('a', {
pagos: [
pago('p1', '2026-08-01', 50000), // vencido sin abono
pago('p2', '2026-08-10', 50000, 20000), // parcial (no cuenta como VENCIDO)
pago('p3', '2026-09-01', 50000), // pendiente futuro
],
}),
plan('b', { pagos: [pago('p4', '2026-07-15', 30000)] }), // otro vencido
plan('c', { status: 'CANCELADO', pagos: [pago('p5', '2026-07-01', 99999)] }), // cancelado: no cuenta
plan('d', { pagos: [pago('p6', '2026-08-01', 1000, 1000)] }), // liquidado (saldo 0)
],
},
AHORA,
)
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1 })
expect(r.cobranza.pagosVencidos).toBe(2)
expect(r.cobranza.montoVencido).toBe(80000)
expect(r.cobranza.planesAlCorriente).toBe(0)
expect(r.cobranza.planesConVencidos).toBe(2)
})
})
// ---------- ReportesService (cache en memoria) ----------
describe('ReportesService', () => {
function mondayMock() {
return {
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
async getLeads(): Promise<LeadMonday[]> {
this.llamadas.leads++
return [lead('1')]
},
async getOportunidades(): Promise<OportunidadMonday[]> {
this.llamadas.oportunidades++
return [op('1', 'Aparto')]
},
async getUnidades(): Promise<Unidad[]> {
this.llamadas.unidades++
return [unidad('U-101', 'ATELIER', 'DISPONIBLE')]
},
async getActividadesAbiertas(): Promise<number> {
this.llamadas.actividades++
return 7
},
}
}
it('agrega todas las fuentes en una sola respuesta', async () => {
const monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const r = await svc.resumen()
expect(r.leads.total).toBe(1)
expect(r.oportunidades.total).toBe(1)
expect(r.unidades.total).toBe(1)
expect(r.cobranza.planes.activos).toBe(1)
expect(r.actividadesAbiertas).toBe(7)
})
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-08-22T12:00:00'))
const monday = mondayMock()
const cargarPlanes = vi.fn(async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const svc = new ReportesService(monday, cargarPlanes)
await svc.resumen()
await svc.resumen()
expect(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
// 61 s después el cache expira y se vuelve a consultar
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
await svc.resumen()
expect(monday.llamadas.leads).toBe(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})

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
}
}