494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
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<string, number>
|
||
porOrigen: Record<string, number>
|
||
/** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */
|
||
perfil: {
|
||
porDesarrollo: Record<string, number>
|
||
porModelo: Record<string, number>
|
||
porEstadoCivil: Record<string, number>
|
||
porTipoEmpleo: Record<string, number>
|
||
porTipoEconomia: Record<string, number>
|
||
porCalifica: Record<string, number>
|
||
porCredito: Record<string, number>
|
||
porRangoSalarial: Record<string, number>
|
||
porMedioContacto: Record<string, number>
|
||
porLoQuierePara: Record<string, number>
|
||
porEdad: Record<string, number> // buckets '15–24' | '25–34' | '35–44' | '45–54' | '55+'
|
||
}
|
||
}
|
||
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> }[]
|
||
/** 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<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
|
||
}
|
||
|
||
/** Igual que contarPor pero sin bucket '(sin dato)': los vacíos se saltan (perfilamiento) */
|
||
function contarPorNoVacios(items: readonly string[]): Record<string, number> {
|
||
const out: Record<string, number> = {}
|
||
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<string, { total: number; porStatus: Record<string, number> }>()
|
||
const conteoEtapas = new Map<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 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<PlanPago[]>
|
||
|
||
/** 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<DatosLocalesReportes>
|
||
|
||
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<string, unknown>, 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<DatosLocalesReportes> {
|
||
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<string, unknown> = {}
|
||
if (c.datosJson !== null) {
|
||
try {
|
||
datos = JSON.parse(c.datosJson) as Record<string, unknown>
|
||
} 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<string, { resumen: ResumenReportes; expira: number }> = 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<ResumenReportes> {
|
||
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<ResumenReportes> {
|
||
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 }
|
||
}
|
||
}
|