Files
FRASA-CAS/api-gateway/src/services/reporte-mi-tablero.service.ts
Consult AS Technology 8a2584438d fix(auditoria general): 25 errores encontrados en la revision modulo por modulo
SEGURIDAD (agente IA):
- Conversaciones: GET /ai/conversations/:id y el reanudar chat verifican
  propiedad (antes cualquier usuario leia/continuaba la conversacion de otro).
- /ai/documents pasa a solo-admin (antes cualquien podia inyectar o borrar
  documentos de la base de conocimiento).

DATOS / CORRUPCION:
- Cotizador: elegir un cliente del maestro en el buscador ya no crea un lead
  fantasma "Registro previo (CRM anterior)" en el embudo.
- Afluencia con oportunidadId inexistente: 404 en vez de crear sombra fantasma.
- Cobranza: los 103 planes importados quedaron con vendedor asignado (7 con su
  asesor real segun el Excel, el resto al admin) y el motor de recordatorios
  cae al admin cuando un plan no tiene vendedor: antes los 411 recordatorios
  se registraban 'sin-destinatario' y no los veia nadie.
- Backups: script reescrito para el host real (sqlite .backup + uploads + env
  en /root/backups) y cron instalado (antes: script que asumia docker y sin
  cron programado = cero respaldos).

REPORTES / METRICAS:
- Mi tablero: el embudo ya excluye cancelaciones reales (Cancelo/Cancelacion
  Apartado) y cuenta como vendidos Firma Cliente/Escriturado/Titulado (antes
  41 "apartados" donde habia 13 activos y vendidos buscaba una etapa que no
  existe); tasa de descarte ya compara el mismo periodo.
- Scoring: etapa real 'Agendo Cita' (antes 'AGENDO' nunca aplicaba) y los
  descartados/perdidos ya no reciben score.
- ver_cobranza (IA): ignora planes cancelados y no cuenta como vencido el pago
  que vence hoy.

UI / FLUJOS:
- mi-dia: typo CANCO -> CANCELO (los cancelados reaparecen en Escrituracion);
  confirmar cita ya no retrocede la etapa de Cotizador/Aparto (boton y kanban).
- alta-clientes: la firma ya no se borra al navegar fuera del paso de firmas;
  toast de exito solo cuando el API confirma; prefill de apartado trae
  direccion, estado civil y fecha de nacimiento desde el alta.
- Auditoria: el filtro por usuario funciona (el front enviaba 'usuario' y el
  API esperaba 'usuarioId') y 'hasta' incluye todo el dia.
- Contactos: paginacion aplicada despues de fusionar fuentes (antes saltaban o
  se repetian contactos entre paginas y 'Siguiente' se deshabilitaba antes de
  tiempo).
- Buscador global: leads -> /hoy/, contactos -> /; el cotizador entiende
  ?cotizacion=<id> para retomar desde la liga.
- Export mi-dia: columnas Desarrollo/Origen rellenas y sin duplicados.
- Recibo de cobranza: "Recibio" es FRASA (el cliente paga, no recibe).
- Alta de plan de cobranza: proyecto libre con sugerencias del catalogo real
  (antes limitado a 3 proyectos que no cubrian la cartera).

Verificado en vivo: autorizacion IA (3x 403), filtro de auditoria, 404 de
afluencia. Suites: 717 API + 124 alta-clientes + 115 cotizador + 92 cobranza +
89 landing + 32 mi-dia en verde. Backup de hoy generado en /root/backups.
2026-09-24 10:54:09 +00:00

349 lines
10 KiB
TypeScript

