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.
This commit is contained in:
2026-09-24 10:54:09 +00:00
parent cfc0acb5d3
commit 8a2584438d
30 changed files with 258 additions and 102 deletions

View File

@@ -114,6 +114,12 @@ export async function obtenerConversacion(req: Request, res: Response): Promise<
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
})
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
// Propiedad: misma regla que al borrar — sólo el dueño o un rol de
// supervisión puede leer la conversación.
const tokenLectura = req.usuarioToken
if (tokenLectura && !esAdminOManager(tokenLectura.rol) && conversacion.usuarioId !== tokenLectura.sub) {
throw new AppError(403, 'Solo puedes ver tus conversaciones')
}
res.json({
id: conversacion.id,
createdAt: conversacion.createdAt,

View File

@@ -68,7 +68,7 @@ describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }),
createdAt: expect.objectContaining({ gte: expect.any(Date), lt: expect.any(Date) }),
}),
}),
)

View File

@@ -525,6 +525,22 @@ async function prefillApartadoLocal(itemId: string) {
})
}
// Datos del alta (payloadJson) que el prefill del front consume con claves
// legacy de Monday: dirección, estado civil y fecha de nacimiento
const contactoExtra: Record<string, string> = {}
if (cliente?.payloadJson) {
try {
const payload = JSON.parse(cliente.payloadJson) as { datosGenerales?: Record<string, unknown> }
const g = payload.datosGenerales ?? {}
const str = (v: unknown): string => (typeof v === 'string' && v.trim() !== '' ? v : '')
if (str(g['direccion']) !== '') contactoExtra['Dirección'] = str(g['direccion'])
if (str(g['estadoCivil']) !== '') contactoExtra['Estado Civil'] = str(g['estadoCivil'])
if (str(g['fechaNacimiento']) !== '') contactoExtra['Fecha Nacimiento'] = str(g['fechaNacimiento'])
} catch {
// payload inválido: el prefill sale parcial, como antes
}
}
return {
apartado: {
id: captura.leadId,
@@ -545,6 +561,7 @@ async function prefillApartadoLocal(itemId: string) {
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
...contactoExtra,
}
: null,
oportunidad: oportunidadLocal

View File

@@ -149,8 +149,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
mondayItemId = opExistente
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
try {
// sombra:false: sólo se marca la etapa si el id corresponde a una
// oportunidad real. Antes (sombra para ids no locales) elegir un cliente
// del maestro en el buscador del cotizador creaba un LeadCaptura
// fantasma ("Registro previo (CRM anterior)") con pendienteSync.
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
sombra: !esIdLocal(opExistente),
sombra: false,
usuarioId: req.usuarioToken?.sub,
})
} catch (err) {

View File

@@ -339,9 +339,11 @@ apiRouter.post('/ai/chat/stream', chatIAStream)
apiRouter.get('/ai/conversations', listarConversaciones)
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
apiRouter.post('/ai/documents', subirDocumento)
apiRouter.get('/ai/documents', listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
// Mantenimiento de la base de conocimiento: SOLO admin (admin/manager no
// basta: borrar o inyectar documentos afecta a todos los usuarios del chat).
apiRouter.post('/ai/documents', requireAdminEstricto, subirDocumento)
apiRouter.get('/ai/documents', requireAdminEstricto, listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', requireAdminEstricto, eliminarDocumento)
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
import { getUnidadesLocal } from './unidades-local.service.js'
import {
APARTADO_DEFAULT,
@@ -1020,10 +1021,12 @@ export function crearEjecutoresHerramientas(
})
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
const hoy = new Date()
// Medianoche de hoy: lo que vence HOY aún no está vencido
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
return planes.map((p) => {
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto)
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < inicioHoy && x.montoPagado < x.monto)
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
const abonos = p.pagos
@@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas(
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
prisma.pago.findMany({
where: { fechaVencimiento: { lt: new Date() }, plan: alcance },
// Sólo planes activos: el adeudo de planes cancelados no cuenta
where: { fechaVencimiento: { lt: new Date() }, plan: { ...alcance, status: 'ACTIVO' } },
select: {
monto: true,
montoPagado: true,
@@ -1505,6 +1509,18 @@ export async function chat(
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
: null
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
// Propiedad: sólo el dueño (o un rol de supervisión) puede reanudar una
// conversación existente. Sin esto, cualquiera con el uuid continuaba el
// chat ajeno con todo su historial.
if (
conversacion &&
opciones.usuarioId &&
conversacion.usuarioId &&
conversacion.usuarioId !== opciones.usuarioId &&
!esAdminOManager(opciones.contexto?.rol)
) {
throw new AppError(403, 'No puedes continuar la conversación de otro usuario')
}
conversacion ??= await prisma.aIConversacion.create({
data: { usuarioId: opciones.usuarioId ?? null },
})

View File

@@ -94,7 +94,8 @@ describe('buscar', () => {
prismaMock.usuario.findMany.mockResolvedValue([])
await buscar({ desde, hasta })
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { createdAt: { gte: desde, lte: hasta } },
// "hasta" incluye todo el día: se excluye con lt al día siguiente
where: { createdAt: { gte: desde, lt: new Date(hasta.getTime() + 24 * 60 * 60 * 1000) } },
orderBy: { createdAt: 'desc' },
take: 100,
})

View File

@@ -43,7 +43,9 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
where['createdAt'] = {
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}),
// "hasta" es inclusive de todo el día: se excluye con lt al día siguiente
// (antes con lte a las 00:00 del día se perdía casi todo el día elegido)
...(filtros.hasta !== undefined ? { lt: new Date(filtros.hasta.getTime() + 24 * 60 * 60 * 1000) } : {}),
}
}
const logs = await prisma.auditoriaLog.findMany({

View File

@@ -155,8 +155,8 @@ describe('buscar (GET /api/buscar)', () => {
])
const r = await buscar('X')
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/hoy/')
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/')
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
})

View File

@@ -88,14 +88,14 @@ export async function buscar(
id: l.leadId,
nombre: l.nombreLead,
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
url: `/app/#leads`,
url: `/hoy/`,
})),
...clientes.map((c) => ({
tipo: 'contacto' as const,
id: c.id,
nombre: c.nombreCompleto,
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
url: `/app/#clientes`,
url: `/`,
})),
...cotizaciones.map((ct) => ({
tipo: 'oportunidad' as const,

View File

@@ -212,7 +212,8 @@ describe('listarContactosLocal', () => {
})
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
// 101 clientes: la página 1 llena 100 y detecta que hay más
// 101 clientes: la paginación se aplica DESPUÉS de fusionar fuentes, así
// que el servicio pide el universo completo y corta en memoria
prismaMock.cliente.findMany.mockResolvedValue(
Array.from({ length: 101 }, (_, i) =>
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
@@ -225,13 +226,12 @@ describe('listarContactosLocal', () => {
expect(pagina1.contactos).toHaveLength(100)
expect(pagina1.hayMas).toBe(true)
// La página 2 recibe el siguiente bloque (skip=100, take=101)
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c101', celular: '4770000100' }),
])
// La página 2 trae la fila más vieja (orden desc por fecha: la 101 va
// primero en la página 1)
const pagina2 = await listarContactosLocal(undefined, 2)
expect(pagina2.pagina).toBe(2)
expect(pagina2.contactos).toHaveLength(1)
expect(pagina2.contactos[0]?.id).toBe('c1')
expect(pagina2.hayMas).toBe(false)
})
})

View File

@@ -92,12 +92,12 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
const skip = (paginaValida - 1) * porPagina
// +1 por fuente para detectar si queda más (hayMas) sin una consulta extra
// La paginación se aplica DESPUÉS de fusionar las dos fuentes: antes se hacía
// skip/take por fuente y el dedupe posterior perdía contactos entre páginas
// y daba hayMas=false antes de tiempo.
const clientes = await prisma.cliente.findMany({
where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' },
skip,
take: porPagina + 1,
})
const capturas = await prisma.leadCaptura.findMany({
where: {
@@ -105,8 +105,6 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
...(usuarioId ? { usuarioId } : {}),
},
orderBy: { updatedAt: 'desc' },
skip,
take: porPagina + 1,
})
const lista: Candidato[] = []
@@ -207,12 +205,11 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
})
}
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const hayMas = ordenada.length > porPagina
return {
contactos: ordenada.slice(0, porPagina),
contactos: ordenada.slice(skip, skip + porPagina),
pagina: paginaValida,
porPagina,
hayMas,
hayMas: ordenada.length > skip + porPagina,
}
}

