import { pagoAtrasado, statusPlan, type PlanPago } from './cobranza.service.js' import type { UnidadCotizable } from './cotizacion.service.js' import { ETAPAS_PIPELINE } from './etapas.js' import { prisma } from './prisma.js' import { unidadLocalADto } from './unidades-local.service.js' /** Lead del embudo tal como lo consume el agregador (fila de LeadCaptura mapeada) */ export type LeadFila = { id: string nombre: string createdAt: string // ISO updatedAt: string // ISO estado: string origen: string telefono: string email: string vendedorIds: number[] desarrollo?: string modeloInteres?: string } /** Oportunidad/afluencia tal como la consume el agregador (fila de LeadCaptura mapeada) */ export type OportunidadFila = { id: string nombre: string createdAt: string // ISO updatedAt: string // ISO etapa: string unidadCodigo: string precioFinal: number asesorIds: number[] telefono: string email: string origen: string desarrollo?: string modeloInteres?: string medioContacto?: string edad?: number | null estadoCivil?: string tipoEmpleo?: string credito?: string tipoEconomia?: string califica?: string rangoSalarial?: string loQuierePara?: string } export type Unidad = UnidadCotizable /** * Agregador de reportes para la app de tableros (/dash/). * En UNA respuesta junta: leads (captura ligera, tabla LeadCaptura board * LEADS), afluencia/perfilamiento (board OPORTUNIDADES — el Alta Afluencia * vive ahí), oportunidades, unidades (espejo local), cobranza (Prisma local) * y actividades abiertas (ActividadLocal). * Cache en memoria de 60 s para no martillar SQLite en cada refresh. */ 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 porOrigen: Record /** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */ perfil: { porDesarrollo: Record porModelo: Record porEstadoCivil: Record porTipoEmpleo: Record porTipoEconomia: Record porCalifica: Record porCredito: Record porRangoSalarial: Record porMedioContacto: Record porLoQuierePara: Record porEdad: Record // buckets '15–24' | '25–34' | '35–44' | '45–54' | '55+' } } oportunidades: { total: number activas: number porEtapa: Record /** 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 }[] /** Conteo por etapa del pipeline de venta, en el orden oficial de las 10 etapas */ porEtapa: { etapa: string; total: 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 /** false: el resumen se calculó desde tablas locales completas */ parcial?: boolean } /** Datos crudos que necesita el agregador (inyectables en tests) */ export interface DatosResumen { // La sección `leads` (total/nuevos/porEstado/porOrigen) lee el tablero Leads // (captura ligera); el `perfil` de la afluencia lee Oportunidades (Alta // Afluencia), que es también la fuente de la sección `oportunidades` leads: LeadFila[] oportunidades: OportunidadFila[] unidades: Unidad[] planes: PlanPago[] // dominio de cobranza (con pagos) actividadesAbiertas: number } function contarPor(items: readonly string[]): Record { const out: Record = {} for (const item of items) { const clave = item.trim() === '' ? '(sin dato)' : item.trim() out[clave] = (out[clave] ?? 0) + 1 } return out } /** Igual que contarPor pero sin bucket '(sin dato)': los vacíos se saltan (perfilamiento) */ function contarPorNoVacios(items: readonly string[]): Record { const out: Record = {} for (const item of items) { const clave = item.trim() if (clave === '') continue out[clave] = (out[clave] ?? 0) + 1 } return out } /** Bucket de edad para la gráfica de perfilamiento; null (sin dato) se salta */ function bucketEdad(edad: number): string { if (edad < 25) return '15–24' if (edad < 35) return '25–34' if (edad < 45) return '35–44' if (edad < 55) return '45–54' return '55+' } /** * Convierte el rango salarial de texto libre ('$80k', '4,500 DLLS', '50-80 * MIL', '100,000.00-120,000.00') a un bucket graficable por moneda; los rangos * se promedian y K/MIL multiplica miles. null = sin monto interpretable * ('NO QUISO DECIR', '?', vacío) → se salta de la gráfica. */ export function bucketSalario(texto: string): string | null { const t = texto.toUpperCase() const tokens = [...t.matchAll(/\d[\d.,]*/g)].map((m) => m[0]) if (tokens.length === 0) return null const montos = tokens .map((tok) => { // separador seguido de 3 dígitos = miles; el resto se trata como decimal let s = tok let prev = '' while (prev !== s) { prev = s s = s.replace(/(\d),(\d{3})(?!\d)/g, '$1$2') } const n = Number.parseFloat(s.replace(',', '.')) return Number.isFinite(n) ? n : null }) .filter((n): n is number => n !== null) if (montos.length === 0) return null const enMiles = /\d\s*(K|MIL)\b/.test(t) && montos.every((n) => n < 1000) const ajustados = enMiles ? montos.map((n) => n * 1000) : montos const monto = ajustados.reduce((a, b) => a + b, 0) / ajustados.length if (/DLLS|DLS|USD/.test(t)) { if (monto < 3000) return 'Hasta $3k USD' if (monto < 5000) return '$3k–$5k USD' return 'Más de $5k USD' } if (monto < 30000) return 'Hasta $30k MXN' if (monto < 60000) return '$30k–$60k MXN' if (monto < 100000) return '$60k–$100k MXN' return 'Más de $100k MXN' } /** 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. * * `dias` (opcional, 7/30/90) acota leads y oportunidades a las capturas del * rango (por createdAt); inventario, cobranza y actividades son posición * actual y no se filtran. Sin `dias` se cuenta TODO el histórico (igual que antes). */ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number): ResumenReportes { const hoy = inicioDelDia(ahora) const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000) const rangoValido = dias !== undefined && Number.isFinite(dias) && dias > 0 const desdeRango = rangoValido ? new Date(hoy.getTime() - (Math.floor(dias!) - 1) * 24 * 60 * 60 * 1000) : null const enRango = (createdAt: string): boolean => { if (!desdeRango) return true const t = Date.parse(createdAt) return !Number.isNaN(t) && t >= desdeRango.getTime() } const leadsRango = datos.leads.filter((l) => enRango(l.createdAt)) const oportunidadesRango = datos.oportunidades.filter((o) => enRango(o.createdAt)) const creadosEn = (desde: Date): number => leadsRango.filter((l) => { const t = Date.parse(l.createdAt) return !Number.isNaN(t) && t >= desde.getTime() }).length const porEtapa = contarPor(oportunidadesRango.map((o) => o.etapa)) const totalOps = oportunidadesRango.length const ganadas = oportunidadesRango.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length const apartadosOMas = oportunidadesRango.filter((o) => ['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()), ).length const activas = oportunidadesRango.filter( (o) => !ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) && o.etapa.trim().toUpperCase() !== 'GANADO', ).length const desarrollos = new Map }>() const conteoEtapas = new Map() 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 etapa = u.etapa?.trim() if (etapa) conteoEtapas.set(etapa, (conteoEtapas.get(etapa) ?? 0) + 1) } // Etapas oficiales primero (en orden del pipeline); cualquier label desconocido va al final const porEtapaUnidades = [ ...ETAPAS_PIPELINE.map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })), ...[...conteoEtapas.keys()] .filter((e) => !(ETAPAS_PIPELINE as readonly string[]).includes(e)) .map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })), ] // `sinPlan` = plan sin calendario capturado (no es lo mismo que cancelado: // 3 planes del maestro llegaron sin pagos y antes se contaban como cancelados) const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0, sinPlan: 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 if (status === 'SIN_PLAN') planesPorStatus.sinPlan++ else planesPorStatus.cancelados++ if (status !== 'ACTIVO') continue // Un pago PARCIAL vencido también es atraso (antes no se contaba y había // planes "al corriente" con meses de retraso) const vencidos = plan.pagos.filter((p) => pagoAtrasado(p, ahora)) 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(), // Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la // afluencia se lee de Oportunidades (el Alta Afluencia vive ahí) leads: { total: leadsRango.length, nuevosHoy: creadosEn(hoy), nuevosSemana: creadosEn(hace7dias), porEstado: contarPor(leadsRango.map((l) => l.estado)), porOrigen: contarPor(leadsRango.map((l) => l.origen)), perfil: { porDesarrollo: contarPorNoVacios(oportunidadesRango.map((o) => o.desarrollo ?? '')), porModelo: contarPorNoVacios(oportunidadesRango.map((o) => o.modeloInteres ?? '')), porEstadoCivil: contarPorNoVacios(oportunidadesRango.map((o) => o.estadoCivil ?? '')), porTipoEmpleo: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEmpleo ?? '')), porTipoEconomia: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEconomia ?? '')), porCalifica: contarPorNoVacios(oportunidadesRango.map((o) => o.califica ?? '')), porCredito: contarPorNoVacios(oportunidadesRango.map((o) => o.credito ?? '')), porRangoSalarial: contarPorNoVacios( oportunidadesRango .map((o) => bucketSalario(o.rangoSalarial ?? '')) .filter((b): b is string => b !== null), ), porMedioContacto: contarPorNoVacios(oportunidadesRango.map((o) => o.medioContacto ?? '')), porLoQuierePara: contarPorNoVacios(oportunidadesRango.map((o) => o.loQuierePara ?? '')), porEdad: contarPorNoVacios( oportunidadesRango.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)), ), }, }, 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')), porEtapa: porEtapaUnidades, }, cobranza: { planes: planesPorStatus, planesAlCorriente, planesConVencidos, pagosVencidos, montoVencido: Math.round(montoVencido * 100) / 100, }, actividadesAbiertas: datos.actividadesAbiertas, } } // ---------- Servicio con cache ---------- /** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */ export type CargadorPlanes = () => Promise /** Datos del embudo/inventario leídos de tablas locales (modo degradado) */ export interface DatosLocalesReportes { leads: LeadFila[] oportunidades: OportunidadFila[] unidades: Unidad[] actividadesAbiertas?: number } /** Cargador de los datos locales para el fallback (Prisma en prod, fixture en tests) */ export type CargadorDatosLocales = () => Promise export interface OpcionesReportes { /** Reloj inyectable para probar el cache (default Date.now) */ now?: () => number /** TTL del cache en ms (default 60 s) */ cacheMs?: number /** Datos locales del embudo/inventario (default: tablas LeadCaptura/Unidad vía Prisma) */ cargarDatosLocales?: CargadorDatosLocales } /** string de un campo del datosJson de afluencia ('' si no es string) */ function campoTexto(datos: Record, campo: string): string { const v = datos[campo] return typeof v === 'string' ? v : '' } /** * Cargador local por defecto: leads/oportunidades desde LeadCaptura (board + * etapa; el perfil de la afluencia va en datosJson) e inventario desde la * tabla Unidad (espejo). */ export async function cargarDatosLocalesPrisma(): Promise { const capturas = await prisma.leadCaptura.findMany({ where: { board: { in: ['LEADS', 'OPORTUNIDADES'] } }, }) const unidadesRows = await prisma.unidad.findMany() const leads: LeadFila[] = capturas .filter((c) => c.board === 'LEADS') .map((c) => ({ id: c.leadId, nombre: c.nombreLead, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), estado: c.etapa, origen: c.origen ?? '', telefono: c.telefono ?? '', email: c.email ?? '', vendedorIds: [], })) const oportunidades: OportunidadFila[] = capturas .filter((c) => c.board === 'OPORTUNIDADES') .map((c) => { let datos: Record = {} if (c.datosJson !== null) { try { datos = JSON.parse(c.datosJson) as Record } catch { datos = {} } } const edad = datos['edad'] return { id: c.leadId, nombre: c.nombreLead, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), etapa: c.etapa, unidadCodigo: '', precioFinal: 0, asesorIds: [], telefono: c.telefono ?? '', email: c.email ?? '', origen: c.origen ?? '', desarrollo: c.desarrollo ?? '', modeloInteres: c.modelo ?? '', medioContacto: campoTexto(datos, 'medioContacto'), edad: typeof edad === 'number' && Number.isFinite(edad) ? edad : null, estadoCivil: campoTexto(datos, 'estadoCivil'), tipoEmpleo: campoTexto(datos, 'tipoEmpleo'), credito: campoTexto(datos, 'credito'), tipoEconomia: campoTexto(datos, 'tipoEconomia'), califica: campoTexto(datos, 'califica'), rangoSalarial: campoTexto(datos, 'rangoSalarial'), loQuierePara: campoTexto(datos, 'loQuierePara'), } }) // Contar actividades abiertas const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } }) return { leads, oportunidades, unidades: unidadesRows.map((u) => unidadLocalADto(u)), actividadesAbiertas } } export class ReportesService { private readonly now: () => number private readonly cacheMs: number private readonly cargarDatosLocales: CargadorDatosLocales /** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */ private cache: Map = new Map() constructor( private readonly cargarPlanes: CargadorPlanes, opciones: OpcionesReportes = {}, ) { this.now = opciones.now ?? Date.now this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS this.cargarDatosLocales = opciones.cargarDatosLocales ?? cargarDatosLocalesPrisma } /** Invalida el cache (útil tras escrituras que cambian los números) */ invalidarCache(): void { this.cache.clear() } async resumen(dias?: number): Promise { const rango = dias !== undefined && Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined const clave = String(rango ?? 0) const ahoraMs = this.now() const hit = this.cache.get(clave) if (hit && hit.expira > ahoraMs) return hit.resumen const resumen = await this.cargarResumen(ahoraMs, rango) this.cache.set(clave, { resumen, expira: ahoraMs + this.cacheMs }) return resumen } /** * Resumen desde tablas locales (LeadCaptura por board, Unidad espejo, * PlanPago y ActividadLocal), en paralelo y con el cache de 60 s de siempre. */ private async cargarResumen(ahoraMs: number, dias?: number): Promise { const [locales, planes] = await Promise.all([this.cargarDatosLocales(), this.cargarPlanes()]) const resumen = construirResumen( { ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 }, new Date(ahoraMs), dias, ) // Los datos son completos (todas las métricas vienen de tablas locales) return { ...resumen, parcial: false } } }