import { prisma } from './prisma.js'
import { AppError } from '../utils/errors.js'
// ---------- Interfaces ----------
export interface MiTablero {
generadoEn: string
leadsPorDia: { fecha: string; cantidad: number }[]
conversion: {
leads: number
oportunidades: number
apartados: number
vendidos: number
/** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */
descartados: number
}
ventasMes: {
monto: number
unidades: number
}
porOrigen: {
origen: string
total: number
convertidos: number
}[]
porDesarrollo: {
desarrollo: string
ventas: number
}[]
actividadesHoy: number
actividadesPendientes: number
}
export interface MetricasUsoUsuario {
usuarioId: string
nombre: string
rol: string
leadsHoy: number
leadsSemana: number
leadsMes: number
tasaDescarte: number
actividadesCompletadas: number
actividadesPendientes: number
}
export interface MetricasUso {
generadoEn: string
usuarios: MetricasUsoUsuario[]
}
// ---------- Helpers ----------
/** Inicio del día local de `ahora` (medianoche) */
function inicioDelDia(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
/** Fecha local 'YYYY-MM-DD' */
function formatoFecha(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
// ---------- Servicio: Mi Tablero ----------
/**
* Construye el tablero personal del vendedor: leads por día (últimos 30 días),
* tasa de conversión, ventas del mes, rendimiento por origen, ranking de
* desarrollos y conteo de actividades.
*
* Función pura: recibe los datos crudos desde Prisma; testeable sin BD.
*/
export function construirMiTablero(
capturas: {
leadId: string
board: string
etapa: string
origen: string | null
desarrollo: string | null
createdAt: Date
/** Necesarios para las ventas cerradas del mes (fecha del cambio y precio) */
updatedAt?: Date
datosJson?: string | null
}[],
cotizaciones: {
precioFinal: number
vendedorId: string | null
createdAt: Date
}[],
actividades: {
usuarioId: string
estado: string
fechaVencimiento: Date | null
}[],
ahora: Date,
): MiTablero {
const hoy = inicioDelDia(ahora)
const hace30Dias = new Date(hoy.getTime() - 30 * 24 * 60 * 60 * 1000)
// -- Leads por día (últimos 30 días) --
const leadsPorDiaMap = new Map<string, number>()
// Inicializar todos los días para que la gráfica no tenga huecos
for (let i = 0; i < 30; i++) {
const d = new Date(hoy.getTime() - (29 - i) * 24 * 60 * 60 * 1000)
leadsPorDiaMap.set(formatoFecha(d), 0)
}
for (const c of capturas) {
const fecha = c.createdAt
if (fecha >= hace30Dias) {
const clave = formatoFecha(fecha)
if (leadsPorDiaMap.has(clave)) {
leadsPorDiaMap.set(clave, (leadsPorDiaMap.get(clave) ?? 0) + 1)
}
}
}
const leadsPorDia = [...leadsPorDiaMap.entries()]
.map(([fecha, cantidad]) => ({ fecha, cantidad }))
.sort((a, b) => a.fecha.localeCompare(b.fecha))
// -- Conversión (funnel) --
const leads = capturas.filter((c) => c.board === 'LEADS').length
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
// Apartados ACTIVOS del funnel: ni cancelados (Cancelo/Cancelacion Apartado)
// ni ya vendidos (Firma Cliente/Escriturado/Titulado pasan a "vendidos")
const apartados = capturas.filter(
(c) =>
c.board === 'APARTADO' &&
!['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes(
c.etapa.toUpperCase(),
),
).length
const vendidos = capturas.filter(
(c) =>
c.board === 'APARTADO' &&
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
).length
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
// -- Ventas del mes --
// Antes sumaba COTIZACIONES (un inmueble recotizado contaba varias veces y una
// cotización no es una venta). Ahora cuenta ventas cerradas del mes: apartados
// con firma de cliente o posteriores (Venta, Escriturado, Titulado), sin
// repetir la misma unidad.
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
const esVentaCerrada = (etapa: string): boolean => {
const e = etapa.toUpperCase()
return (
e.includes('FIRMA') || e.includes('VENTA') || e.includes('ESCRITURADO') || e.includes('TITULADO')
) && !e.includes('CANCEL')
}
const ventasDelMes = capturas.filter(
(c) => c.board === 'APARTADO' && esVentaCerrada(c.etapa) && (c.updatedAt ?? c.createdAt) >= inicioMes,
)
const unidadesVendidas = new Map<string, number>()
for (const v of ventasDelMes) {
let monto = 0
try {
const datos = JSON.parse(v.datosJson ?? '{}') as Record<string, unknown>
const precio = Number(datos['precioFinal'] ?? datos['precioVenta'] ?? 0)
monto = Number.isFinite(precio) ? precio : 0
} catch {
monto = 0
}
unidadesVendidas.set(v.leadId, monto)
}
const ventasMes = {
monto: Math.round([...unidadesVendidas.values()].reduce((a, b) => a + b, 0) * 100) / 100,
unidades: unidadesVendidas.size,
}
// -- Por origen --
const origenMap = new Map<string, { total: number; convertidos: number }>()
for (const c of capturas) {
const origen = c.origen?.trim() || '(sin origen)'
const entrada = origenMap.get(origen) ?? { total: 0, convertidos: 0 }
entrada.total++
if (c.board === 'OPORTUNIDADES' || c.board === 'APARTADO') {
entrada.convertidos++
}
origenMap.set(origen, entrada)
}
const porOrigen = [...origenMap.entries()]
.map(([origen, v]) => ({ origen, ...v }))
.sort((a, b) => b.total - a.total)
// -- Por desarrollo (solo apartados vendidos) --
const desarrolloMap = new Map<string, number>()
for (const c of capturas) {
if (
c.board === 'APARTADO' &&
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
c.desarrollo
) {
const dev = c.desarrollo.trim()
if (dev !== '') {
desarrolloMap.set(dev, (desarrolloMap.get(dev) ?? 0) + 1)
}
}
}
const porDesarrollo = [...desarrolloMap.entries()]
.map(([desarrollo, ventas]) => ({ desarrollo, ventas }))
.sort((a, b) => b.ventas - a.ventas)
// -- Actividades --
const manana = new Date(hoy.getTime() + 24 * 60 * 60 * 1000)
const actividadesHoy = actividades.filter(
(a) =>
a.estado.toUpperCase() === 'ABIERTA' &&
(!a.fechaVencimiento || a.fechaVencimiento < manana),
).length
const actividadesPendientes = actividades.filter(
(a) => a.estado.toUpperCase() === 'ABIERTA',
).length
return {
generadoEn: ahora.toISOString(),
leadsPorDia,
conversion: { leads, oportunidades, apartados, vendidos, descartados },
ventasMes,
porOrigen,
porDesarrollo,
actividadesHoy,
actividadesPendientes,
}
}
// ---------- Servicio con Prisma ----------
/**
* Obtiene el tablero personal de un vendedor desde la base de datos.
*/
export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
const ahora = new Date()
const capturas = await prisma.leadCaptura.findMany({
where: { usuarioId },
select: {
leadId: true,
board: true,
etapa: true,
origen: true,
desarrollo: true,
createdAt: true,
updatedAt: true,
datosJson: true,
},
})
const cotizaciones = await prisma.cotizacion.findMany({
where: { vendedorId: usuarioId },
select: {
precioFinal: true,
vendedorId: true,
createdAt: true,
},
})
const actividades = await prisma.actividadLocal.findMany({
where: { usuarioId },
select: {
usuarioId: true,
estado: true,
fechaVencimiento: true,
},
})
return construirMiTablero(capturas, cotizaciones, actividades, ahora)
}
// ---------- Servicio: Métricas de uso (admin/coordinador) ----------
/**
* Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de
* descarte, actividades completadas vs pendientes. Visible solo para admin y
* coordinador.
*/
export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUso> {
const ahora = new Date()
const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
const usuarioFiltro = usuarioId ?? undefined
const [capturas, actividades, usuarios] = await Promise.all([
prisma.leadCaptura.findMany({
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
select: {
usuarioId: true,
etapa: true,
createdAt: true,
},
}),
prisma.actividadLocal.findMany({
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
select: {
usuarioId: true,
estado: true,
},
}),
prisma.usuario.findMany({
where: usuarioFiltro ? { id: usuarioFiltro } : { activo: true },
select: {
id: true,
nombre: true,
rol: true,
},
}),
])
const metricasPorUsuario: MetricasUsoUsuario[] = usuarios.map((u) => {
const misCapturas = capturas.filter((c) => c.usuarioId === u.id)
const leadsHoy = misCapturas.filter((c) => c.createdAt >= hoy).length
const leadsSemana = misCapturas.filter((c) => c.createdAt >= hace7dias).length
const leadsMes = misCapturas.filter((c) => c.createdAt >= inicioMes).length
const descartados = misCapturas.filter((c) =>
c.etapa.toUpperCase() === 'DESCARTADO',
).length
const tasaDescarte = leadsMes > 0 ? Math.round((descartados / leadsMes) * 100) / 100 : 0
const misActividades = actividades.filter((a) => a.usuarioId === u.id)
const actividadesCompletadas = misActividades.filter(
(a) => a.estado.toUpperCase() === 'CERRADA',
).length
const actividadesPendientes = misActividades.filter(
(a) => a.estado.toUpperCase() === 'ABIERTA',
).length
return {
usuarioId: u.id,
nombre: u.nombre,
rol: u.rol,
leadsHoy,
leadsSemana,
leadsMes,
tasaDescarte,
actividadesCompletadas,
actividadesPendientes,
}
})
return {
generadoEn: ahora.toISOString(),
usuarios: metricasPorUsuario,
}
}