View File

@@ -124,27 +124,11 @@ export class CrmLocalService {
if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
// ese id para que la captura no se pierda en silencio.
// Sin fila local: id inválido o liga guardada de una limpieza anterior.
// Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
// rechaza para no sembrar registros huérfanos en Oportunidades.
if (!existente) {
await prisma.leadCaptura.create({
data: {
leadId: oportunidadId,
oportunidadId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
})
return { id: oportunidadId, oportunidadId, actualizada: true }
throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
}
// Sólo se pisan los campos capturados: lo que no viene en el payload se

View File

@@ -137,6 +137,8 @@ describe('obtenerScores', () => {
where: {
board: { in: ['LEADS', 'OPORTUNIDADES'] },
usuarioId: 'u1',
// Los cerrados (descartados/perdidos) no reciben score
etapa: { notIn: ['Descartado', 'Perdido'] },
},
})
})

View File

@@ -10,9 +10,10 @@ import { prisma } from './prisma.js'
const ETAPA_SCORE: Record<string, number> = {
NUEVO: 20,
'INTENTO DE CONTACTO': 30,
CONTACTADO: 40,
INTERESADO: 60,
AGENDO: 80,
'AGENDO CITA': 80,
CALIFICADO: 90,
}
@@ -74,6 +75,8 @@ export function calcularScore(lead: {
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
const where: Record<string, unknown> = {
board: { in: ['LEADS', 'OPORTUNIDADES'] },
// Los cerrados (descartados/perdidos) no reciben score ni salen en el ranking
etapa: { notIn: ['Descartado', 'Perdido'] },
}
if (usuarioId) {
where['usuarioId'] = usuarioId

View File

@@ -1,4 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
// La pasada consulta un admin de respaldo para los planes sin vendedor
// (antes caían a 'sin-destinatario' y nadie los veía).
const prismaMock = vi.hoisted(() => ({ usuario: { findFirst: vi.fn() } }))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import type { Pago, PlanPago } from './cobranza.service.js'
import {
RecordatoriosCobranzaService,
@@ -231,7 +238,29 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
expect(log).toHaveLength(1)
})
it('sin destinatario (plan sin vendedor): solo registra en el log, sin canal', async () => {
it('sin destinatario (plan sin vendedor): el recordatorio cae al administrador de respaldo', async () => {
prismaMock.usuario.findFirst.mockResolvedValue({ id: 'admin-qa' })
const planesSinVendedor = [
plan({
id: 'p1',
vendedorId: null,
pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })],
}),
]
const { store, log } = storeFake(planesSinVendedor)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(1)
expect(r.enviados[0]).toMatchObject({ canal: 'fake', destinatario: 'admin-qa' })
expect(envios).toHaveLength(1)
expect(envios[0].destinatario).toMatchObject({ vendedorId: 'admin-qa', origen: 'admin-fallback' })
expect(log).toHaveLength(1)
})
it('sin destinatario y SIN admin en el sistema: solo registra en el log, sin canal', async () => {
prismaMock.usuario.findFirst.mockResolvedValue(null)
const planesSinVendedor = [
plan({
id: 'p1',

View File

@@ -130,10 +130,11 @@ export function debeEnviar(
export interface DestinatarioRecordatorio {
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
vendedorId: string | null
origen: 'vendedor' | 'ninguno'
origen: 'vendedor' | 'ninguno' | 'admin-fallback'
}
/** Regla pura de selección: vendedor del plan → nadie */
/** Regla pura de selección: vendedor del plan → nadie (el fallback a admin se
* resuelve en la pasada, que tiene acceso a la BD). */
export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio {
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
return { vendedorId: null, origen: 'ninguno' }
@@ -218,9 +219,21 @@ export class RecordatoriosCobranzaService {
const planes = await this.store.cargarPlanes()
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
// Planes sin vendedor asignado (p. ej. cartera importada sin asesor): el
// recordatorio cae al administrador para que no se pierda y pueda
// reasignar el plan. Antes quedaban en 'sin-destinatario' y nadie los veía.
const adminFallback = await prisma.usuario.findFirst({
where: { rol: 'admin', activo: true },
orderBy: { createdAt: 'asc' },
select: { id: true },
})
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
for (const c of candidatos) {
const destinatario = elegirDestinatario(c.vendedorId)
let destinatario = elegirDestinatario(c.vendedorId)
if (destinatario.origen === 'ninguno' && adminFallback) {
destinatario = { vendedorId: adminFallback.id, origen: 'admin-fallback' }
}
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {

View File

@@ -77,15 +77,17 @@ describe('construirMiTablero', () => {
captura({ board: 'LEADS' }),
captura({ board: 'LEADS' }),
captura({ board: 'OPORTUNIDADES' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE' }),
captura({ board: 'APARTADO', etapa: 'CANCELO' }),
captura({ board: 'APARTADO', etapa: 'ESCRITURADO' }),
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
expect(tablero.conversion.oportunidades).toBe(1)
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
expect(tablero.conversion.vendidos).toBe(1)
// Cancelo no cuenta; Firma Cliente y Escriturado ya son ventas
expect(tablero.conversion.apartados).toBe(0)
expect(tablero.conversion.vendidos).toBe(2)
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
})
@@ -146,9 +148,9 @@ describe('construirMiTablero', () => {
it('agrupa por desarrollo solo con vendidos', () => {
const capturas = [
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'ATELIER' }),
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
]
const tablero = construirMiTablero(capturas, [], [], AHORA)

View File

@@ -122,13 +122,19 @@ export function construirMiTablero(
// -- 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' && c.etapa.toUpperCase() !== 'CANCELADO',
(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 (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
).length
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
@@ -184,7 +190,7 @@ export function construirMiTablero(
for (const c of capturas) {
if (
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
c.desarrollo
) {
const dev = c.desarrollo.trim()