refactor: las afluencias viven en Oportunidades (no en Leads)

- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia)
  con todo el perfil (desarrollo, modelo, estado civil, credito, etc.)
  en las columnas del board; dedup por telefono contra Oportunidades;
  asignacion al capturista via columna Asesor o round-robin del equipo.
- Round-robin, escalado e inactividad operan sobre Oportunidades (solo
  etapa Afluencia sin asesor entra al reparto).
- Acciones del vendedor (contactado/cita/descartar) escriben directo en
  la oportunidad; descarte = Perdido + Motivo No Compra.
- Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en
  Afluencia; misOportunidades.porEtapa ya es real (columna Asesor).
- El picker del alta busca en Leads (historico) Y Oportunidades, con
  insignia de origen; moverAAparto tolera ambos ids.
- Lectura de status con value null = vacio (quirk del default Monday).
- Script de migracion del historial de agosto Leads -> Oportunidades.
This commit is contained in:
2026-09-04 04:46:34 +00:00
parent d4fc9ee892
commit 845f314560
26 changed files with 1496 additions and 985 deletions

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Migra las afluencias históricas del tablero Leads al tablero Oportunidades.
Las afluencias ahora viven en Oportunidades (etapa Afluencia y afines) con todo
el perfil; el tablero Leads queda como archivo (se vacía tras migrar).
Uso:
python3 migrar-afluencias-a-oportunidades.py # dry-run
python3 migrar-afluencias-a-oportunidades.py --apply # migra y BORRA del board Leads
python3 migrar-afluencias-a-oportunidades.py --apply --no-delete # migra sin borrar
- Estado: CONTACTADO→Contactado, AGENDA CITA→Agendo Cita, CALIFICADO→Calificado,
DESCARTADO→Perdido, Abandono→Abandono, NUEVO/vacío→Afluencia.
- Grupo destino: Perdidos si Perdido/Abandono, Ganados si Ganado, si no topics.
- Vendedor (people) → Asesor (people), si lo tiene.
- Crea la fila LeadOportunidad {leadId: opId, oportunidadId: opId} para que el
alta de clientes pueda mover la oportunidad migrada a 'Aparto'.
"""
import json
import re
import sqlite3
import sys
import time
import urllib.request
from pathlib import Path
ENV = Path(__file__).resolve().parents[2] / '.env'
DB = Path(__file__).resolve().parents[1] / 'prisma' / 'dev.db'
BOARD_LEADS = '18427678493'
BOARD_OPS = '18427678497'
GRUPO_PERDIDOS = 'group_mm6s4cgq'
GRUPO_GANADOS = 'group_mm6scqz3'
GRUPO_TOPICS = 'topics'
TOKEN = re.search(r'^MONDAY_API_KEY="?([^"\n]+)"?', ENV.read_text(), re.M).group(1)
def gql(query, variables=None):
req = urllib.request.Request(
'https://api.monday.com/v2',
data=json.dumps({'query': query, 'variables': variables or {}}).encode(),
headers={'Authorization': TOKEN, 'Content-Type': 'application/json'},
)
with urllib.request.urlopen(req) as r:
res = json.loads(r.read())
if 'errors' in res:
raise RuntimeError(res['errors'])
return res['data']
ESTADO_MAP = {
'CONTACTADO': 'Contactado',
'AGENDA CITA': 'Agendo Cita',
'CALIFICADO': 'Calificado',
'DESCARTADO': 'Perdido',
'ABANDONO': 'Abandono',
'NUEVO': 'Afluencia',
'': 'Afluencia',
}
def todos_los_items(board):
items, cursor = [], None
while True:
if cursor is None:
q = 'query { boards(ids: [%s]) { items_page(limit: 200) { cursor items { id name created_at column_values { id text value } } } } }' % board
else:
q = 'query { boards(ids: [%s]) { items_page(limit: 200, cursor: "%s") { cursor items { id name created_at column_values { id text value } } } } }' % (board, cursor)
page = gql(q)['boards'][0]['items_page']
items.extend(page['items'])
cursor = page['cursor']
if not cursor:
return items
def main():
apply = '--apply' in sys.argv
borrar = apply and '--no-delete' not in sys.argv
cols_leads = {c['title']: c for c in gql('query { boards(ids: [%s]) { columns { id title type } } }' % BOARD_LEADS)['boards'][0]['columns']}
cols_ops = {c['title']: c for c in gql('query { boards(ids: [%s]) { columns { id title type } } }' % BOARD_OPS)['boards'][0]['columns']}
# Mapeo de columnas Leads → Oportunidades (por título)
MAPA = [
('Teléfono', 'Cliente teléfono'),
('Email', 'Cliente email'),
('Desarrollo', 'Desarrollo'),
('Modelo de Interés', 'Modelo de Interés'),
('Origen', 'Origen del Lead'),
('Medio de Contacto', 'Medio de Contacto'),
('Edad', 'Edad'),
('Estado Civil', 'Estado Civil'),
('Tipo de Empleo', 'Tipo de Empleo'),
('Ocupación', 'Ocupación'),
('Rango Salarial', 'Rango Salarial'),
('Crédito', 'Crédito'),
('Tipo de Economía', 'Tipo de Econimia'),
('Califica', 'Lead Califica'),
('Lo Quiere Para', 'Proposito de Compra'),
('Notas', 'COMENTARIOS'),
('Fecha', 'Fecha'),
]
items = todos_los_items(BOARD_LEADS)
# existentes en Oportunidades (por teléfono) para no duplicar si se corre 2 veces
existentes = todos_los_items(BOARD_OPS)
col_tel_ops = cols_ops['Cliente teléfono']['id']
tels_ops = set()
for it in existentes:
for cv in it['column_values']:
if cv['id'] == col_tel_ops and cv['text']:
d = re.sub(r'\D', '', cv['text'])
if len(d) >= 10:
tels_ops.add(d[-10:])
print(f'Leads a migrar: {len(items)} · oportunidades existentes: {len(existentes)}')
migrados, saltados, errores = 0, 0, 0
detalles = []
db = sqlite3.connect(DB) if apply else None
for it in items:
cvs = {cv['id']: cv for cv in it['column_values']}
# teléfono para dedup
tel_txt = (cvs.get(cols_leads['Teléfono']['id']) or {}).get('text') or ''
dig = re.sub(r'\D', '', tel_txt)
if dig and dig[-10:] in tels_ops:
saltados += 1
detalles.append(f" skip (ya existe): {it['name'][:45]}")
continue
estado_leads = (cvs.get(cols_leads['Estado']['id']) or {})
estado_txt = (estado_leads.get('text') or '') if estado_leads.get('value') else ''
estado_destino = ESTADO_MAP.get(estado_txt.strip().upper(), 'Afluencia')
grupo = GRUPO_PERDIDOS if estado_destino in ('Perdido', 'Abandono') else (GRUPO_GANADOS if estado_destino == 'Ganado' else GRUPO_TOPICS)
valores = {}
for titulo_leads, titulo_ops in MAPA:
cl, co = cols_leads.get(titulo_leads), cols_ops.get(titulo_ops)
if not cl or not co:
continue
cv = cvs.get(cl['id'])
if not cv or not cv.get('text'):
continue
txt = cv['text']
if co['type'] == 'status':
valores[co['id']] = {'label': txt}
elif co['type'] == 'dropdown':
valores[co['id']] = {'labels': [txt]}
elif co['type'] == 'numbers':
try:
valores[co['id']] = float(txt)
except ValueError:
pass
elif co['type'] == 'date':
if re.match(r'^\d{4}-\d{2}-\d{2}$', txt):
valores[co['id']] = {'date': txt}
elif co['type'] == 'phone':
valores[co['id']] = {'phone': txt, 'countryShortName': 'MX'}
elif co['type'] == 'email':
if re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', txt):
valores[co['id']] = {'email': txt, 'text': txt}
elif co['type'] == 'long_text':
valores[co['id']] = {'text': txt[:1900]}
else:
valores[co['id']] = txt
valores[cols_ops['Estado']['id']] = {'label': estado_destino}
# Asesor si el lead tenía vendedor
raw_vend = (cvs.get(cols_leads['Vendedor']['id']) or {}).get('value')
if raw_vend:
try:
persons = [p for p in json.loads(raw_vend).get('personsAndTeams', []) if p.get('kind') == 'person']
if persons:
valores[cols_ops['Asesor']['id']] = {'personsAndTeams': persons}
except Exception:
pass
if not apply:
migrados += 1
continue
try:
res = gql(
'mutation ($b: ID!, $g: String!, $n: String!, $v: JSON!) { create_item(board_id: $b, group_id: $g, item_name: $n, column_values: $v) { id } }',
{'b': BOARD_OPS, 'g': grupo, 'n': it['name'], 'v': json.dumps(valores)},
)
op_id = res['create_item']['id']
db.execute(
'INSERT OR IGNORE INTO "LeadOportunidad" (id, leadId, oportunidadId, createdAt) VALUES (lower(hex(randomblob(16))), ?, ?, datetime("now"))',
(op_id, op_id),
)
migrados += 1
print(f"OK {it['name'][:45]} → op {op_id} [{estado_destino}]")
if borrar:
gql('mutation { delete_item(item_id: %s) { id } }' % it['id'])
time.sleep(0.45)
except Exception as e:
errores += 1
print(f"ERROR {it['name'][:45]}: {e}")
time.sleep(0.5)
if db:
db.commit()
db.close()
for d in detalles:
print(d)
print(f"\n{'MIGRADOS' if apply else 'DRY-RUN'}: {migrados} · saltados (dup): {saltados} · errores: {errores} · borrados de Leads: {migrados if borrar else 0}")
if __name__ == '__main__':
main()

View File

@@ -160,8 +160,11 @@ const hoyMas = (dias: number): string => {
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
/**
* Si el alta viene de un lead del funnel (la app alta-clientes manda
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico del
* tablero Leads; moverAApartoConDedup resuelve ambos vía LeadOportunidad.
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
* Un fallo aquí no rompe el alta.

View File

@@ -9,8 +9,7 @@ import {
descartarLead,
ESTADOS_LEAD_PERMITIDOS,
} from './leads.controller.js'
import { mondayService, type LeadMonday } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -19,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
@@ -30,37 +32,38 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
const LABELS_DESARROLLO = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
const LABELS_ORIGEN = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro', 'Instagram', 'Broker', 'Base de datos', 'Prospección']
const LABELS_MOTIVO = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
function mondayMock(
opts: {
labelsOrigen?: string[]
labelsDesarrollo?: string[]
labelsMotivo?: string[]
duplicado?: LeadMonday | null
duplicado?: OportunidadMonday | null
} = {},
) {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
leadsCreados: [] as Record<string, unknown>[],
oportunidadesCreadas: [] as Record<string, unknown>[],
movimientosGrupo: [] as { itemId: string; groupId: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async crearLead(datos: Record<string, unknown>) {
this.leadsCreados.push(datos)
return 'lead-nuevo-1'
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'op-nueva-1'
},
async buscarLeadPorTelefono(_telefono: string) {
async buscarOportunidadPorTelefono(_telefono: string) {
return opts.duplicado ?? null
},
async labelsDeColumna(_boardId: string, titulo: string) {
if (titulo === 'Desarrollo') {
return opts.labelsDesarrollo ?? ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
}
if (titulo === 'Motivo No Compra') {
return opts.labelsMotivo ?? ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
}
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO
return opts.labelsOrigen ?? LABELS_ORIGEN
},
async obtenerNombreItem(_itemId: string) {
return 'Lead Prueba'
@@ -69,46 +72,70 @@ function mondayMock(
this.motivos.push(datos)
return 'motivo-item-1'
},
}
}
/** Funnel mockeado: registra asegurados/movimientos; configurable para fallar */
function funnelMock(opts: { oportunidadId?: string | null; fallaAsegurar?: boolean } = {}) {
return {
asegurados: [] as Record<string, unknown>[],
movimientos: [] as { leadId: string; etapa: string }[],
async asegurarOportunidadAfluencia(lead: Record<string, unknown>) {
if (opts.fallaAsegurar === true) throw new Error('Monday caído')
this.asegurados.push(lead)
return 'op-1'
// Para moverAGrupoSegunEstado tras la creación: el item nace en el grupo
// top del tablero (como en Monday real) con Estado 'Afluencia'
async columnaIdPorTitulo(_boardId: string, _titulo: string) {
return 'col_estado'
},
async moverOportunidadDeLead(leadId: string, etapa: string) {
this.movimientos.push({ leadId, etapa })
return true
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Lead Prueba',
groupId: 'group_top',
column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }],
}
},
async oportunidadDeLead(_leadId: string) {
return opts.oportunidadId === undefined ? 'op-1' : opts.oportunidadId
async moverItemAGrupo(itemId: string, groupId: string) {
this.movimientosGrupo.push({ itemId, groupId })
},
}
}
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
describe('crearLead (POST /api/leads)', () => {
it('crea el item en Monday con Estado NUEVO y devuelve su id', async () => {
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
id: 'op-1',
nombre: 'Lead Prueba',
createdAt: '2026-08-01T00:00:00Z',
updatedAt: '2026-08-01T00:00:00Z',
etapa: 'Afluencia',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
origen: '',
...overrides,
})
describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () => {
it('crea UN item en Oportunidades (Estado Afluencia) y devuelve su id como id y oportunidadId', async () => {
const monday = mondayMock()
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados).toEqual([
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas).toEqual([
{
nombre: 'Lead Prueba',
telefono: '4771234567',
email: undefined,
origenLabel: 'Facebook',
desarrolloLabel: undefined,
origenLabel: 'Facebook',
modeloLabel: undefined,
medioContacto: undefined,
edad: undefined,
estadoCivilLabel: undefined,
tipoEmpleoLabel: undefined,
ocupacion: undefined,
rangoSalarial: undefined,
creditoLabel: undefined,
tipoEconomiaLabel: undefined,
calificaLabel: undefined,
loQuierePara: undefined,
notas: 'Vio el volante',
},
])
// Y la mueve al grupo de su etapa (Monday la crea en el grupo top, hoy "Perdidos")
expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-nueva-1', groupId: 'topics' }])
})
it('pasa email y desarrollo al item de Monday cuando vienen en la captura', async () => {
@@ -118,8 +145,8 @@ describe('crearLead (POST /api/leads)', () => {
email: 'lead@correo.com',
desarrollo: 'SENDA',
})
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
})
it('pasa los campos de calificación al item de Monday (labels listos para el board)', async () => {
@@ -138,8 +165,8 @@ describe('crearLead (POST /api/leads)', () => {
califica: 'Sí',
loQuierePara: 'Vivir ahí',
})
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados[0]).toMatchObject({
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas[0]).toMatchObject({
modeloLabel: 'HIMALAYA',
medioContacto: 'WhatsApp',
edad: 35,
@@ -154,189 +181,136 @@ describe('crearLead (POST /api/leads)', () => {
})
})
it('con funnel: asegura la oportunidad de Afluencia con los datos del lead', async () => {
const monday = mondayMock()
const funnel = funnelMock()
const r = await crearLead(monday, { ...datosBase, email: 'lead@correo.com', desarrollo: 'SENDA' }, funnel)
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: 'op-1' })
expect(funnel.asegurados).toEqual([
{ id: 'lead-nuevo-1', nombre: 'Lead Prueba', telefono: '4771234567', email: 'lead@correo.com', desarrolloLabel: 'SENDA' },
])
})
it('si falla la oportunidad de Afluencia, el lead igual se crea (oportunidadId null)', async () => {
const monday = mondayMock()
const funnel = funnelMock({ fallaAsegurar: true })
const r = await crearLead(monday, datosBase, funnel)
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados).toHaveLength(1)
})
it('duplicado por teléfono: no crea lead ni oportunidad de Afluencia', async () => {
const existente: LeadMonday = {
id: 'lead-viejo-9',
nombre: 'Lead Prueba',
createdAt: '',
updatedAt: '',
estado: 'NUEVO',
origen: '',
telefono: '4771234567',
email: '',
vendedorIds: [],
}
it('duplicado por teléfono contra Oportunidades: no crea nada y devuelve el item existente', async () => {
const existente = oportunidadMonday({ id: 'op-vieja-9', telefono: '4771234567' })
const monday = mondayMock({ duplicado: existente })
const funnel = funnelMock()
const r = await crearLead(monday, datosBase, funnel)
const r = await crearLead(monday, datosBase)
expect(r).toEqual({ duplicado: existente })
expect(monday.leadsCreados).toEqual([])
expect(funnel.asegurados).toEqual([])
expect(monday.oportunidadesCreadas).toEqual([])
expect(monday.movimientosGrupo).toEqual([])
})
it('rechaza un desarrollo que no sea label real de la columna Desarrollo', async () => {
it('un fallo al mover de grupo no rompe la captura (la oportunidad ya quedó)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'moverItemAGrupo').mockRejectedValueOnce(new Error('Monday caído'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const r = await crearLead(monday, datosBase)
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
espia.mockRestore()
})
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => {
const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
/Desarrollo inválido/,
)
expect(monday.leadsCreados).toEqual([])
expect(monday.oportunidadesCreadas).toEqual([])
})
it('rechaza un origen que no sea label real de la columna Origen', async () => {
it('rechaza un origen que no sea label real del dropdown Origen del Lead', async () => {
const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
expect(monday.leadsCreados).toEqual([])
expect(monday.oportunidadesCreadas).toEqual([])
})
})
describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
it('la whitelist solo permite las transiciones del vendedor', () => {
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'])
// DESCARTADO tiene su propio endpoint (lleva motivo); NUEVO no es acción
// Perdido tiene su propio endpoint (lleva motivo)
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO')
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
})
it('cambia solo la columna Estado en el board Leads (444 en tests)', async () => {
it('escribe la columna Estado en Oportunidades (555 en tests) con el label real del board', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'CONTACTADO' }])
await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Contactado' }])
})
it('AGENDA CITA con fecha también setea la columna Fecha', async () => {
it('AGENDA CITA se traduce al label "Agendo Cita" y con fecha también setea la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01')
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA', '2026-09-01')
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' },
{ boardId: '444', itemId: 'lead-1', columna: 'Fecha', valor: '2026-09-01' },
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Agendo Cita' },
{ boardId: '555', itemId: 'op-1', columna: 'Fecha', valor: '2026-09-01' },
])
})
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA')
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA')
expect(monday.cambios).toHaveLength(1)
})
it('AGENDA CITA mueve la oportunidad del lead a "Agendo Cita"', async () => {
it('CALIFICADO se escribe como "Calificado" (label real del board)', async () => {
const monday = mondayMock()
const funnel = funnelMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', funnel)
expect(funnel.movimientos).toEqual([{ leadId: 'lead-1', etapa: 'Agendo Cita' }])
})
it('otros estados NO mueven la oportunidad del lead', async () => {
const monday = mondayMock()
const funnel = funnelMock()
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO', undefined, funnel)
await cambiarEstadoLead(monday, 'lead-1', 'CALIFICADO', undefined, funnel)
expect(funnel.movimientos).toEqual([])
})
it('un fallo del funnel en AGENDA CITA no rompe el cambio de estado', async () => {
const monday = mondayMock()
const funnel = funnelMock()
vi.spyOn(funnel, 'moverOportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído'))
await expect(cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', undefined, funnel)).resolves.toBeUndefined()
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' }])
await cambiarEstadoLead(monday, 'op-1', 'CALIFICADO')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Calificado' }])
})
})
describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
it('Estado DESCARTADO + Notas + registro en Motivos No Compra', async () => {
it('Estado Perdido + Motivo No Compra + COMENTARIOS + registro en Motivos No Compra', async () => {
const monday = mondayMock()
const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', 'Se le hizo caro')
const motivoItemId = await descartarLead(monday, 'op-1', 'Precio', 'Se le hizo caro')
expect(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' },
{ boardId: '444', itemId: 'lead-1', columna: 'Notas', valor: 'Se le hizo caro' },
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' },
{ boardId: '555', itemId: 'op-1', columna: 'COMENTARIOS', valor: 'Se le hizo caro' },
{ boardId: '555', itemId: 'op-1', columna: 'Motivo No Compra', valor: 'Precio' },
])
expect(monday.motivos).toEqual([
{ nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' },
])
})
it('sin comentario no escribe Notas', async () => {
it('sin comentario no escribe COMENTARIOS', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Timing')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }])
await descartarLead(monday, 'op-1', 'Timing')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' },
{ boardId: '555', itemId: 'op-1', columna: 'Motivo No Compra', valor: 'Timing' },
])
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
})
it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
await expect(descartarLead(monday, 'lead-1', 'Cliente')).rejects.toThrow('Monday caído')
await expect(descartarLead(monday, 'op-1', 'Cliente')).rejects.toThrow('Monday caído')
expect(monday.motivos).toEqual([])
})
it('con funnel: la oportunidad pasa a "Perdido" y hereda el motivo en el dropdown', async () => {
const monday = mondayMock()
const funnel = funnelMock({ oportunidadId: 'op-9' })
await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel)
expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' })
expect(monday.cambios).toContainEqual({
boardId: '555',
itemId: 'op-9',
columna: 'Motivo No Compra',
valor: 'Precio',
})
})
it('si el motivo no es label del dropdown, solo mueve a Perdido (sin dropdown)', async () => {
const monday = mondayMock({ labelsMotivo: ['Otro'] })
const funnel = funnelMock({ oportunidadId: 'op-9' })
await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel)
expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' })
await descartarLead(monday, 'op-1', 'Precio')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' }])
expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined()
})
it('sin mapping lead ↔ oportunidad no toca el tablero Oportunidades', async () => {
const monday = mondayMock()
const funnel = funnelMock({ oportunidadId: null })
const motivoItemId = await descartarLead(monday, 'lead-1', 'Timing', undefined, funnel)
expect(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios.filter((c) => c.boardId === '555')).toEqual([])
})
it('un fallo del funnel no rompe el descarte (DESCARTADO + motivo ya quedaron)', async () => {
const monday = mondayMock()
const funnel = funnelMock({ oportunidadId: 'op-9' })
vi.spyOn(funnel, 'oportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído'))
const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel)
expect(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios).toContainEqual({ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' })
expect(monday.motivos).toHaveLength(1) // el registro en el catálogo se crea igual
})
})
describe('crearLeadEndpoint (POST /api/leads)', () => {
beforeEach(() => {
vi.clearAllMocks()
// El singleton Monday responde como si creara el lead; el funnel liga la oportunidad
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-9')
// El singleton Monday responde como si creara la afluencia en Oportunidades
vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9')
// moverAGrupoSegunEstado tras la creación (mocks de sus dependencias)
vi.spyOn(mondayService, 'columnaIdPorTitulo').mockResolvedValue('col_estado')
vi.spyOn(mondayService, 'getItemConGrupo').mockResolvedValue({
id: 'op-nueva-9',
name: 'Lead Prueba',
groupId: 'group_top',
column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }],
})
vi.spyOn(mondayService, 'moverItemAGrupo').mockResolvedValue(undefined)
vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined)
// Default: el capturista NO tiene Monday vinculado → asignación round-robin
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
@@ -355,27 +329,30 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
return { res: { json, status } as unknown as Response, status, json }
}
it('201: registra la captura local con el usuario del token', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
it('201: registra la captura local y el auto-mapping con el usuario del token', async () => {
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: {
leadId: 'lead-nuevo-9',
oportunidadId: 'op-9',
leadId: 'op-nueva-9',
oportunidadId: 'op-nueva-9',
usuarioId: 'u-1',
nombreLead: 'Lead Prueba',
telefono: '4771234567',
},
})
// Auto-mapping LeadOportunidad (leadId = oportunidadId): el alta de
// clientes encuentra así la oportunidad a mover a 'Aparto'
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'op-nueva-9', oportunidadId: 'op-nueva-9' },
})
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' })
})
it('con Monday vinculado, el lead queda asignado al capturista (no al round-robin)', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
it('con Monday vinculado, la afluencia queda asignada al capturista (columna Asesor)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const { res, status, json } = resFake()
@@ -383,15 +360,14 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
// Bandera anti-carrera ANTES de asignar + asignación al capturista
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'rr:skip:lead-nuevo-9' }, create: { clave: 'rr:skip:lead-nuevo-9', valor: 'capturista' } }),
expect.objectContaining({ where: { clave: 'rr:skip:op-nueva-9' }, create: { clave: 'rr:skip:op-nueva-9', valor: 'capturista' } }),
)
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('lead-nuevo-9', 'Lead Prueba', 12345)
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'capturista' })
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'capturista' })
})
it('un fallo al asignar al capturista no rompe la captura (cae al round-robin)', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -400,7 +376,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
await crearLeadEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' })
espia.mockRestore()
})
@@ -410,10 +386,12 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
// El auto-mapping sí se escribe (no depende del usuario)
expect(prismaMock.leadOportunidad.create).toHaveBeenCalled()
expect(status).toHaveBeenCalledWith(201)
})
it('un fallo del registro local no rompe la respuesta (el lead ya quedó en Monday)', async () => {
it('un fallo del registro local no rompe la respuesta (la afluencia ya quedó en Monday)', async () => {
prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status } = resFake()
@@ -425,7 +403,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
})
})
describe('buscarLeads (GET /api/leads/buscar)', () => {
describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportunidades', () => {
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
@@ -439,31 +417,47 @@ describe('buscarLeads (GET /api/leads/buscar)', () => {
...overrides,
})
it('matchea por nombre, teléfono y email (case-insensitive)', () => {
it('matchea por nombre, teléfono y email (case-insensitive) en ambos tableros, con su fuente', () => {
const leads = [
lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
lead({ id: 'b', telefono: '477 123 4567' }),
lead({ id: 'c', email: 'Cliente@Correo.com' }),
lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
]
expect(buscarLeads(leads, 'fernanda').map((l) => l.id)).toEqual(['a'])
expect(buscarLeads(leads, '123').map((l) => l.id)).toEqual(['b'])
expect(buscarLeads(leads, 'cliente@').map((l) => l.id)).toEqual(['c'])
const ops = [
oportunidadMonday({ id: 'op-c', email: 'Cliente@Correo.com' }),
oportunidadMonday({ id: 'op-x', nombre: 'Nadie' }),
]
expect(buscarLeads(leads, ops, 'fernanda').map((r) => r.id)).toEqual(['a'])
expect(buscarLeads(leads, ops, '123').map((r) => r.id)).toEqual(['b'])
const porEmail = buscarLeads(leads, ops, 'cliente@')
expect(porEmail.map((r) => r.id)).toEqual(['op-c'])
expect(porEmail[0]?.fuente).toBe('oportunidad')
expect(buscarLeads(leads, ops, 'fernanda')[0]?.fuente).toBe('lead')
})
it('ignora acentos tanto en el lead como en la búsqueda', () => {
it('ignora acentos tanto en el item como en la búsqueda', () => {
const leads = [lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ', email: 'andres@correo.com' })]
expect(buscarLeads(leads, 'maria')).toHaveLength(1)
expect(buscarLeads(leads, 'andrés')).toHaveLength(1)
expect(buscarLeads(leads, [], 'maria')).toHaveLength(1)
expect(buscarLeads(leads, [], 'andrés')).toHaveLength(1)
})
it('ordena por updatedAt descendente y limita a 15', () => {
const leads = Array.from({ length: 20 }, (_, i) =>
it('ordena por updatedAt descendente (mezclando tableros) y limita a 15', () => {
const leads = Array.from({ length: 10 }, (_, i) =>
lead({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }),
)
const r = buscarLeads(leads, 'común')
const ops = Array.from({ length: 10 }, (_, i) =>
oportunidadMonday({ id: `op-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 11).padStart(2, '0')}T00:00:00Z` }),
)
const r = buscarLeads(leads, ops, 'común')
expect(r).toHaveLength(15)
expect(r[0]?.id).toBe('l-19') // el más reciente primero
expect(r[0]?.id).toBe('op-9') // el más reciente primero (de Oportunidades)
expect(r[14]?.id).toBe('l-5') // se corta en 15 entre ambos tableros (10 ops + 5 leads)
})
it('la oportunidad expone su etapa como estado y su origen del dropdown', () => {
const ops = [oportunidadMonday({ id: 'op-1', nombre: 'Cliente Común', etapa: 'Agendo Cita', origen: 'WhatsApp' })]
const r = buscarLeads([], ops, 'común')
expect(r[0]).toMatchObject({ id: 'op-1', estado: 'Agendo Cita', origen: 'WhatsApp', fuente: 'oportunidad' })
})
it('el endpoint responde 400 con q de menos de 2 caracteres', async () => {

View File

@@ -1,22 +1,35 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
import { funnelService, type FunnelService } from '../services/funnel.service.js'
import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
/**
* CRM ligero sobre el tablero Leads: captura rápida y acciones del vendedor.
* Captura y acciones del CRM ligero sobre el tablero Oportunidades: la
* afluencia ES la oportunidad en etapa 'Afluencia' (UN solo registro con todo
* el perfil). El tablero Leads queda como archivo histórico: solo se consulta
* en la búsqueda dual del picker del alta de clientes.
* Los labels de estado/origen son los reales del board (verificados contra
* Monday); la whitelist evita escribir labels que el status no tenga dados de alta.
* Monday); la whitelist evita escribir labels que el status no tenga dados de
* alta, y cada valor de la whitelist se traduce al label real del board.
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' del board Oportunidades
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado',
}
const crearLeadSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
@@ -52,7 +65,7 @@ const descartarSchema = z.object({
comentario: z.string().trim().min(1).optional(),
})
// ---------- Búsqueda de leads (prellenado del alta de clientes) ----------
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
const LIMITE_BUSQUEDA_LEADS = 15
@@ -63,70 +76,109 @@ const sinAcentos = (s: string): string =>
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
/** Resultado del picker del alta: conviven el archivo histórico (Leads) y el modelo nuevo (Oportunidades) */
export interface ResultadoBusquedaLead {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: 'lead' | 'oportunidad'
}
/**
* Filtra leads por q en nombre/teléfono/email (case-insensitive, sin acentos),
* ordena por actualización reciente y limita a 15 resultados.
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
* AMBOS tableros — Leads (histórico) y Oportunidades (modelo nuevo) —, ordena
* por actualización reciente y limita a 15 resultados combinados.
*/
export function buscarLeads(leads: LeadMonday[], q: string): LeadMonday[] {
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
const needle = sinAcentos(q)
return leads
.filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle)))
const candidatos = [
...leads.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
updatedAt: l.updatedAt,
fuente: 'lead' as const,
})),
...oportunidades.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
fuente: 'oportunidad' as const,
})),
]
return candidatos
.filter((c) => [c.nombre, c.telefono, c.email].some((campo) => sinAcentos(campo).includes(needle)))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS)
.map(({ updatedAt: _updatedAt, ...resto }) => resto)
}
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
| 'crearLead'
| 'crearOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'labelsDeColumna'
| 'obtenerNombreItem'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
>
export type ResultadoCrearLead = { id: string; oportunidadId: string | null } | { duplicado: LeadMonday }
export type ResultadoCrearLead = { id: string; oportunidadId: string } | { duplicado: OportunidadMonday }
/**
* Crea un lead en Monday (Estado NUEVO). Si viene teléfono y ya existe un lead
* con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* El origen se valida contra los labels reales de la columna del board.
* Con `funnel`, el lead entra además al tablero Oportunidades en 'Afluencia';
* un fallo de Monday en la oportunidad solo se loguea (el lead ya quedó).
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
* 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una
* oportunidad con ese número, no crea nada y devuelve { duplicado } (el
* endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status
* 'Desarrollo') se validan contra los labels reales del board.
* Tras crearla se mueve al grupo que corresponde a su etapa (Monday crea el
* item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no
* dispara en la creación); un fallo ahí solo se loguea.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
funnel?: Pick<FunnelService, 'asegurarOportunidadAfluencia'>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
const boardOportunidades = config.monday.boards.oportunidades
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: existente }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
}
}
if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearLead({
const id = await monday.crearOportunidadAfluencia({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
medioContacto: datos.medioContacto,
edad: datos.edad,
@@ -138,98 +190,67 @@ export async function crearLead(
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
let oportunidadId: string | null = null
if (funnel !== undefined) {
try {
oportunidadId = await funnel.asegurarOportunidadAfluencia({
id,
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear la oportunidad de Afluencia del lead ${id}:`, err)
}
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, id)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err)
}
return { id, oportunidadId }
return { id, oportunidadId: id }
}
/** Cambia el estado del lead (whitelist) y, en AGENDA CITA, puede setear la Fecha */
/** Cambia el estado de la afluencia (whitelist → label real del board); en AGENDA CITA puede setear la Fecha */
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
funnel?: Pick<FunnelService, 'moverOportunidadDeLead'>,
): Promise<void> {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado)
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha)
}
// La oportunidad del lead avanza a 'Agendo Cita'; un fallo no rompe el cambio
if (estado === 'AGENDA CITA' && funnel !== undefined) {
try {
await funnel.moverOportunidadDeLead(itemId, 'Agendo Cita')
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Agendo Cita' la oportunidad del lead ${itemId}:`, err)
}
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
}
}
/** Estado DESCARTADO + registro en el catálogo Motivos No Compra (+ Notas opcional) */
/**
* Estado 'Perdido' + dropdown Motivo No Compra (label ya sembrado; si no
* existiera se omite) + registro en el catálogo Motivos No Compra. El
* comentario opcional va a la columna long_text COMENTARIOS.
*/
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
funnel?: Pick<FunnelService, 'oportunidadDeLead'>,
): Promise<string> {
const boardLeads = config.monday.boards.leads
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(itemId)
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO')
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
const motivoItemId = await monday.crearMotivoNoCompra({
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
// La oportunidad del lead pasa a 'Perdido' y hereda el motivo en su dropdown
// (solo si el label existe en la columna). Fallos → solo log.
if (funnel !== undefined) {
try {
const oportunidadId = await funnel.oportunidadDeLead(itemId)
if (oportunidadId !== null) {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Estado', 'Perdido')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Perdido' la oportunidad del lead ${itemId}:`, err)
}
}
return motivoItemId
}
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body, funnelService)
const resultado = await crearLead(mondayService, body)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -239,8 +260,9 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
// no guarda quién dio de alta el lead). leadId = el id de la oportunidad
// (modelo nuevo: la afluencia ES la oportunidad). Un fallo aquí no rompe la
// respuesta: la afluencia ya quedó creada en Monday.
if (req.usuarioToken?.sub) {
try {
await prisma.leadCaptura.create({
@@ -257,10 +279,22 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
// Asignación: si el capturista tiene su usuario Monday vinculado, el lead
// queda asignado a él. La bandera rr:skip se prende ANTES de asignar para
// que el webhook create_item no lo reparta por round-robin mientras Monday
// propaga la escritura (si la asignación falla, se apaga y cae al reparto).
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
// clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
try {
await prisma.leadOportunidad.create({
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
// Asignación: si el capturista tiene su usuario Monday vinculado, la
// oportunidad queda asignada a él (columna Asesor). La bandera rr:skip se
// prende ANTES de asignar para que el webhook create_item no la reparta por
// round-robin mientras Monday propaga la escritura (si la asignación falla,
// se apaga y cae al reparto).
let asignacion: 'capturista' | 'round-robin' = 'round-robin'
if (req.usuarioToken?.sub) {
try {
@@ -280,17 +314,17 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el lead ${resultado.id} al capturista:`, err)
console.error(`No se pudo asignar la afluencia ${resultado.id} al capturista:`, err)
}
}
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId, asignacion })
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
}
/** POST /api/leads/:itemId/estado { estado, fecha? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, funnelService)
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
@@ -298,26 +332,18 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, funnelService)
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes desde un lead. q mínimo 2 caracteres; devuelve máximo 15.
* clientes. Busca en AMBOS tableros (Leads histórico + Oportunidades); q
* mínimo 2 caracteres; devuelve máximo 15, cada uno con su `fuente`.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
const encontrados = buscarLeads(await mondayService.getLeads(), q)
res.json(
encontrados.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
})),
)
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
}

View File

@@ -4,7 +4,6 @@ import type { Request, Response } from 'express'
const mocks = vi.hoisted(() => ({
runRoundRobin: vi.fn(),
escaneoOportunidades: vi.fn(),
asegurarAfluencia: vi.fn(),
moverAGrupo: vi.fn(),
}))
@@ -15,12 +14,6 @@ vi.mock('../services/automatizaciones.service.js', () => ({
},
}))
vi.mock('../services/funnel.service.js', () => ({
funnelService: {
asegurarOportunidadAfluenciaPorLeadId: mocks.asegurarAfluencia,
},
}))
vi.mock('../services/grupos-estado.service.js', () => ({
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
GRUPOS_POR_ESTADO: {
@@ -52,7 +45,6 @@ beforeEach(() => {
vi.clearAllMocks()
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
mocks.asegurarAfluencia.mockResolvedValue('op-1')
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
})
@@ -70,31 +62,34 @@ describe('webhookMonday', () => {
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
expect(mocks.asegurarAfluencia).not.toHaveBeenCalled()
})
it('create_item en Leads dispara el round-robin del item', () => {
it('create_item en Oportunidades dispara el round-robin del item', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999')
expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('999')
})
it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 111, pulseId: 777 } }), res)
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777')
expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('777')
})
it('create_item en otro board no dispara round-robin ni afluencia', () => {
it('create_item en Leads ya NO dispara nada (tablero archivo histórico)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
})
it('create_item en otro board no dispara round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.asegurarAfluencia).not.toHaveBeenCalled()
})
it('change_status_column_value en Oportunidades dispara el escaneo', () => {
@@ -148,7 +143,7 @@ describe('webhookMonday', () => {
it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 1 } }), res)
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
// Deja que el rechazo se procese para no ensuciar otros tests
await new Promise((r) => setImmediate(r))

View File

@@ -2,7 +2,6 @@ import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { funnelService } from '../services/funnel.service.js'
import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { mondayService } from '../services/monday.service.js'
@@ -46,7 +45,6 @@ export function webhookMonday(req: Request, res: Response): void {
}
const { type, boardId, itemId, pulseId } = parsed.data.event
const esBoardLeads = String(boardId) === config.monday.boards.leads
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
const idEvento = itemId ?? pulseId
@@ -55,7 +53,15 @@ export function webhookMonday(req: Request, res: Response): void {
// Monday registra el webhook como 'create_item' pero el payload real llega
// como 'create_pulse'; se aceptan ambos.
if ((type === 'create_item' || type === 'create_pulse') && esBoardLeads && idEvento !== undefined) {
// Item nuevo en Oportunidades → solo round-robin (la afluencia entra en
// etapa 'Afluencia' sin asesor; las del cotizador nacen en 'Cotizador' y el
// servicio las salta). El grupo NO se arregla aquí: recién creado el item aún
// no tiene Estado (moverAGrupoSegunEstado no haría nada) y el create con
// column_values no dispara el webhook de change — para items creados a mano
// en la UI, el grupo lo corrige la próxima vez que cambie una columna.
// (El tablero Leads quedó como archivo histórico: ya no tiene automatización
// de create; su webhook se desregistra en Monday.)
if ((type === 'create_item' || type === 'create_pulse') && esBoardOportunidades && idEvento !== undefined) {
const id = String(idEvento)
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
void automatizacionesService
@@ -68,18 +74,6 @@ export function webhookMonday(req: Request, res: Response): void {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en round-robin item=${id}:`, err)
})
// El lead entra también al funnel de Oportunidades (etapa 'Afluencia').
// El payload no trae nombre/teléfono: el servicio los consulta en Monday.
void funnelService
.asegurarOportunidadAfluenciaPorLeadId(id)
.then((opId) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] afluencia item=${id}: oportunidad ${opId ?? 'no creada'}`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en afluencia item=${id}:`, err)
})
}
// Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica.

View File

@@ -8,13 +8,13 @@ import {
claveVentaCreada,
esEstadoTerminal,
esEtapa,
leadsInactivos,
leadsSinContactar,
oportunidadesInactivas,
oportunidadesSinContactar,
parseRrUserIds,
vendedorEnTurno,
type StateStore,
} from './automatizaciones.service.js'
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
import type { OportunidadMonday, UsuarioMonday } from './monday.service.js'
// ---------- Fixtures ----------
@@ -24,27 +24,19 @@ const VENDEDORES: UsuarioMonday[] = [
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
]
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-01T09:00:00Z',
updatedAt: '2026-08-01T10:00:00Z',
estado: 'NUEVO',
origen: '',
vendedorIds: [],
...overrides,
}
}
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
nombre: `Cliente ${id}`,
createdAt: '2026-08-01T09:00:00Z',
etapa: 'Cotizador',
updatedAt: '2026-08-01T10:00:00Z',
etapa: 'Afluencia',
unidadCodigo: 'U-101',
precioFinal: 2500000,
asesorIds: [],
telefono: '',
email: '',
origen: '',
...overrides,
}
}
@@ -63,8 +55,8 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
}
}
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) {
/** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */
function mondayMock(oportunidades: OportunidadMonday[] = []) {
return {
asignaciones: [] as { itemId: string; userId: number }[],
updates: [] as { itemId: string; body: string }[],
@@ -78,13 +70,10 @@ function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []
}[],
apartados: [] as Record<string, unknown>[],
ventas: [] as Record<string, unknown>[],
async getLeads() {
return leads
},
async getOportunidades() {
return oportunidades
},
async asignarVendedorALead(itemId: string, userId: number) {
async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
async crearUpdateEnItem(itemId: string, body: string) {
@@ -136,12 +125,14 @@ describe('parseRrUserIds', () => {
})
})
describe('esEstadoTerminal', () => {
it('reconoce DESCARTADO sin importar mayúsculas ni espacios', () => {
expect(esEstadoTerminal('DESCARTADO')).toBe(true)
expect(esEstadoTerminal(' descartado ')).toBe(true)
expect(esEstadoTerminal('NUEVO')).toBe(false)
expect(esEstadoTerminal('CONTACTADO')).toBe(false)
describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => {
expect(esEstadoTerminal('GANADO')).toBe(true)
expect(esEstadoTerminal(' perdido ')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Afluencia')).toBe(false)
expect(esEstadoTerminal('Contactado')).toBe(false)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
expect(esEstadoTerminal('')).toBe(false)
})
})
@@ -160,31 +151,34 @@ describe('vendedorEnTurno (rotación round-robin)', () => {
})
})
describe('leadsInactivos', () => {
describe('oportunidadesInactivas', () => {
const ahora = new Date('2026-08-22T00:00:00Z')
it('incluye leads activos que superan el umbral de días', () => {
const leads = [
lead('1', { updatedAt: '2026-08-10T00:00:00Z' }), // 12 días → inactivo (umbral 10)
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // 2 días → activo
lead('3', { updatedAt: '2026-07-01T00:00:00Z', estado: 'DESCARTADO' }), // terminal → excluido
it('incluye oportunidades en etapa temprana CON asesor que superan el umbral de días', () => {
const ops = [
oportunidad('1', { updatedAt: '2026-08-10T00:00:00Z', asesorIds: [101] }), // 12 días → inactiva (umbral 10)
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // 2 días → activa
oportunidad('3', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Perdido' }), // terminal → excluida
oportunidad('4', { updatedAt: '2026-07-01T00:00:00Z' }), // sin asesor → excluida
oportunidad('5', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Cotizador' }), // etapa avanzada → excluida
oportunidad('6', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101], etapa: 'Contactado' }), // 21 días → inactiva
]
const inactivos = leadsInactivos(leads, ahora, 10)
expect(inactivos.map((l) => l.id)).toEqual(['1'])
const inactivas = oportunidadesInactivas(ops, ahora, 10)
expect(inactivas.map((o) => o.id)).toEqual(['1', '6'])
})
it('un lead actualizado hace exactamente N días aún no supera el umbral', () => {
const leads = [lead('1', { updatedAt: '2026-08-12T00:00:00Z' })] // justo 10 días
expect(leadsInactivos(leads, ahora, 10)).toEqual([])
it('una oportunidad actualizada hace exactamente N días aún no supera el umbral', () => {
const ops = [oportunidad('1', { updatedAt: '2026-08-12T00:00:00Z', asesorIds: [101] })] // justo 10 días
expect(oportunidadesInactivas(ops, ahora, 10)).toEqual([])
})
it('ignora fechas inválidas', () => {
expect(leadsInactivos([lead('1', { updatedAt: 'no-es-fecha' })], ahora, 10)).toEqual([])
expect(oportunidadesInactivas([oportunidad('1', { updatedAt: 'no-es-fecha', asesorIds: [101] })], ahora, 10)).toEqual([])
})
})
describe('claveRecordatorio (dedup por día)', () => {
it('una clave por lead por día', () => {
it('una clave por oportunidad por día', () => {
expect(claveRecordatorio('123', new Date('2026-08-22T09:00:00Z'))).toBe('recordatorio:123:2026-08-22')
expect(claveRecordatorio('123', new Date('2026-08-22T23:59:00Z'))).toBe('recordatorio:123:2026-08-22')
expect(claveRecordatorio('123', new Date('2026-08-23T00:00:00Z'))).toBe('recordatorio:123:2026-08-23')
@@ -194,8 +188,8 @@ describe('claveRecordatorio (dedup por día)', () => {
// ---------- Servicio (monday mockeado, sin red) ----------
describe('AutomatizacionesService.runRoundRobin', () => {
it('asigna vendedores en rotación a leads sin persona y persiste el índice', async () => {
const monday = mondayMock([lead('1'), lead('2'), lead('3'), lead('4')])
it('asigna asesores en rotación a afluencias sin persona y persiste el índice', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2'), oportunidad('3'), oportunidad('4')])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -204,8 +198,8 @@ describe('AutomatizacionesService.runRoundRobin', () => {
expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
// Segunda pasada con un lead nuevo: continúa donde quedó (no reinicia en 0)
const monday2 = mondayMock([lead('5')])
// Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
const monday2 = mondayMock([oportunidad('5')])
const svc2 = new AutomatizacionesService(monday2, state)
const r2 = await svc2.runRoundRobin()
expect(r2.asignados).toBe(1)
@@ -213,22 +207,28 @@ describe('AutomatizacionesService.runRoundRobin', () => {
expect(state.mapa.get('rr:indice')).toBe('2')
})
it('omite leads con vendedor ya asignado o en estado terminal', async () => {
it('solo reparte etapa Afluencia: Cotizador/Contactado NO entran al reparto', async () => {
const monday = mondayMock([
lead('1'),
lead('2', { vendedorIds: [101] }), // ya asignado
lead('3', { estado: 'DESCARTADO' }), // terminal
oportunidad('1'),
oportunidad('2', { asesorIds: [101] }), // ya asignada
oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra
oportunidad('4', { etapa: 'Contactado' }), // ya en seguimiento: no entra
oportunidad('5', { etapa: 'Perdido' }), // terminal: no entra
oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1)
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
expect(r.asignados).toBe(2)
expect(monday.asignaciones).toEqual([
{ itemId: '1', userId: 101 },
{ itemId: '6', userId: 102 },
])
})
it('omite leads con bandera rr:skip=capturista (los asigna su capturista)', async () => {
const monday = mondayMock([lead('1'), lead('2')])
it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const state = stateEnMemoria()
state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna
state.mapa.set('rr:skip:1', 'capturista') // el capturista la asigna
state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
const svc = crearServicio(monday, state)
const r = await svc.runRoundRobin()
@@ -237,7 +237,7 @@ describe('AutomatizacionesService.runRoundRobin', () => {
})
it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => {
const monday = mondayMock([lead('1'), lead('2')])
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin('2')
expect(r.asignados).toBe(1)
@@ -271,13 +271,13 @@ describe('AutomatizacionesService.runRoundRobin', () => {
describe('AutomatizacionesService.escaneoInactividad', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
const leadsViejos = () => [
lead('1', { updatedAt: '2026-08-01T00:00:00Z' }), // 21 días inactivo
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // activo
const oportunidadesViejas = () => [
oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] }), // 21 días inactiva
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // activa
]
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
const monday = mondayMock(leadsViejos())
const monday = mondayMock(oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -291,7 +291,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
})
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
const monday = mondayMock(leadsViejos())
const monday = mondayMock(oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -301,7 +301,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
expect(monday.updates[0]?.body).toContain('21 días')
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined()
// Mismo día: no repite aunque el lead siga inactivo
// Mismo día: no repite aunque la oportunidad siga inactiva
const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
expect(r2.recordatoriosEnviados).toBe(0)
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
@@ -319,15 +319,15 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
it('el round-robin crea "Contactar a {lead}" tipo Llamada, Abierto, ligada al lead', async () => {
const monday = mondayMock([lead('1'), lead('2')])
it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(2)
expect(monday.actividades).toHaveLength(2)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead 1',
nombre: 'Contactar a Cliente 1',
tipoLabel: 'Llamada',
responsableId: 101,
estadoLabel: 'Abierto',
@@ -343,24 +343,24 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
const monday = mondayMock([lead('1')])
const monday = mondayMock([oportunidad('1')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.runRoundRobin()
expect(monday.actividades).toEqual([])
})
it('asignarACapturista: asigna al capturista y le crea su actividad de contacto', async () => {
it('asignarACapturista: asigna al capturista (columna Asesor) y le crea su actividad de contacto', async () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.asignarACapturista('lead-77', 'Lead Propio', 555)
await svc.asignarACapturista('op-77', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555 }])
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555 }])
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead Propio',
responsableId: 555,
estadoLabel: 'Abierto',
leadItemId: 'lead-77',
leadItemId: 'op-77',
})
})
@@ -368,24 +368,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.asignarACapturista('lead-78', 'Lead Propio', 555)
await svc.asignarACapturista('op-78', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'lead-78', userId: 555 }])
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555 }])
expect(monday.actividades).toEqual([])
})
it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => {
const monday = mondayMock([
lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor
lead('2', { updatedAt: '2026-08-01T00:00:00Z' }), // inactivo sin vendedor
])
it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => {
const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoInactividad({ preview: false, ahora })
expect(r.recordatoriosEnviados).toBe(2)
expect(r.recordatoriosEnviados).toBe(1)
expect(monday.actividades).toHaveLength(1)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Retomar contacto con Lead 1',
nombre: 'Retomar contacto con Cliente 1',
tipoLabel: 'Llamada',
responsableId: 103,
estadoLabel: 'Abierto',
@@ -394,14 +391,14 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
it('preview de inactividad no crea actividades', async () => {
const monday = mondayMock([lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [101] })])
const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.escaneoInactividad({ preview: true, ahora })
expect(monday.actividades).toEqual([])
})
it('un fallo al crear la actividad no rompe la asignación', async () => {
const monday = mondayMock([lead('1')])
const monday = mondayMock([oportunidad('1')])
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
@@ -413,43 +410,35 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
})
describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
it('ABANDONO también es terminal', () => {
expect(esEstadoTerminal('ABANDONO')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
})
})
// ---------- Escalado de afluencias sin contactar ----------
// ---------- Ronda 2: escalado de leads sin contactar ----------
describe('leadsSinContactar (regla de escalado por horas)', () => {
describe('oportunidadesSinContactar (regla de escalado por horas)', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
it('incluye leads asignados en NUEVO / Intento de contacto que superan el umbral', () => {
const leads = [
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
lead('2', { vendedorIds: [102], estado: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
lead('3', { vendedorIds: [103], estado: 'CONTACTADO', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactado
lead('4', { estado: 'NUEVO', updatedAt: '2026-08-01T12:00:00Z' }), // sin vendedor → no aplica
lead('5', { vendedorIds: [101], estado: 'DESCARTADO', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
it('incluye oportunidades asignadas en Afluencia / Intento de contacto que superan el umbral', () => {
const ops = [
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
oportunidad('2', { asesorIds: [102], etapa: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
oportunidad('3', { asesorIds: [103], etapa: 'Contactado', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactada
oportunidad('4', { etapa: 'Afluencia', updatedAt: '2026-08-01T12:00:00Z' }), // sin asesor → no aplica
oportunidad('5', { asesorIds: [101], etapa: 'Perdido', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
]
expect(leadsSinContactar(leads, ahora, 48).map((l) => l.id)).toEqual(['1'])
expect(oportunidadesSinContactar(ops, ahora, 48).map((o) => o.id)).toEqual(['1'])
})
it('un lead con exactamente N horas aún no supera el umbral', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
it('una oportunidad con exactamente N horas aún no supera el umbral', () => {
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
})
it('ignora fechas inválidas', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })]
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
})
})
describe('claves de dedup de ronda 2', () => {
it('una clave por lead/oportunidad', () => {
describe('claves de dedup', () => {
it('una clave por oportunidad', () => {
expect(claveEscalado('123')).toBe('escalado:123')
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
expect(claveVentaCreada('456')).toBe('venta-creada:456')
@@ -469,11 +458,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
const configAuto = config.automatizaciones as Record<string, unknown>
it('escala al vendedor asignado cuando no hay coordinador configurado', async () => {
it('escala al asesor asignado cuando no hay coordinador configurado', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
@@ -481,7 +470,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
expect(r.escalados).toBe(1)
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
expect(monday.actividades[0]).toMatchObject({
nombre: 'URGENTE: lead sin contactar — Lead 1',
nombre: 'URGENTE: oportunidad sin contactar — Cliente 1',
responsableId: 102,
estadoLabel: 'Abierto',
leadItemId: '1',
@@ -498,7 +487,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 999
try {
const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1)
@@ -509,11 +498,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
}
})
it('dedup: no vuelve a escalar un lead ya escalado', async () => {
it('dedup: no vuelve a escalar una oportunidad ya escalada', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
const monday = mondayMock([oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
@@ -529,14 +518,14 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
}
})
it('un fallo de Monday en un lead no detiene el escalado de los demás', async () => {
it('un fallo de Monday en una oportunidad no detiene el escalado de las demás', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const monday = mondayMock([
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
lead('2', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
])
vi.spyOn(monday, 'crearUpdateEnItem')
.mockRejectedValueOnce(new Error('Monday caído'))
@@ -544,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1) // el segundo lead sí se escaló
expect(r.escalados).toBe(1) // la segunda oportunidad sí se escaló
expect(r.candidatos).toHaveLength(2)
} finally {
configAuto['coordinadorUserId'] = anterior
@@ -553,11 +542,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
})
})
// ---------- Ronda 2: Oportunidad → Aparto / Ganado ----------
// ---------- Oportunidad → Aparto / Ganado ----------
describe('AutomatizacionesService.escaneoOportunidades', () => {
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -573,7 +562,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
const monday = mondayMock([oportunidad('11', { etapa: 'GANADO' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -584,7 +573,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -599,7 +588,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('ignora oportunidades en otras etapas', async () => {
const monday = mondayMock([], [
const monday = mondayMock([
oportunidad('10', { etapa: 'Cotizador' }),
oportunidad('11', { etapa: 'Perdido' }),
oportunidad('12', { etapa: 'Afluencia' }),
@@ -613,7 +602,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('un fallo de Monday en una oportunidad no detiene a las demás', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
const state = stateEnMemoria()
const svc = crearServicio(monday, state)

View File

@@ -1,26 +1,32 @@
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js'
import { mondayService, type LeadMonday, type MondayService, type UsuarioMonday } from './monday.service.js'
import { mondayService, type MondayService, type OportunidadMonday, type UsuarioMonday } from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Automatizaciones sobre el tablero Leads de Monday:
* (a) asignación round-robin de vendedores a leads sin persona asignada
* (b) recordatorio de leads inactivos (> INACTIVIDAD_DIAS sin actualizarse)
* Automatizaciones sobre el tablero Oportunidades de Monday (la afluencia ES
* la oportunidad en etapa 'Afluencia'):
* (a) asignación round-robin de asesores a afluencias sin persona asignada
* (b) recordatorio de oportunidades inactivas (> INACTIVIDAD_DIAS sin actualizarse)
* (c) escalado de afluencias asignadas sin contactar
* (d) Oportunidad → Apartado / Ventas al entrar a 'Aparto' / 'Ganado'
*
* El estado (índice RR, cache de vendedores, recordatorios enviados) vive en la
* tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir
* recordatorios el mismo día.
*/
// Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO
// más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado,
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
// Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo
// activo del asesor (fuera de seguimiento).
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const
// Estados en los que un lead asignado aún no ha sido contactado de verdad;
// si permanece así más de LEAD_ESCALADO_HORAS horas se escala al coordinador.
export const ESTADOS_SIN_CONTACTAR = ['NUEVO', 'INTENTO DE CONTACTO'] as const
// Etapas tempranas que vigilan los escaneos de inactividad/escalado
export const ETAPAS_SEGUIMIENTO = ['AFLUENCIA', 'CONTACTADO', 'INTENTO DE CONTACTO'] as const
// Etapas en las que una oportunidad asignada aún no ha sido contactada de
// verdad; si permanece así más de LEAD_ESCALADO_HORAS horas se escala al
// coordinador.
export const ETAPAS_SIN_CONTACTAR = ['AFLUENCIA', 'INTENTO DE CONTACTO'] as const
// Etapas del tablero Oportunidades que disparan las reglas de ronda 2
export const ETAPA_APARTO = 'APARTO'
@@ -66,7 +72,7 @@ export function parseRrUserIds(raw: string): UsuarioMonday[] {
})
}
export function esEstadoTerminal(estado: string, terminales: readonly string[] = ESTADOS_TERMINALES_LEAD): boolean {
export function esEstadoTerminal(estado: string, terminales: readonly string[] = ETAPAS_TERMINALES_OPORTUNIDAD): boolean {
const normalizado = estado.trim().toUpperCase()
return terminales.some((t) => t.toUpperCase() === normalizado)
}
@@ -78,40 +84,50 @@ export function vendedorEnTurno(vendedores: readonly UsuarioMonday[], indice: nu
}
/**
* Leads activos (estado no terminal) cuya última actualización supera
* Oportunidades en etapa temprana (Afluencia / Contactado / Intento de
* contacto) CON asesor asignado cuya última actualización supera
* `diasInactividad` días respecto a `ahora`.
*/
export function leadsInactivos(leads: readonly LeadMonday[], ahora: Date, diasInactividad: number): LeadMonday[] {
export function oportunidadesInactivas(
oportunidades: readonly OportunidadMonday[],
ahora: Date,
diasInactividad: number,
): OportunidadMonday[] {
const limiteMs = diasInactividad * DIA_MS
return leads.filter((lead) => {
if (esEstadoTerminal(lead.estado)) return false
const t = Date.parse(lead.updatedAt)
return oportunidades.filter((op) => {
if (op.asesorIds.length === 0) return false
if (!ETAPAS_SEGUIMIENTO.some((e) => e === op.etapa.trim().toUpperCase())) return false
const t = Date.parse(op.updatedAt)
if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs
})
}
/** Clave de dedup: un recordatorio por lead por día */
/** Clave de dedup: un recordatorio por oportunidad por día */
export function claveRecordatorio(itemId: string, fecha: Date): string {
return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}`
}
export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS)
export function diasSinActividad(op: Pick<OportunidadMonday, 'updatedAt'>, ahora: Date): number {
return Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / DIA_MS)
}
/**
* Leads ASIGNADOS (con vendedor) que siguen en estado NUEVO / Intento de contacto
* y llevan más de `horasUmbral` horas sin actualizarse: candidatos a escalar.
* (El round-robin actualiza el item al asignar, así que el reloj arranca ahí.)
* Oportunidades ASIGNADAS (con asesor) que siguen en Afluencia /
* Intento de contacto y llevan más de `horasUmbral` horas sin actualizarse:
* candidatas a escalar. (El round-robin actualiza el item al asignar, así que
* el reloj arranca ahí.)
*/
export function leadsSinContactar(leads: readonly LeadMonday[], ahora: Date, horasUmbral: number): LeadMonday[] {
export function oportunidadesSinContactar(
oportunidades: readonly OportunidadMonday[],
ahora: Date,
horasUmbral: number,
): OportunidadMonday[] {
const limiteMs = horasUmbral * HORA_MS
return leads.filter((lead) => {
if (lead.vendedorIds.length === 0) return false
if (esEstadoTerminal(lead.estado)) return false
if (!ESTADOS_SIN_CONTACTAR.some((e) => e === lead.estado.trim().toUpperCase())) return false
const t = Date.parse(lead.updatedAt)
return oportunidades.filter((op) => {
if (op.asesorIds.length === 0) return false
if (!ETAPAS_SIN_CONTACTAR.some((e) => e === op.etapa.trim().toUpperCase())) return false
const t = Date.parse(op.updatedAt)
if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs
})
@@ -141,9 +157,8 @@ export function esEtapa(etapa: string, esperada: string): boolean {
type MondayLeads = Pick<
MondayService,
| 'getLeads'
| 'getOportunidades'
| 'asignarVendedorALead'
| 'asignarAsesorAOportunidad'
| 'crearUpdateEnItem'
| 'getUsuariosEquipo'
| 'crearActividad'
@@ -279,24 +294,25 @@ export class AutomatizacionesService {
}
/**
* Una pasada de round-robin: asigna vendedor a todo lead sin persona asignada
* y con estado no terminal. Si se pasa `soloItemId`, solo se evalúa ese item
* (ruta del webhook create_item).
* Una pasada de round-robin: asigna asesor a toda oportunidad en etapa
* 'Afluencia' sin persona asignada (las creadas por el cotizador nacen en
* 'Cotizador' y NO entran al reparto). Si se pasa `soloItemId`, solo se
* evalúa ese item (ruta del webhook create_item).
*/
async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
const leads = await this.monday.getLeads()
const sinAsignar = leads.filter(
(l) =>
l.vendedorIds.length === 0 &&
!esEstadoTerminal(l.estado) &&
(soloItemId === undefined || l.id === soloItemId),
const oportunidades = await this.monday.getOportunidades()
const sinAsignar = oportunidades.filter(
(o) =>
o.asesorIds.length === 0 &&
o.etapa.trim().toUpperCase() === 'AFLUENCIA' &&
(soloItemId === undefined || o.id === soloItemId),
)
// Los leads capturados desde una app con sesión se asignan a su capturista
// (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday
// hacía que el webhook los viera sin vendedor y pisara la asignación.
// Las afluencias capturadas desde una app con sesión se asignan a su
// capturista (bandera rr:skip='capturista'): sin este filtro, la latencia
// de Monday hacía que el webhook las viera sin asesor y pisara la asignación.
const pendientes: typeof sinAsignar = []
for (const l of sinAsignar) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${l.id}`)) !== 'capturista') pendientes.push(l)
for (const o of sinAsignar) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o)
}
if (pendientes.length === 0) {
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
@@ -308,18 +324,18 @@ export class AutomatizacionesService {
if (!Number.isInteger(indice) || indice < 0) indice = 0
const detalle: AsignacionRR[] = []
for (const lead of pendientes) {
for (const op of pendientes) {
const vendedor = vendedorEnTurno(vendedores, indice)
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
await this.monday.asignarVendedorALead(lead.id, vendedor.id)
await this.monday.asignarAsesorAOportunidad(op.id, vendedor.id)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${lead.nombre}`,
nombre: `Contactar a ${op.nombre}`,
responsableId: vendedor.id,
leadItemId: lead.id,
leadItemId: op.id,
})
}
detalle.push({ itemId: lead.id, lead: lead.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
detalle.push({ itemId: op.id, lead: op.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
indice++
}
@@ -329,18 +345,19 @@ export class AutomatizacionesService {
}
/**
* Asigna el lead a quien lo capturó desde una app del ecosistema (su usuario
* Monday vinculado) y le crea la actividad de contacto — el mismo paquete
* que haría el round-robin, pero para el capturista. No hay carrera con el
* webhook create_item: runRoundRobin solo toma leads sin persona asignada.
* Asigna la afluencia a quien la capturó desde una app del ecosistema (su
* usuario Monday vinculado, columna Asesor de Oportunidades) y le crea la
* actividad de contacto — el mismo paquete que haría el round-robin, pero
* para el capturista. No hay carrera con el webhook create_item:
* runRoundRobin solo toma oportunidades sin persona asignada.
*/
async asignarACapturista(leadId: string, leadNombre: string, vendedorId: number): Promise<void> {
await this.monday.asignarVendedorALead(leadId, vendedorId)
async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise<void> {
await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${leadNombre}`,
nombre: `Contactar a ${nombre}`,
responsableId: vendedorId,
leadItemId: leadId,
leadItemId: oportunidadId,
})
}
}
@@ -352,38 +369,38 @@ export class AutomatizacionesService {
async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
const ahora = opciones.ahora ?? new Date()
const diasUmbral = config.automatizaciones.inactividadDias
const leads = await this.monday.getLeads()
const inactivos = leadsInactivos(leads, ahora, diasUmbral)
const oportunidades = await this.monday.getOportunidades()
const inactivas = oportunidadesInactivas(oportunidades, ahora, diasUmbral)
const candidatos: CandidatoInactividad[] = []
let recordatoriosEnviados = 0
for (const lead of inactivos) {
const clave = claveRecordatorio(lead.id, ahora)
for (const op of inactivas) {
const clave = claveRecordatorio(op.id, ahora)
const yaEnviado = (await this.state.get(clave)) !== null
const dias = diasSinActividad(lead, ahora)
const dias = diasSinActividad(op, ahora)
candidatos.push({
itemId: lead.id,
lead: lead.nombre,
estado: lead.estado,
updatedAt: lead.updatedAt,
itemId: op.id,
lead: op.nombre,
estado: op.etapa,
updatedAt: op.updatedAt,
diasInactivo: dias,
yaRecordadoHoy: yaEnviado,
})
if (opciones.preview || yaEnviado) continue
const body =
`⚠️ Recordatorio automático: este lead lleva ${dias} días sin actualizarse ` +
`(última actividad: ${lead.updatedAt.slice(0, 10)}). ` +
`Favor de darle seguimiento o marcarlo como DESCARTADO.`
await this.monday.crearUpdateEnItem(lead.id, body)
`⚠️ Recordatorio automático: esta oportunidad lleva ${dias} días sin actualizarse ` +
`(última actividad: ${op.updatedAt.slice(0, 10)}). ` +
`Favor de darle seguimiento o marcarla como Perdido.`
await this.monday.crearUpdateEnItem(op.id, body)
await this.state.set(clave, ahora.toISOString())
recordatoriosEnviados++
// Además del update, deja una actividad al vendedor asignado (si tiene)
const vendedorId = lead.vendedorIds[0]
if (this.actividadesEnabled && vendedorId !== undefined) {
// Además del update, deja una actividad al asesor asignado (si tiene)
const asesorId = op.asesorIds[0]
if (this.actividadesEnabled && asesorId !== undefined) {
await this.crearActividadSegura({
nombre: `Retomar contacto con ${lead.nombre}`,
responsableId: vendedorId,
leadItemId: lead.id,
nombre: `Retomar contacto con ${op.nombre}`,
responsableId: asesorId,
leadItemId: op.id,
})
}
}
@@ -399,36 +416,36 @@ export class AutomatizacionesService {
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
}
/** Responsable del escalado: coordinador (env) o, si no hay, el vendedor asignado */
private responsableEscalado(lead: LeadMonday): number | null {
/** Responsable del escalado: coordinador (env) o, si no hay, el asesor asignado */
private responsableEscalado(op: OportunidadMonday): number | null {
const coordinador = config.automatizaciones.coordinadorUserId
if (Number.isInteger(coordinador) && coordinador > 0) return coordinador
return lead.vendedorIds[0] ?? null
return op.asesorIds[0] ?? null
}
/**
* Escalado de leads sin contactar: un lead asignado que sigue en
* NUEVO / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
* actividad URGENTE al coordinador (o al vendedor) + update en el item.
* Un escalado por lead (dedup `escalado:<itemId>`); un fallo de Monday en
* un lead no detiene a los demás.
* Escalado de afluencias sin contactar: una oportunidad asignada que sigue en
* Afluencia / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
* actividad URGENTE al coordinador (o al asesor) + update en el item.
* Un escalado por oportunidad (dedup `escalado:<itemId>`); un fallo de Monday
* en una oportunidad no detiene a las demás.
*/
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
const ahora = opciones.ahora ?? new Date()
const horasUmbral = config.automatizaciones.escaladoHoras
const leads = await this.monday.getLeads()
const pendientes = leadsSinContactar(leads, ahora, horasUmbral)
const oportunidades = await this.monday.getOportunidades()
const pendientes = oportunidadesSinContactar(oportunidades, ahora, horasUmbral)
const candidatos: CandidatoEscalado[] = []
let escalados = 0
for (const lead of pendientes) {
const responsableId = this.responsableEscalado(lead)
const horas = Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / HORA_MS)
const yaEscalado = (await this.state.get(claveEscalado(lead.id))) !== null
for (const op of pendientes) {
const responsableId = this.responsableEscalado(op)
const horas = Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / HORA_MS)
const yaEscalado = (await this.state.get(claveEscalado(op.id))) !== null
candidatos.push({
itemId: lead.id,
lead: lead.nombre,
estado: lead.estado,
itemId: op.id,
lead: op.nombre,
estado: op.etapa,
horasSinContactar: horas,
responsableId,
yaEscalado,
@@ -437,24 +454,24 @@ export class AutomatizacionesService {
try {
if (this.actividadesEnabled && responsableId !== null) {
await this.monday.crearActividad({
nombre: `URGENTE: lead sin contactar — ${lead.nombre}`,
nombre: `URGENTE: oportunidad sin contactar — ${op.nombre}`,
tipoLabel: 'Llamada',
responsableId,
fecha: AutomatizacionesService.fechaHoyMas(0),
estadoLabel: 'Abierto',
leadItemId: lead.id,
leadItemId: op.id,
})
}
await this.monday.crearUpdateEnItem(
lead.id,
`🚨 Escalado automático: el lead lleva ${horas} h asignado en estado "${lead.estado}" sin contacto. ` +
`Se notificó a ${responsableId !== null && responsableId !== lead.vendedorIds[0] ? 'la coordinación' : 'el vendedor asignado'}.`,
op.id,
`🚨 Escalado automático: la oportunidad lleva ${horas} h asignada en etapa "${op.etapa}" sin contacto. ` +
`Se notificó a ${responsableId !== null && responsableId !== op.asesorIds[0] ? 'la coordinación' : 'el asesor asignado'}.`,
)
await this.state.set(claveEscalado(lead.id), ahora.toISOString())
await this.state.set(claveEscalado(op.id), ahora.toISOString())
escalados++
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[escalado] falló el escalado del lead ${lead.id} (${lead.nombre}):`, err)
console.error(`[escalado] falló el escalado de la oportunidad ${op.id} (${op.nombre}):`, err)
}
}

View File

@@ -1,40 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { FunnelService, type LeadOportunidadStore } from './funnel.service.js'
import type { LeadMonday } from './monday.service.js'
import type { LeadMonday, OportunidadMonday } from './monday.service.js'
/** Monday mockeado: registra oportunidades creadas, cambios de columna y movimientos de grupo */
function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: LeadMonday | null } = {}) {
/** Monday mockeado: registra cambios de columna y devuelve búsquedas controladas */
function mondayMock(
opts: { leadPorTelefono?: LeadMonday | null; oportunidadPorTelefono?: OportunidadMonday | null } = {},
) {
return {
oportunidadesCreadas: [] as Record<string, unknown>[],
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientosGrupo: [] as { itemId: string; groupId: string }[],
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return `op-${this.oportunidadesCreadas.length}`
},
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async buscarLeadPorTelefono(_telefono: string) {
return opts.leadPorTelefono ?? null
},
async getLeadPorId(_itemId: string) {
return opts.leadPorId ?? null
},
// La oportunidad recién creada nace en el grupo top del tablero (como en Monday real)
async columnaIdPorTitulo(_boardId: string, _titulo: string) {
return 'status_col'
},
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Op Prueba',
groupId: 'group_top',
column_values: [{ id: 'status_col', text: 'Afluencia', value: null }],
}
},
async moverItemAGrupo(itemId: string, groupId: string) {
this.movimientosGrupo.push({ itemId, groupId })
async buscarOportunidadPorTelefono(_telefono: string) {
return opts.oportunidadPorTelefono ?? null
},
}
}
@@ -47,14 +28,18 @@ function storeMock(): LeadOportunidadStore & { mapping: Map<string, string> } {
async buscarPorLeadId(leadId: string) {
return mapping.get(leadId) ?? null
},
async buscarPorOportunidadId(oportunidadId: string) {
for (const opId of mapping.values()) {
if (opId === oportunidadId) return opId
}
return null
},
async guardar(leadId: string, oportunidadId: string) {
mapping.set(leadId, oportunidadId)
},
}
}
const leadBase = { id: 'lead-1', nombre: 'Lead Prueba', telefono: '4771234567', desarrolloLabel: 'SENDA' }
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
@@ -68,47 +53,19 @@ const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
...overrides,
})
describe('FunnelService.asegurarOportunidadAfluencia', () => {
it('crea la oportunidad en Monday y guarda el mapping lead ↔ oportunidad', async () => {
const monday = mondayMock()
const store = storeMock()
const funnel = new FunnelService(monday, store)
const opId = await funnel.asegurarOportunidadAfluencia(leadBase)
expect(opId).toBe('op-1')
expect(monday.oportunidadesCreadas).toEqual([
{ nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, desarrolloLabel: 'SENDA' },
])
expect(store.mapping.get('lead-1')).toBe('op-1')
// Y la mueve al grupo que corresponde a 'Afluencia' (nace en el grupo top del tablero)
expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-1', groupId: 'topics' }])
})
it('es idempotente: con mapping existente no crea una segunda oportunidad', async () => {
const monday = mondayMock()
const store = storeMock()
store.mapping.set('lead-1', 'op-vieja')
const funnel = new FunnelService(monday, store)
const opId = await funnel.asegurarOportunidadAfluencia(leadBase)
expect(opId).toBe('op-vieja')
expect(monday.oportunidadesCreadas).toEqual([])
})
it('por leadId (webhook): consulta el lead en Monday y crea la oportunidad', async () => {
const monday = mondayMock({ leadPorId: leadMonday({ telefono: '4771234567', email: 'a@b.com' }) })
const store = storeMock()
const funnel = new FunnelService(monday, store)
const opId = await funnel.asegurarOportunidadAfluenciaPorLeadId('lead-1')
expect(opId).toBe('op-1')
expect(monday.oportunidadesCreadas[0]).toMatchObject({ nombre: 'Lead Prueba', telefono: '4771234567' })
expect(store.mapping.get('lead-1')).toBe('op-1')
})
it('por leadId inexistente en Monday no crea nada (null)', async () => {
const monday = mondayMock({ leadPorId: null })
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.asegurarOportunidadAfluenciaPorLeadId('fantasma')).resolves.toBeNull()
expect(monday.oportunidadesCreadas).toEqual([])
})
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
id: 'op-1',
nombre: 'Cliente Prueba',
createdAt: '',
updatedAt: '',
etapa: 'Afluencia',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '4771234567',
email: '',
origen: '',
...overrides,
})
describe('FunnelService.moverOportunidadDeLead', () => {
@@ -130,7 +87,7 @@ describe('FunnelService.moverOportunidadDeLead', () => {
})
describe('FunnelService.oportunidadPorTelefono', () => {
it('resuelve lead por teléfono y devuelve la oportunidad mapeada', async () => {
it('resuelve lead histórico por teléfono y devuelve la oportunidad mapeada', async () => {
const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'lead-7' }) })
const store = storeMock()
store.mapping.set('lead-7', 'op-7')
@@ -138,11 +95,24 @@ describe('FunnelService.oportunidadPorTelefono', () => {
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7')
})
it('sin lead con ese teléfono (o sin mapping) devuelve null', async () => {
it('sin lead histórico, busca el teléfono directo en Oportunidades (modelo nuevo)', async () => {
const monday = mondayMock({ oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }) })
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa')
})
it('lead histórico sin mapping cae a la búsqueda directa en Oportunidades', async () => {
const monday = mondayMock({
leadPorTelefono: leadMonday({ id: 'sin-mapping' }),
oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }),
})
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa')
})
it('sin lead ni oportunidad con ese teléfono devuelve null', async () => {
const funnel = new FunnelService(mondayMock(), storeMock())
await expect(funnel.oportunidadPorTelefono('4770000000')).resolves.toBeNull()
const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'sin-mapping' }) })
await expect(new FunnelService(monday, storeMock()).oportunidadPorTelefono('4771234567')).resolves.toBeNull()
})
})
@@ -171,11 +141,39 @@ describe('FunnelService.moverAApartoConDedup', () => {
expect(orden).toEqual(['dedup:apartado-creado:op-1', 'monday:Aparto'])
})
it('con id de oportunidad directa (modelo nuevo) busca por oportunidadId y mueve ESA', async () => {
const monday = mondayMock()
const store = storeMock()
// Auto-mapping de la captura nueva: leadId = oportunidadId
store.mapping.set('op-77', 'op-77')
const funnel = new FunnelService(monday, store)
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
const opId = await funnel.moverAApartoConDedup('op-77', state)
expect(opId).toBe('op-77')
expect(state.set).toHaveBeenCalledWith('apartado-creado:op-77', expect.any(String))
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-77', columna: 'Estado', valor: 'Aparto' }])
})
it('un id que es a la vez lead de otro mapping y oportunidad: gana la oportunidad directa', async () => {
const monday = mondayMock()
const store = storeMock()
store.mapping.set('lead-1', 'op-1') // liga histórica cuya oportunidad es op-1
store.mapping.set('op-1', 'op-1') // auto-mapping de op-1 (modelo nuevo)
const funnel = new FunnelService(monday, store)
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
// 'op-1' como entrada: se resuelve como oportunidad directa
await expect(funnel.moverAApartoConDedup('op-1', state)).resolves.toBe('op-1')
// 'lead-1' como entrada: se resuelve vía la liga histórica
await expect(funnel.moverAApartoConDedup('lead-1', state)).resolves.toBe('op-1')
})
it('sin mapping no escribe dedup ni mueve nada (null)', async () => {
const monday = mondayMock()
const funnel = new FunnelService(monday, storeMock())
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
await expect(funnel.moverAApartoConDedup('lead-sin-op', state)).resolves.toBeNull()
await expect(funnel.moverAApartoConDedup('sin-mapping', state)).resolves.toBeNull()
expect(state.set).not.toHaveBeenCalled()
expect(monday.cambios).toEqual([])
})

View File

@@ -1,44 +1,33 @@
import { config } from '../config/index.js'
import { claveApartadoCreado, type StateStore } from './automatizaciones.service.js'
import { moverAGrupoSegunEstado } from './grupos-estado.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Funnel comercial Leads → Oportunidades:
* todo lead capturado entra al tablero Oportunidades en etapa 'Afluencia' y la
* oportunidad avanza de etapa sola conforme avanza el proceso (Agendo Cita,
* Cotizador, Aparto, Ganado / Perdido).
* Funnel comercial sobre el tablero Oportunidades:
* la captura de afluencia crea la oportunidad directamente en etapa
* 'Afluencia' (ver leads.controller), y la oportunidad avanza de etapa sola
* conforme avanza el proceso (Agendo Cita, Cotizador, Aparto, Ganado /
* Perdido).
*
* La vinculación lead ↔ oportunidad vive en la tabla LeadOportunidad (dedup
* persistente: una oportunidad por lead), inyectada como store para testear.
* La tabla LeadOportunidad (mapping lead ↔ oportunidad) queda para las ligas
* históricas (leads del tablero Leads de agosto) y como auto-mapping
* (leadId = oportunidadId) de las afluencias nuevas, para que el alta de
* clientes encuentre la oportunidad a mover a 'Aparto' en ambos esquemas.
* Los errores de Monday se propagan al caller, que decide loguear y continuar
* (la acción principal — crear el lead, la cotización o el alta — no se rompe).
* (la acción principal — la cotización o el alta — no se rompe).
*/
export interface LeadFunnel {
id: string // item ID del lead en Monday
nombre: string
telefono?: string | undefined
email?: string | undefined
desarrolloLabel?: string | undefined
}
/** Store del mapping lead ↔ oportunidad (Prisma en prod, memoria en tests) */
export interface LeadOportunidadStore {
buscarPorLeadId(leadId: string): Promise<string | null> // oportunidadId
buscarPorOportunidadId(oportunidadId: string): Promise<string | null> // oportunidadId si hay mapping
guardar(leadId: string, oportunidadId: string): Promise<void>
}
type MondayFunnel = Pick<
MondayService,
| 'crearOportunidadAfluencia'
| 'cambiarValorColumna'
| 'buscarLeadPorTelefono'
| 'getLeadPorId'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono'
>
export class FunnelService {
@@ -47,49 +36,6 @@ export class FunnelService {
private readonly store: LeadOportunidadStore,
) {}
/**
* Garantiza que el lead tenga su oportunidad en 'Afluencia': si ya existe el
* mapping devuelve la oportunidad existente; si no, la crea en Monday y
* guarda el mapping. Idempotente.
*/
async asegurarOportunidadAfluencia(lead: LeadFunnel): Promise<string> {
const existente = await this.store.buscarPorLeadId(lead.id)
if (existente !== null) return existente
const oportunidadId = await this.monday.crearOportunidadAfluencia({
nombre: lead.nombre,
telefono: lead.telefono,
email: lead.email,
desarrolloLabel: lead.desarrolloLabel,
})
await this.store.guardar(lead.id, oportunidadId)
// Monday crea los items en el grupo TOP del tablero (hoy "Perdidos" en
// Oportunidades) y el webhook de change_column_value no dispara en la
// creación: la oportunidad recién creada se mueve al grupo que corresponde
// a su etapa. Best-effort: un fallo no rompe la captura del lead.
try {
await moverAGrupoSegunEstado(this.monday, config.monday.boards.oportunidades, oportunidadId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${oportunidadId}:`, err)
}
return oportunidadId
}
/**
* Variante para el webhook create_pulse: el payload no trae nombre/teléfono,
* así que el lead se consulta en Monday antes de asegurar la oportunidad.
*/
async asegurarOportunidadAfluenciaPorLeadId(leadId: string): Promise<string | null> {
const lead = await this.monday.getLeadPorId(leadId)
if (!lead) return null
return this.asegurarOportunidadAfluencia({
id: lead.id,
nombre: lead.nombre,
telefono: lead.telefono !== '' ? lead.telefono : undefined,
email: lead.email !== '' ? lead.email : undefined,
})
}
/** Mueve la oportunidad del lead a otra etapa. Sin mapping → no-op (false). */
async moverOportunidadDeLead(leadId: string, etapa: string): Promise<boolean> {
const oportunidadId = await this.store.buscarPorLeadId(leadId)
@@ -104,24 +50,36 @@ export class FunnelService {
}
/**
* Oportunidad del lead que tenga ese teléfono (match de cotizaciones:
* busca el lead por teléfono en Monday y de ahí el mapping).
* Oportunidad que tenga ese teléfono (match de cotizaciones). Convierte con
* ambos esquemas: primero el mapping histórico (lead del tablero Leads → su
* oportunidad); si no hay lead con ese número, busca el teléfono directo en
* Oportunidades (modelo nuevo: la afluencia ES la oportunidad).
*/
async oportunidadPorTelefono(telefono: string): Promise<string | null> {
const lead = await this.monday.buscarLeadPorTelefono(telefono)
if (!lead) return null
return this.store.buscarPorLeadId(lead.id)
if (lead) {
const mapeada = await this.store.buscarPorLeadId(lead.id)
if (mapeada !== null) return mapeada
}
const directa = await this.monday.buscarOportunidadPorTelefono(telefono)
return directa?.id ?? null
}
/**
* Alta de cliente desde un lead: escribe el dedup `apartado-creado:<opId>`
* ANTES de mover la oportunidad a 'Aparto'. Este orden evita la carrera con
* el webhook/cron (escaneoOportunidades vería 'Aparto' y crearía un Apartado
* duplicado; con el dedup previo lo salta). Devuelve la oportunidadId movida
* o null si el lead no tiene mapping.
* Alta de cliente: escribe el dedup `apartado-creado:<opId>` ANTES de mover
* la oportunidad a 'Aparto'. Este orden evita la carrera con el webhook/cron
* (escaneoOportunidades vería 'Aparto' y crearía un Apartado duplicado; con
* el dedup previo lo salta). Devuelve la oportunidadId movida o null si no
* hay mapping.
*
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad; el picker del alta la manda con fuente 'oportunidad') o un
* lead histórico del tablero Leads: se busca primero como oportunidad y
* luego como lead.
*/
async moverAApartoConDedup(leadId: string, state: StateStore): Promise<string | null> {
const oportunidadId = await this.store.buscarPorLeadId(leadId)
async moverAApartoConDedup(id: string, state: StateStore): Promise<string | null> {
const comoOportunidad = await this.store.buscarPorOportunidadId(id)
const oportunidadId = comoOportunidad ?? (await this.store.buscarPorLeadId(id))
if (oportunidadId === null) return null
await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString())
await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', 'Aparto')
@@ -135,6 +93,10 @@ export const prismaLeadOportunidadStore: LeadOportunidadStore = {
const row = await prisma.leadOportunidad.findUnique({ where: { leadId } })
return row?.oportunidadId ?? null
},
async buscarPorOportunidadId(oportunidadId) {
const row = await prisma.leadOportunidad.findUnique({ where: { oportunidadId } })
return row?.oportunidadId ?? null
},
async guardar(leadId, oportunidadId) {
await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } })
},

View File

@@ -42,6 +42,9 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
Abandono: 'group_mm6s4cgq',
'No Asistio': 'group_mm6s4cgq',
Afluencia: 'topics',
Contactado: 'topics',
'Intento de contacto': 'topics',
Calificado: 'topics',
Cotizador: 'topics',
'Agendo Cita': 'topics',
Aparto: 'topics',

View File

@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
esEstadoFinalApartado,
esEstadoTerminalLead,
esEtapaTerminal,
resumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
import type { ActividadMonday, ApartadoMonday, LeadMonday } from './monday.service.js'
import type { ActividadMonday, ApartadoMonday, OportunidadMonday } from './monday.service.js'
const MI_ID = 106736727
const OTRO_ID = 111111111
@@ -14,16 +14,18 @@ const AHORA = new Date('2026-08-28T12:00:00') // viernes
const HOY = '2026-08-28'
const MANANA = '2026-08-29'
function lead(parcial: Partial<LeadMonday> & { id: string }): LeadMonday {
function oportunidad(parcial: Partial<OportunidadMonday> & { id: string }): OportunidadMonday {
return {
nombre: `Lead ${parcial.id}`,
createdAt: '2026-08-25T10:00:00Z',
updatedAt: '2026-08-27T10:00:00Z',
estado: 'NUEVO',
etapa: 'Afluencia',
origen: 'Facebook',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [MI_ID],
telefono: '',
email: '',
vendedorIds: [MI_ID],
...parcial,
}
}
@@ -50,13 +52,13 @@ function apartado(parcial: Partial<ApartadoMonday> & { id: string }): ApartadoMo
}
function mondayMock(datos: {
leads?: LeadMonday[]
oportunidades?: OportunidadMonday[]
actividades?: ActividadMonday[]
apartados?: ApartadoMonday[]
}) {
return {
async getLeads() {
return datos.leads ?? []
async getOportunidades() {
return datos.oportunidades ?? []
},
async getActividades() {
return datos.actividades ?? []
@@ -72,23 +74,37 @@ const sinMapeo: UsuarioMiDia = { nombre: 'Erick Peña', rol: 'vendedor', mondayU
const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null }
describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('filtra mis leads por mondayUserId y excluye estados terminales', async () => {
it('filtra mis leads por mondayUserId (columna Asesor) y excluye etapas terminales', async () => {
const monday = mondayMock({
leads: [
lead({ id: '1', estado: 'NUEVO' }),
lead({ id: '2', estado: 'CONTACTADO', telefono: '4771234567' }),
lead({ id: '3', estado: 'DESCARTADO' }),
lead({ id: '4', estado: 'CALIFICADO' }),
lead({ id: '5', estado: 'Abandono' }),
lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }),
oportunidades: [
oportunidad({ id: '1', etapa: 'Afluencia' }),
oportunidad({ id: '2', etapa: 'Contactado', telefono: '4771234567' }),
oportunidad({ id: '3', etapa: 'Perdido' }),
oportunidad({ id: '4', etapa: 'Calificado' }), // NO es terminal en el nuevo modelo
oportunidad({ id: '5', etapa: 'Abandono' }),
oportunidad({ id: '6', etapa: 'Ganado' }),
oportunidad({ id: '7', etapa: 'Afluencia', asesorIds: [OTRO_ID] }),
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(false)
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2'])
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2', '4'])
expect(r.misLeads[1]?.telefono).toBe('4771234567')
})
it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => {
const monday = mondayMock({
oportunidades: [
oportunidad({ id: '1', etapa: 'Afluencia' }),
oportunidad({ id: '2', etapa: 'Afluencia' }),
oportunidad({ id: '3', etapa: 'Ganado' }),
oportunidad({ id: '4', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: no cuenta
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misOportunidades).toEqual({ porEtapa: { Afluencia: 2, Ganado: 1 } })
})
it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
const monday = mondayMock({
actividades: [
@@ -126,13 +142,14 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => {
const monday = mondayMock({
leads: [lead({ id: '1' })],
oportunidades: [oportunidad({ id: '1' })],
actividades: [actividad({ id: 'a1' })],
apartados: [apartado({ id: 'p1' })],
})
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(true)
expect(r.misLeads).toEqual([])
expect(r.misOportunidades).toEqual({ porEtapa: {} })
expect(r.misActividadesHoy).toEqual([])
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
// Los apartados por vencer son del pipeline completo: sí se muestran
@@ -141,32 +158,26 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => {
const monday = mondayMock({
leads: [
lead({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
lead({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
lead({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro
oportunidades: [
oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
oportunidad({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
oportunidad({ id: '4', createdAt: '2026-08-27T08:00:00', asesorIds: [OTRO_ID] }), // de otro
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2)
})
it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => {
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
expect(r.misOportunidades).toBeNull()
expect(r.notaOportunidades).toContain('asesor')
})
it('vendedor no ve sección equipo; coordinador sí (conteos por vendedor y sin asignar)', async () => {
it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', async () => {
const monday = mondayMock({
leads: [
lead({ id: '1', vendedorIds: [] }), // sin asignar, NUEVO
lead({ id: '2', vendedorIds: [], estado: 'CONTACTADO' }), // sin asignar, no NUEVO
lead({ id: '3', vendedorIds: [MI_ID] }),
lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta
lead({ id: '5', vendedorIds: [OTRO_ID] }),
lead({ id: '6', vendedorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
oportunidades: [
oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia
oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia
oportunidad({ id: '3', asesorIds: [MI_ID] }),
oportunidad({ id: '4', asesorIds: [MI_ID], etapa: 'Perdido' }), // terminal: no cuenta
oportunidad({ id: '5', asesorIds: [OTRO_ID] }),
oportunidad({ id: '6', asesorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
],
})
const nombres = new Map([[MI_ID, 'Martin Zavala']])
@@ -183,42 +194,42 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
})
it('corporativo y admin también ven equipo', async () => {
const monday = mondayMock({ leads: [lead({ id: '1' })] })
const monday = mondayMock({ oportunidades: [oportunidad({ id: '1' })] })
for (const rol of ['corporativo', 'admin']) {
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA)
expect(r.equipo).toBeDefined()
}
})
it('mis capturas: enriquecidas con el estado/origen actual del lead en Monday', async () => {
it('mis capturas: enriquecidas con la etapa/origen actual de la oportunidad en Monday', async () => {
const monday = mondayMock({
leads: [lead({ id: '100', nombre: 'Ana López', estado: 'CONTACTADO', origen: 'WhatsApp', vendedorIds: [OTRO_ID] })],
oportunidades: [oportunidad({ id: '100', nombre: 'Ana López', etapa: 'Contactado', origen: 'WhatsApp', asesorIds: [OTRO_ID] })],
})
const capturas = [
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
// Aunque el lead quedó asignado a OTRO por round-robin, la captura es mía
// Aunque la afluencia quedó asignada a OTRO por round-robin, la captura es mía
expect(r.misCapturas).toEqual([
{
leadId: '100',
nombre: 'Ana López',
telefono: '6641112233',
estado: 'CONTACTADO',
estado: 'Contactado',
origen: 'WhatsApp',
capturadoEn: '2026-08-27T09:00:00.000Z',
},
])
})
it('mis capturas: si el lead ya no existe en Monday, estado y origen van vacíos', async () => {
it('mis capturas: capturas viejas (del tablero Leads) ya no cruzan; estado y origen van vacíos', async () => {
const monday = mondayMock({})
const capturas = [
{ leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
{ leadId: 'lead-viejo-1', nombreLead: 'Lead Histórico', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas).toEqual([
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
{ leadId: 'lead-viejo-1', nombre: 'Lead Histórico', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
])
})
@@ -290,12 +301,13 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
})
describe('clasificadores de estado', () => {
it('estados terminales de lead (case-insensitive)', () => {
expect(esEstadoTerminalLead('DESCARTADO')).toBe(true)
expect(esEstadoTerminalLead('calificado')).toBe(true)
expect(esEstadoTerminalLead('Abandono')).toBe(true)
expect(esEstadoTerminalLead('NUEVO')).toBe(false)
expect(esEstadoTerminalLead('Intento de contacto')).toBe(false)
it('etapas terminales de oportunidad (case-insensitive)', () => {
expect(esEtapaTerminal('GANADO')).toBe(true)
expect(esEtapaTerminal('perdido')).toBe(true)
expect(esEtapaTerminal('Abandono')).toBe(true)
expect(esEtapaTerminal('Afluencia')).toBe(false)
expect(esEtapaTerminal('Calificado')).toBe(false)
expect(esEtapaTerminal('Intento de contacto')).toBe(false)
})
it('estados finales de apartado', () => {

View File

@@ -5,12 +5,15 @@ import type { MondayService } from './monday.service.js'
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
* "Mis capturas" viene del registro local (tabla LeadCaptura), no de Monday:
* es la lista de leads que el usuario dio de alta, con su estado actual.
* La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia
* ES la oportunidad en etapa 'Afluencia'; el tablero Leads es archivo
* histórico). "Mis capturas" viene del registro local (tabla LeadCaptura), no
* de Monday: es la lista de afluencias que el usuario dio de alta, con su
* estado actual.
*/
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
// Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
// Estados del board Apartado que cierran el pipeline Enkontrol
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
@@ -76,9 +79,8 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
misOportunidades: { porEtapa: Record<string, number> } | null
notaOportunidades: string | null
/** Conteo de MIS oportunidades por etapa (columna Estado del board) */
misOportunidades: { porEtapa: Record<string, number> }
conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto
@@ -96,7 +98,7 @@ export interface ResumenMiDia {
}
}
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getActividades' | 'getApartados'>
type MondayMiDia = Pick<MondayService, 'getOportunidades' | 'getActividades' | 'getApartados'>
const MS_DIA = 24 * 60 * 60 * 1000
@@ -109,8 +111,8 @@ function fechaLocal(base: Date, dias: number): string {
return `${y}-${m}-${dd}`
}
export function esEstadoTerminalLead(estado: string): boolean {
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
export function esEtapaTerminal(etapa: string): boolean {
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
}
export function esEstadoFinalApartado(estado: string): boolean {
@@ -131,8 +133,8 @@ export async function resumenMiDia(
const miId = usuario.mondayUserId
const sinMapeo = miId === null
const [leads, actividades, apartados] = await Promise.all([
monday.getLeads(),
const [oportunidades, actividades, apartados] = await Promise.all([
monday.getOportunidades(),
monday.getActividades(),
monday.getApartados(),
])
@@ -143,31 +145,40 @@ export async function resumenMiDia(
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) ---
const mios = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
const misLeads: LeadMiDia[] = mios
.filter((l) => !esEstadoTerminalLead(l.estado))
.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
updatedAt: l.updatedAt,
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
}))
// "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday
// (si ya no existe en el board, estado y origen van ''), más reciente primero
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
const porEtapa: Record<string, number> = {}
for (const o of mios) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday (las
// capturas nuevas apuntan a Oportunidades; las viejas — del tablero Leads —
// ya no cruzan y quedan con estado/origen ''), más reciente primero
const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o]))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => {
const lead = leadsPorId.get(c.leadId)
const op = oportunidadesPorId.get(c.leadId)
return {
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: lead?.estado ?? '',
origen: lead?.origen ?? '',
estado: op?.etapa ?? '',
origen: op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
}
})
@@ -224,27 +235,25 @@ export async function resumenMiDia(
misClientes,
misActividadesHoy,
apartadosPorVencer,
// El tablero Oportunidades no tiene columna people de asesor: no se puede
// filtrar "mío"; se omite con nota explícita
misOportunidades: null,
notaOportunidades: 'El tablero Oportunidades no tiene columna de asesor (people); no se puede filtrar por vendedor',
misOportunidades: { porEtapa },
conteos,
}
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado))
const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa))
const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
for (const o of oportunidades) {
if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
for (const id of o.asesorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
resumen.equipo = {
leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length,
// Afluencias recién llegadas que el round-robin aún no reparte
leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length,
porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({
mondayUserId,

View File

@@ -415,5 +415,241 @@ describe('MondayService', () => {
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ label: 'Nuevo' })
})
it('cambiarValorColumna resuelve el label de un DROPDOWN case-insensitive (los dropdown no auto-crean labels)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', settings_str: '{"labels":[{"name":"Precio"},{"name":"Timing"}]}' }] }],
})
}
if (body.query.includes('columns')) {
return respuestaOk({ boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', type: 'dropdown' }] }] })
}
return respuestaOk({ change_column_value: { id: 'it-1' } })
})
await crearServicio().cambiarValorColumna('b1', 'it-1', 'Motivo No Compra', 'PRECIO')
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ labels: ['Precio'] })
})
})
// Columnas del board Oportunidades con los tipos reales (verificadas en Monday)
const COLUMNAS_OPORTUNIDADES = [
{ id: 'color_estado', title: 'Estado', type: 'status' },
{ id: 'date_fecha', title: 'Fecha', type: 'date' },
{ id: 'phone_ct', title: 'Cliente teléfono', type: 'phone' },
{ id: 'email_ce', title: 'Cliente email', type: 'email' },
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
{ id: 'color_mod', title: 'Modelo de Interés', type: 'status' },
{ id: 'dropdown_or', title: 'Origen del Lead', type: 'dropdown' },
{ id: 'text_mc', title: 'Medio de Contacto', type: 'text' },
{ id: 'numeric_edad', title: 'Edad', type: 'numbers' },
{ id: 'dropdown_ec', title: 'Estado Civil', type: 'dropdown' },
{ id: 'color_te', title: 'Tipo de Empleo', type: 'status' },
{ id: 'text_ocu', title: 'Ocupación', type: 'text' },
{ id: 'text_rs', title: 'Rango Salarial', type: 'text' },
{ id: 'color_cr', title: 'Crédito', type: 'status' },
{ id: 'color_teco', title: 'Tipo de Econimia', type: 'status' }, // SIC: typo real
{ id: 'dropdown_cal', title: 'Lead Califica', type: 'dropdown' },
{ id: 'dropdown_lqp', title: 'Proposito de Compra', type: 'dropdown' },
{ id: 'long_com', title: 'COMENTARIOS', type: 'long_text' },
{ id: 'people_ase', title: 'Asesor', type: 'people' },
{ id: 'text_u1', title: 'Unidad', type: 'text' },
{ id: 'numeric_pf', title: 'Precio final', type: 'numbers' },
]
const itemOportunidad = (id: string, extras: { id: string; text: string; value?: string | null }[] = []) => ({
id,
name: `Cliente ${id}`,
created_at: '2026-08-20T10:00:00Z',
updated_at: '2026-08-21T10:00:00Z',
column_values: [
{ id: 'color_estado', text: 'Afluencia', value: '{"index":1}' },
{ id: 'phone_ct', text: '4771234567', value: null },
{ id: 'people_ase', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' },
...extras.map((e) => ({ id: e.id, text: e.text, value: e.value ?? null })),
],
})
const mockTableroOportunidades = (items: unknown[]) =>
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ boards: [{ items_page: { cursor: null, items } }] })
})
describe('getOportunidades / mapearOportunidad (afluencia en Oportunidades)', () => {
it('mapea asesor (people), contacto, origen y perfilamiento cuando existen en el board', async () => {
mockTableroOportunidades([
itemOportunidad('1', [
{ id: 'email_ce', text: 'a@b.com' },
{ id: 'dropdown_or', text: 'WhatsApp' },
{ id: 'color_des', text: 'ATELIER' },
{ id: 'color_mod', text: 'HIMALAYA' },
{ id: 'text_mc', text: 'Cabina' },
{ id: 'numeric_edad', text: '32' },
{ id: 'dropdown_ec', text: 'Soltero(a)' },
{ id: 'color_te', text: 'Asalariado' },
{ id: 'color_cr', text: 'Infonavit' },
{ id: 'color_teco', text: 'Economía mexicana' },
{ id: 'dropdown_cal', text: 'Sí' },
{ id: 'text_rs', text: '20-30 mil' },
{ id: 'dropdown_lqp', text: 'Vivir' },
{ id: 'text_u1', text: 'U-101' },
{ id: 'numeric_pf', text: '2500000' },
]),
])
const ops = await crearServicio().getOportunidades()
expect(ops[0]).toMatchObject({
id: '1',
etapa: 'Afluencia',
asesorIds: [106736727],
telefono: '4771234567',
email: 'a@b.com',
origen: 'WhatsApp',
desarrollo: 'ATELIER',
modeloInteres: 'HIMALAYA',
medioContacto: 'Cabina',
edad: 32,
estadoCivil: 'Soltero(a)',
tipoEmpleo: 'Asalariado',
credito: 'Infonavit',
tipoEconomia: 'Economía mexicana',
califica: 'Sí',
rangoSalarial: '20-30 mil',
loQuierePara: 'Vivir',
unidadCodigo: 'U-101',
precioFinal: 2500000,
updatedAt: '2026-08-21T10:00:00Z',
})
})
it('etapa con value null (default del tablero, nunca escrito) se lee como vacía', async () => {
mockTableroOportunidades([
// text trae el label default del board, pero value es null: no hay etapa real
itemOportunidad('1', [{ id: 'color_estado', text: 'Perdido', value: null }]),
])
// itemOportunidad ya pone color_estado con value; aquí el extra lo pisa en el mapa por id
const ops = await crearServicio().getOportunidades()
expect(ops[0]?.etapa).toBe('')
})
it('sin la columna Asesor devuelve asesorIds vacío (lectura tolerante)', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) {
return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES.filter((c) => c.title !== 'Asesor') }] })
}
return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemOportunidad('1')] } }] })
})
const ops = await crearServicio().getOportunidades()
expect(ops[0]?.asesorIds).toEqual([])
})
it('buscarOportunidadPorTelefono: dedup por últimos 10 dígitos', async () => {
mockTableroOportunidades([itemOportunidad('1'), itemOportunidad('2', [{ id: 'phone_ct', text: '' }])])
const svc = crearServicio()
await expect(svc.buscarOportunidadPorTelefono('+52 477 123 4567')).resolves.toMatchObject({ id: '1' })
await expect(svc.buscarOportunidadPorTelefono('4770000000')).resolves.toBeNull()
await expect(svc.buscarOportunidadPorTelefono('123')).resolves.toBeNull() // menos de 10 dígitos
})
})
describe('asignarAsesorAOportunidad (columna people Asesor)', () => {
it('escribe personsAndTeams con el user id en el board Oportunidades (555 en tests)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ change_column_value: { id: 'op-1' } })
})
await crearServicio().asignarAsesorAOportunidad('op-1', 12345)
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(mutacion?.body.variables['boardId']).toBe('555')
expect(mutacion?.body.variables['columnId']).toBe('people_ase')
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({
personsAndTeams: [{ id: 12345, kind: 'person' }],
})
})
})
describe('crearOportunidadAfluencia (captura completa en Oportunidades)', () => {
it('serializa cada campo según su tipo real (dropdown, status, phone, numbers, long_text, date)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
// El resolver de labels consulta los settings de cada status/dropdown escrito
return respuestaOk({
boards: [
{
columns: COLUMNAS_OPORTUNIDADES
.filter((c) => c.type === 'status' || c.type === 'dropdown')
.map((c) => ({ id: c.id, title: c.title, settings_str: '{"labels":{"1":"Afluencia"}}' })),
},
],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ create_item: { id: 'op-nueva-1' } })
})
const id = await crearServicio().crearOportunidadAfluencia({
nombre: 'Lead Prueba',
telefono: '4771234567',
email: 'lead@correo.com',
desarrolloLabel: 'SENDA',
origenLabel: 'WhatsApp',
modeloLabel: 'HIMALAYA',
medioContacto: 'Cabina',
edad: 35,
estadoCivilLabel: 'Casado(a)',
tipoEmpleoLabel: 'Asalariado',
ocupacion: 'Ingeniero',
rangoSalarial: '$20,000 - $30,000',
creditoLabel: 'Infonavit',
tipoEconomiaLabel: 'Economía mexicana',
calificaLabel: 'Sí',
loQuierePara: 'Vivir',
notas: 'Vio el volante',
})
expect(id).toBe('op-nueva-1')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(parsed['color_estado']).toEqual({ label: 'Afluencia' }) // nace en etapa Afluencia
expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) // fecha de captura = hoy
expect(parsed['phone_ct']).toEqual({ phone: '4771234567', countryShortName: 'MX' })
expect(parsed['email_ce']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' })
expect(parsed['dropdown_or']).toEqual({ labels: ['WhatsApp'] })
expect(parsed['dropdown_ec']).toEqual({ labels: ['Casado(a)'] })
expect(parsed['dropdown_cal']).toEqual({ labels: ['Sí'] })
expect(parsed['dropdown_lqp']).toEqual({ labels: ['Vivir'] })
expect(parsed['color_mod']).toEqual({ label: 'HIMALAYA' })
expect(parsed['color_teco']).toEqual({ label: 'Economía mexicana' }) // columna con typo 'Tipo de Econimia'
expect(parsed['numeric_edad']).toBe(35)
expect(parsed['text_mc']).toBe('Cabina')
expect(parsed['text_ocu']).toBe('Ingeniero')
expect(parsed['text_rs']).toBe('$20,000 - $30,000')
expect(parsed['long_com']).toEqual({ text: 'Vio el volante' })
})
it('solo envía las columnas capturadas (las vacías se omiten)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [{ columns: [{ id: 'color_estado', title: 'Estado', settings_str: '{"labels":{"1":"Afluencia"}}' }] }],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ create_item: { id: 'op-nueva-2' } })
})
await crearServicio().crearOportunidadAfluencia({ nombre: 'Solo Nombre' })
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
})
})
})

View File

@@ -86,9 +86,27 @@ export interface OportunidadMonday {
id: string
nombre: string // nombre del cliente
createdAt: string // ISO
updatedAt: string // ISO (updated_at del item en Monday)
etapa: string // label de la columna status 'Estado'
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
asesorIds: number[] // user IDs asignados en la columna people 'Asesor'
telefono: string // columna phone 'Cliente teléfono' ('' si no tiene)
email: string // columna email 'Cliente email' ('' si no tiene)
origen: string // dropdown 'Origen del Lead' ('' si no tiene)
// Perfilamiento de la afluencia (alimenta las gráficas de /dash/); todos
// tolerantes: si la columna no existe en el board o viene vacía, quedan '' / null
desarrollo?: string // status 'Desarrollo'
modeloInteres?: string // status 'Modelo de Interés'
medioContacto?: string // text 'Medio de Contacto'
edad?: number | null // numbers 'Edad' (null si vacía o no numérica)
estadoCivil?: string // dropdown 'Estado Civil'
tipoEmpleo?: string // status 'Tipo de Empleo'
credito?: string // status 'Crédito'
tipoEconomia?: string // status 'Tipo de Econimia' (SIC: el título tiene typo en Monday)
califica?: string // dropdown 'Lead Califica'
rangoSalarial?: string // text 'Rango Salarial'
loQuierePara?: string // dropdown 'Proposito de Compra'
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
@@ -371,17 +389,17 @@ export class MondayService {
}
/**
* Resuelve el label real de una columna status tolerando mayúsculas y
* minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
* un rename en la UI). Sin match devuelve el valor tal cual (Monday dirá
* que el label no existe, como antes).
* Resuelve el label real de una columna status/dropdown tolerando mayúsculas
* y minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
* un rename en la UI; los dropdown NO auto-crean labels al escribir). Sin
* match devuelve el valor tal cual (Monday dirá que el label no existe).
*/
private async resolverLabelStatus(
private async resolverLabel(
boardId: string,
col: ColumnaMeta,
valor: string | number | boolean,
): Promise<string | number | boolean> {
if (col.type !== 'status') return valor
if (col.type !== 'status' && col.type !== 'dropdown') return valor
const buscado = String(valor).trim().toUpperCase()
const labels = await this.labelsDeColumna(boardId, col.title)
return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor
@@ -395,7 +413,7 @@ export class MondayService {
for (const [titulo, crudo] of Object.entries(valores)) {
if (crudo === undefined || crudo === '') continue
const col = await this.columnaId(boardId, titulo)
out[col.id] = this.valorPorTipo(col, await this.resolverLabelStatus(boardId, col, crudo))
out[col.id] = this.valorPorTipo(col, await this.resolverLabel(boardId, col, crudo))
}
return JSON.stringify(out)
}
@@ -429,16 +447,33 @@ export class MondayService {
}
/**
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado
* (entrada al funnel; sin unidad ni precios todavía).
* Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la
* afluencia ES la oportunidad: no hay item aparte en el tablero Leads).
* La columna date 'Fecha' lleva la fecha de captura (hoy, hora local).
*/
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
const boardId = config.monday.boards.oportunidades
const hoy = new Date()
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
const columnValues = await this.construirColumnValues(boardId, {
Estado: 'Afluencia',
Fecha: fechaHoy,
'Cliente teléfono': datos.telefono,
'Cliente email': datos.email,
Desarrollo: datos.desarrolloLabel,
Estado: 'Afluencia',
'Modelo de Interés': datos.modeloLabel,
'Origen del Lead': datos.origenLabel,
'Medio de Contacto': datos.medioContacto,
Edad: datos.edad,
'Estado Civil': datos.estadoCivilLabel,
'Tipo de Empleo': datos.tipoEmpleoLabel,
Ocupación: datos.ocupacion,
'Rango Salarial': datos.rangoSalarial,
Crédito: datos.creditoLabel,
'Tipo de Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real de la columna
'Lead Califica': datos.calificaLabel,
'Proposito de Compra': datos.loQuierePara,
COMENTARIOS: datos.notas,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
@@ -561,7 +596,7 @@ export class MondayService {
valor: string | number | boolean,
): Promise<void> {
const col = await this.columnaId(boardId, tituloColumna)
const resuelto = await this.resolverLabelStatus(boardId, col, valor)
const resuelto = await this.resolverLabel(boardId, col, valor)
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
@@ -689,21 +724,6 @@ export class MondayService {
return items.map((item) => this.mapearLead(item, cols))
}
/** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */
async getLeadPorId(itemId: string): Promise<LeadMonday | null> {
const boardId = config.monday.boards.leads
const cols = await this.columnasDe(boardId)
const data = await this.graphql<{ items: (ItemConFechas | null)[] }>(
`query ($ids: [ID!]) {
items(ids: $ids) { id name created_at updated_at column_values { id text value } }
}`,
{ ids: [itemId] },
)
const item = data.items[0]
if (!item) return null
return this.mapearLead(item, cols)
}
private mapearLead(item: ItemConFechas, cols: Map<string, ColumnaMeta>): LeadMonday {
const colVendedor = cols.get('Vendedor')
if (!colVendedor || colVendedor.type !== 'people') {
@@ -761,16 +781,16 @@ export class MondayService {
}
/**
* Items del tablero Oportunidades con etapa, unidad y precio final
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
* Items del tablero Oportunidades con etapa, unidad, precio final, asesor
* asignado y perfil de afluencia (los consumen las automatizaciones
* Aparto/Ganado, el round-robin, Mi día y los reportes).
*/
async getOportunidades(): Promise<OportunidadMonday[]> {
const boardId = config.monday.boards.oportunidades
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
const colUnidad = cols.get('Unidad')
const colPrecio = cols.get('Precio final')
if (!cols.has('Estado')) {
throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
}
const items: ItemConFechas[] = []
let cursor: string | null = null
@@ -805,18 +825,68 @@ export class MondayService {
cursor = page.cursor
} while (cursor !== null)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
etapa: porId.get(colEstado.id)?.text ?? '',
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
precioFinal: Number.isFinite(precio) ? precio : 0,
}
})
return items.map((item) => this.mapearOportunidad(item, cols))
}
private mapearOportunidad(item: ItemConFechas, cols: Map<string, ColumnaMeta>): OportunidadMonday {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
// Lectura tolerante por título ('' si falta la columna o el valor)
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
return col ? (porId.get(col.id)?.text ?? '') : ''
}
// Misma corrección que mapearLead: cuando el status nunca se ha escrito,
// Monday devuelve value=null pero `text` muestra el default del tablero;
// la etapa real es vacía. Se lee `text` SOLO si hay value.
const colEstado = cols.get('Estado')
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const etapa = estadoCv?.value ? (estadoCv.text ?? '') : ''
const colAsesor = cols.get('Asesor')
const precio = Number(textoDe('Precio final'))
const edadRaw = textoDe('Edad')
const edadNum = Number(edadRaw)
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
etapa,
unidadCodigo: textoDe('Unidad'),
precioFinal: Number.isFinite(precio) ? precio : 0,
asesorIds: colAsesor ? this.peopleIds(porId.get(colAsesor.id)?.value) : [],
telefono: textoDe('Cliente teléfono'),
email: textoDe('Cliente email'),
origen: textoDe('Origen del Lead'),
desarrollo: textoDe('Desarrollo'),
modeloInteres: textoDe('Modelo de Interés'),
medioContacto: textoDe('Medio de Contacto'),
edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum,
estadoCivil: textoDe('Estado Civil'),
tipoEmpleo: textoDe('Tipo de Empleo'),
credito: textoDe('Crédito'),
tipoEconomia: textoDe('Tipo de Econimia'), // SIC: typo en el título real
califica: textoDe('Lead Califica'),
rangoSalarial: textoDe('Rango Salarial'),
loQuierePara: textoDe('Proposito de Compra'),
}
}
/**
* Oportunidad con el mismo teléfono (dedup de la captura de afluencia, que
* ahora vive en Oportunidades). Compara solo dígitos y los últimos 10
* (tolerante a lada/prefijo 52). null si no hay duplicado.
*/
async buscarOportunidadPorTelefono(telefono: string): Promise<OportunidadMonday | null> {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length < 10) return null
const clave = digitos.slice(-10)
const oportunidades = await this.getOportunidades()
return (
oportunidades.find((o) => {
const d = o.telefono.replace(/\D/g, '')
return d.length >= 10 && d.slice(-10) === clave
}) ?? null
)
}
// ---------- Mi día (CRM ligero para vendedores) ----------
@@ -963,31 +1033,6 @@ export class MondayService {
}
}
/** Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor) */
async crearLead(datos: DatosLead): Promise<string> {
const boardId = config.monday.boards.leads
const columnValues = await this.construirColumnValues(boardId, {
Estado: 'NUEVO',
Teléfono: datos.telefono,
Email: datos.email,
Origen: datos.origenLabel,
Desarrollo: datos.desarrolloLabel,
Notas: datos.notas,
'Modelo de Interés': datos.modeloLabel,
'Medio de Contacto': datos.medioContacto,
Edad: datos.edad,
'Estado Civil': datos.estadoCivilLabel,
'Tipo de Empleo': datos.tipoEmpleoLabel,
Ocupación: datos.ocupacion,
'Rango Salarial': datos.rangoSalarial,
Crédito: datos.creditoLabel,
'Tipo de Economía': datos.tipoEconomiaLabel,
Califica: datos.calificaLabel,
'Lo Quiere Para': datos.loQuierePara,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/**
* Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos
* y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
@@ -1066,10 +1111,10 @@ export class MondayService {
return abiertas
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads
const col = await this.columnaId(boardId, 'Vendedor')
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.oportunidades
const col = await this.columnaId(boardId, 'Asesor')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
@@ -1141,10 +1186,25 @@ export interface DatosOportunidad {
}
export interface DatosOportunidadAfluencia {
nombre: string // name del item = nombre del lead
nombre: string // name del item = nombre del interesado
telefono?: string | undefined
email?: string | undefined
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
// Campos de calificación de la afluencia (labels reales de las columnas del
// board Oportunidades; los dropdown no auto-crean labels, deben venir sembrados)
origenLabel?: string | undefined // dropdown 'Origen del Lead'
modeloLabel?: string | undefined // status 'Modelo de Interés'
medioContacto?: string | undefined // text 'Medio de Contacto'
edad?: number | undefined
estadoCivilLabel?: string | undefined // dropdown 'Estado Civil'
tipoEmpleoLabel?: string | undefined // status 'Tipo de Empleo'
ocupacion?: string | undefined // text 'Ocupación'
rangoSalarial?: string | undefined // text 'Rango Salarial'
creditoLabel?: string | undefined // status 'Crédito'
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
calificaLabel?: string | undefined // dropdown 'Lead Califica'
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
notas?: string | undefined // long_text 'COMENTARIOS'
}
export interface DatosContacto {
@@ -1195,27 +1255,6 @@ export interface DatosActividad {
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
}
export interface DatosLead {
nombre: string
telefono?: string | undefined
email?: string | undefined
origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads
desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads
notas?: string | undefined
// Campos de calificación del interesado (labels reales de las columnas status del board Leads)
modeloLabel?: string | undefined // label de la columna status 'Modelo de Interés' (modelo según desarrollo)
medioContacto?: string | undefined
edad?: number | undefined
estadoCivilLabel?: string | undefined
tipoEmpleoLabel?: string | undefined
ocupacion?: string | undefined
rangoSalarial?: string | undefined
creditoLabel?: string | undefined
tipoEconomiaLabel?: string | undefined
calificaLabel?: string | undefined
loQuierePara?: string | undefined
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()

View File

@@ -1,25 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import type { OportunidadMonday, Unidad } from './monday.service.js'
import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ----------
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
/** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */
function afluencia(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z',
estado: 'NUEVO',
etapa: 'Afluencia',
origen: 'Facebook',
vendedorIds: [],
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
...overrides,
}
}
function op(id: string, etapa: string): OportunidadMonday {
return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101', precioFinal: 0 }
return afluencia(id, { nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101' })
}
function unidad(codigo: string, desarrollo: string, status: string, etapa: string | null = null): Unidad {
@@ -63,37 +68,37 @@ function pago(id: string, fechaVencimiento: string, monto: number, montoPagado =
const AHORA = new Date('2026-08-22T12:00:00') // mediodía local
const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
const DATOS_BASE: DatosResumen = { oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
// ---------- construirResumen (puro) ----------
describe('construirResumen', () => {
it('cuenta leads por estado y por origen', () => {
it('cuenta la afluencia por estado (Etapa) y por origen (Origen del Lead)', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { estado: 'NUEVO', origen: 'Facebook' }),
lead('2', { estado: 'NUEVO', origen: 'Facebook' }),
lead('3', { estado: 'CONTACTADO', origen: 'Referido' }),
lead('4', { estado: '', origen: '' }),
oportunidades: [
afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }),
afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }),
afluencia('3', { etapa: 'Contactado', origen: 'Referido' }),
afluencia('4', { etapa: '', origen: '' }),
],
},
AHORA,
)
expect(r.leads.total).toBe(4)
expect(r.leads.porEstado).toEqual({ NUEVO: 2, CONTACTADO: 1, '(sin dato)': 1 })
expect(r.leads.porEstado).toEqual({ Afluencia: 2, Contactado: 1, '(sin dato)': 1 })
expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 })
})
it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => {
it('cuenta afluencias nuevas de hoy y de los últimos 7 días (fecha local)', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
oportunidades: [
afluencia('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
afluencia('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
afluencia('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
],
},
AHORA,
@@ -218,10 +223,10 @@ describe('construirResumen', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }),
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
oportunidades: [
afluencia('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Sí' }),
afluencia('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
afluencia('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
],
},
AHORA,
@@ -229,7 +234,7 @@ describe('construirResumen', () => {
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 })
expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 })
expect(r.leads.perfil.porCalifica).toEqual({ Sí: 1 })
expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k
// Dimensiones sin ningún dato van como objeto vacío
expect(r.leads.perfil.porModelo).toEqual({})
@@ -243,16 +248,16 @@ describe('construirResumen', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
lead('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
lead('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
lead('9', { rangoSalarial: '' }), // vacío: se salta
oportunidades: [
afluencia('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
afluencia('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
afluencia('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
afluencia('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
afluencia('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
afluencia('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
afluencia('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
afluencia('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
afluencia('9', { rangoSalarial: '' }), // vacío: se salta
],
},
AHORA,
@@ -272,16 +277,16 @@ describe('construirResumen', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { edad: 18 }), // 15–24
lead('2', { edad: 24 }), // 15–24
lead('3', { edad: 25 }), // 25–34
lead('4', { edad: 39 }), // 35–44
lead('5', { edad: 50 }), // 45–54
lead('6', { edad: 55 }), // 55+
lead('7', { edad: 70 }), // 55+
lead('8', { edad: null }), // sin dato: se salta
lead('9'), // edad undefined: se salta
oportunidades: [
afluencia('1', { edad: 18 }), // 15–24
afluencia('2', { edad: 24 }), // 15–24
afluencia('3', { edad: 25 }), // 25–34
afluencia('4', { edad: 39 }), // 35–44
afluencia('5', { edad: 50 }), // 45–54
afluencia('6', { edad: 55 }), // 55+
afluencia('7', { edad: 70 }), // 55+
afluencia('8', { edad: null }), // sin dato: se salta
afluencia('9'), // edad undefined: se salta
],
},
AHORA,
@@ -295,14 +300,10 @@ describe('construirResumen', () => {
describe('ReportesService', () => {
function mondayMock() {
return {
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
async getLeads(): Promise<LeadMonday[]> {
this.llamadas.leads++
return [lead('1')]
},
llamadas: { oportunidades: 0, unidades: 0, actividades: 0 },
async getOportunidades(): Promise<OportunidadMonday[]> {
this.llamadas.oportunidades++
return [op('1', 'Aparto')]
return [afluencia('1')]
},
async getUnidades(): Promise<Unidad[]> {
this.llamadas.unidades++
@@ -315,15 +316,16 @@ describe('ReportesService', () => {
}
}
it('agrega todas las fuentes en una sola respuesta', async () => {
it('agrega todas las fuentes en una sola respuesta (una sola llamada a Oportunidades)', async () => {
const monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const r = await svc.resumen()
expect(r.leads.total).toBe(1)
expect(r.leads.total).toBe(1) // la sección leads se alimenta de Oportunidades
expect(r.oportunidades.total).toBe(1)
expect(r.unidades.total).toBe(1)
expect(r.cobranza.planes.activos).toBe(1)
expect(r.actividadesAbiertas).toBe(7)
expect(monday.llamadas.oportunidades).toBe(1) // sin llamada duplicada
})
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
@@ -336,14 +338,13 @@ describe('ReportesService', () => {
await svc.resumen()
await svc.resumen()
expect(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
// 61 s después el cache expira y se vuelve a consultar
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
await svc.resumen()
expect(monday.llamadas.leads).toBe(2)
expect(monday.llamadas.oportunidades).toBe(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()

View File

@@ -1,11 +1,12 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import type { MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import { ETAPAS_PIPELINE } from './etapas.js'
/**
* Agregador de reportes para la app de tableros (/dash/).
* En UNA respuesta junta: leads (Monday), oportunidades (Monday), unidades
* (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
* En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la
* afluencia ES la oportunidad en etapa 'Afluencia'), oportunidades (Monday),
* unidades (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
* Cache en memoria de 60 s para no martillar la API de Monday.
*/
@@ -75,7 +76,8 @@ export interface ResumenReportes {
/** Datos crudos que necesita el agregador (inyectables en tests) */
export interface DatosResumen {
leads: LeadMonday[]
// La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades`
// se alimentan de la MISMA fuente: el tablero Oportunidades
oportunidades: OportunidadMonday[]
unidades: Unidad[]
planes: PlanPago[] // dominio de cobranza (con pagos)
@@ -162,8 +164,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const creadosEn = (desde: Date): number =>
datos.leads.filter((l) => {
const t = Date.parse(l.createdAt)
datos.oportunidades.filter((o) => {
const t = Date.parse(o.createdAt)
return !Number.isNaN(t) && t >= desde.getTime()
}).length
@@ -219,29 +221,31 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
return {
generadoEn: ahora.toISOString(),
// Sección "leads" = afluencia sobre el tablero Oportunidades (modelo
// nuevo: la afluencia ES la oportunidad en etapa 'Afluencia')
leads: {
total: datos.leads.length,
total: datos.oportunidades.length,
nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.leads.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
perfil: {
porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')),
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
porRangoSalarial: contarPorNoVacios(
datos.leads
.map((l) => bucketSalario(l.rangoSalarial ?? ''))
datos.oportunidades
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
.filter((b): b is string => b !== null),
),
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
porEdad: contarPorNoVacios(
datos.leads.filter((l) => l.edad != null).map((l) => bucketEdad(l.edad!)),
datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
),
},
},
@@ -273,7 +277,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
// ---------- Servicio con cache ----------
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
type MondayReportes = Pick<MondayService, 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
@@ -309,14 +313,13 @@ export class ReportesService {
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen
}

View File

@@ -206,7 +206,7 @@ describe('AltaAfluenciaForm', () => {
})
it('tras el éxito, "Pasar a Alta de Cliente" entrega el lead recién creado', async () => {
fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' }))
fetchMock.mockResolvedValueOnce(respuesta({ id: 'op-1', estado: 'Afluencia', oportunidadId: 'op-1' }))
const onPasar = vi.fn()
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={onPasar} />)
llenarRequeridos()
@@ -216,12 +216,13 @@ describe('AltaAfluenciaForm', () => {
fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ }))
expect(onPasar).toHaveBeenCalledWith({
id: 'l-1',
id: 'op-1',
nombre: 'Ana Demo',
telefono: '6641234567',
email: 'ana@correo.com',
estado: 'NUEVO',
estado: 'Afluencia',
origen: '',
fuente: 'oportunidad', // la captura nueva vive en Oportunidades
})
})
})

View File

@@ -122,6 +122,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
email: form.email.trim(),
estado: res.lead.estado,
origen: form.origen,
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
})
setAsignacion(res.lead.asignacion ?? 'round-robin')
} else if (res.motivo === 'duplicado') {

View File

@@ -6,8 +6,8 @@ import type { LeadResumen } from '../services/api'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const LEADS: LeadResumen[] = [
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web' },
{ id: 'lead-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'CONTACTADO', origen: '' },
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web', fuente: 'lead' },
{ id: 'op-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'Afluencia', origen: '', fuente: 'oportunidad' },
]
const respuestaOk = (data: unknown) =>
@@ -35,13 +35,16 @@ describe('BusquedaLead', () => {
expect(llamadasBusqueda()).toHaveLength(0)
})
it('renderiza los resultados de la búsqueda (nombre, teléfono, estado)', async () => {
it('renderiza los resultados de la búsqueda (nombre, teléfono, estado) con insignia de fuente', async () => {
render(<BusquedaLead onSeleccion={() => undefined} />)
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy()
expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy()
expect(screen.getByText('Juan Pérez')).toBeTruthy()
// Insignias: gris "Lead" (histórico) y azul "Oportunidad" (modelo nuevo)
expect(screen.getByText('Lead')).toBeTruthy()
expect(screen.getByText('Oportunidad')).toBeTruthy()
const [url] = llamadasBusqueda()[0] as [string]
expect(url).toContain('/leads/buscar?q=maria')
})

View File

@@ -8,8 +8,9 @@ interface Props {
}
/**
* Buscador con debounce de leads de Monday (GET /leads/buscar). No llama al
* API con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
* Buscador con debounce de leads/afluencias de Monday (GET /leads/buscar,
* búsqueda dual: tablero Leads histórico + Oportunidades). No llama al API
* con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
*/
export default function BusquedaLead({ onSeleccion }: Props) {
const [q, setQ] = useState('')
@@ -71,13 +72,25 @@ export default function BusquedaLead({ onSeleccion }: Props) {
{resultados !== null && resultados.length > 0 && (
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
{resultados.map((lead) => (
<li key={lead.id}>
<li key={`${lead.fuente}-${lead.id}`}>
<button
type="button"
onClick={() => onSeleccion(lead)}
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
>
<span className="text-sm text-[#111827]">{lead.nombre}</span>
<span className="flex items-baseline gap-2 text-sm text-[#111827]">
{lead.nombre}
{/* Insignia de procedencia: gris = archivo histórico, azul = modelo nuevo */}
<span
className={
lead.fuente === 'oportunidad'
? 'rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-800'
: 'rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600'
}
>
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
</span>
</span>
<span className="text-xs text-[#64748B]">
{lead.telefono || 'sin teléfono'}
{lead.estado ? ` · ${lead.estado}` : ''}

View File

@@ -12,6 +12,7 @@ const LEAD: LeadResumen = {
email: 'maria@correo.com',
estado: 'NUEVO',
origen: 'Web',
fuente: 'lead',
}
const respuestaOk = (data: unknown) =>

View File

@@ -225,9 +225,11 @@ export interface LeadResumen {
email: string
estado: string
origen: string
/** 'lead' = archivo histórico (tablero Leads); 'oportunidad' = modelo nuevo (tablero Oportunidades) */
fuente: 'lead' | 'oportunidad'
}
/** GET /leads/buscar?q= — autocompletado para prellenar el alta desde un lead */
/** GET /leads/buscar?q= — autocompletado para prellenar el alta (busca en AMBOS tableros) */
export async function buscarLeads(q: string): Promise<LeadResumen[]> {
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
manejar401(res)
@@ -236,8 +238,9 @@ export async function buscarLeads(q: string): Promise<LeadResumen[]> {
}
export interface LeadCreado {
/** id de la oportunidad creada (la afluencia ES la oportunidad: id === oportunidadId) */
id: string
estado: string
estado: string // 'Afluencia'
oportunidadId: string | null
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
asignacion?: 'capturista' | 'round-robin'
@@ -250,9 +253,10 @@ export type ResultadoCrearLead =
| { ok: false; motivo: 'conexion' }
/**
* POST /leads — captura ligera de afluencia: crea el lead en Monday y su
* oportunidad en etapa Afluencia (round-robin). Si el teléfono ya existe el
* API responde 409 con el lead existente (motivo 'duplicado').
* POST /leads — captura ligera de afluencia: crea UN item en el tablero
* Oportunidades (etapa Afluencia, con todo el perfil; la asigna el capturista
* o el round-robin). Si el teléfono ya existe el API responde 409 con la
* oportunidad existente (motivo 'duplicado').
*/
export async function crearLead(payload: {
nombre: string

View File

@@ -115,20 +115,19 @@ describe('unidadesEnEtapa', () => {
})
describe('datosFunnel', () => {
it('ordena Leads → Oportunidades → Aparto o más → Ganado sin subir', () => {
it('ordena Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (todo desde porEtapa)', () => {
const funnel = datosFunnel(resumen())
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Oportunidades', 'Aparto o más', 'Ganado'])
expect(funnel.map((e) => e.valor)).toEqual([40, 20, 6, 2]) // Aparto(4) + Ganado(2) acumulados
const valores = funnel.map((e) => e.valor)
for (let i = 1; i < valores.length; i++) expect(valores[i]!).toBeLessThanOrEqual(valores[i - 1]!)
expect(funnel.map((e) => e.etapa)).toEqual(['Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
expect(funnel.map((e) => e.valor)).toEqual([8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
})
it('tolera etiquetas con otra capitalización', () => {
const r = resumen()
r.oportunidades.porEtapa = { aparto: 1, ' GANADO ': 3 }
r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
const funnel = datosFunnel(r)
expect(funnel[2]?.valor).toBe(4)
expect(funnel[3]?.valor).toBe(3)
expect(funnel[0]?.valor).toBe(3)
expect(funnel[3]?.valor).toBe(4)
expect(funnel[4]?.valor).toBe(3)
})
})

View File

@@ -104,8 +104,10 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number
}
/**
* Datos del funnel comercial: Leads → Oportunidades → Aparto o más → Ganado.
* "Aparto o más" es acumulado (Aparto + Ganado) para que el funnel no suba.
* Datos del funnel comercial, todo desde el tablero Oportunidades (la
* afluencia ES la oportunidad en etapa 'Afluencia'): Afluencia → Agendo Cita
* → Cotizador → Aparto o más → Ganado. "Aparto o más" es acumulado
* (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado.
*/
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
const porEtapa = resumen.oportunidades.porEtapa
@@ -115,8 +117,9 @@ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
.reduce((acc, [, v]) => acc + v, 0)
const apartoOMas = conteo('APARTO') + conteo('GANADO')
return [
{ etapa: 'Leads', valor: resumen.leads.total },
{ etapa: 'Oportunidades', valor: resumen.oportunidades.total },
{ etapa: 'Afluencia', valor: conteo('AFLUENCIA') },
{ etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') },
{ etapa: 'Cotizador', valor: conteo('COTIZADOR') },
{ etapa: 'Aparto o más', valor: apartoOMas },
{ etapa: 'Ganado', valor: conteo('GANADO') },
]

View File

@@ -53,8 +53,7 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
misOportunidades: { porEtapa: Record<string, number> } | null
notaOportunidades: string | null
misOportunidades: { porEtapa: Record<string, number> } // conteo de MIS oportunidades por etapa
conteos: {
leadsNuevosSemana: number
actividadesAbiertas: number