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.
349 lines
10 KiB
TypeScript
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,
|
|
}
|
|
}
|