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:
@@ -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 * * *'),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
api-gateway/src/controllers/reportes.controller.ts
Normal file
12
api-gateway/src/controllers/reportes.controller.ts
Normal 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())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
238
api-gateway/src/services/reportes.service.test.ts
Normal file
238
api-gateway/src/services/reportes.service.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
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