Files
FRASA-CAS/api-gateway/src/services/reportes.service.ts

494 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}
}