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:
206
api-gateway/scripts/migrar-afluencias-a-oportunidades.py
Normal file
206
api-gateway/scripts/migrar-afluencias-a-oportunidades.py
Normal 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()
|
||||||
@@ -160,8 +160,11 @@ const hoyMas = (dias: number): string => {
|
|||||||
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
|
// ---------- 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'.
|
* 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
|
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
|
||||||
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
|
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
|
||||||
* Un fallo aquí no rompe el alta.
|
* Un fallo aquí no rompe el alta.
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
descartarLead,
|
descartarLead,
|
||||||
ESTADOS_LEAD_PERMITIDOS,
|
ESTADOS_LEAD_PERMITIDOS,
|
||||||
} from './leads.controller.js'
|
} from './leads.controller.js'
|
||||||
import { mondayService, type LeadMonday } from '../services/monday.service.js'
|
import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js'
|
||||||
import { funnelService } from '../services/funnel.service.js'
|
|
||||||
import { automatizacionesService } from '../services/automatizaciones.service.js'
|
import { automatizacionesService } from '../services/automatizaciones.service.js'
|
||||||
|
|
||||||
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
|
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
|
||||||
@@ -19,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
leadCaptura: {
|
leadCaptura: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
},
|
},
|
||||||
|
leadOportunidad: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
usuario: {
|
usuario: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -30,37 +32,38 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
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 */
|
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
|
||||||
function mondayMock(
|
function mondayMock(
|
||||||
opts: {
|
opts: {
|
||||||
labelsOrigen?: string[]
|
labelsOrigen?: string[]
|
||||||
labelsDesarrollo?: string[]
|
labelsDesarrollo?: string[]
|
||||||
labelsMotivo?: string[]
|
labelsMotivo?: string[]
|
||||||
duplicado?: LeadMonday | null
|
duplicado?: OportunidadMonday | null
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||||
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
|
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) {
|
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||||
this.cambios.push({ boardId, itemId, columna, valor })
|
this.cambios.push({ boardId, itemId, columna, valor })
|
||||||
},
|
},
|
||||||
async crearLead(datos: Record<string, unknown>) {
|
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
|
||||||
this.leadsCreados.push(datos)
|
this.oportunidadesCreadas.push(datos)
|
||||||
return 'lead-nuevo-1'
|
return 'op-nueva-1'
|
||||||
},
|
},
|
||||||
async buscarLeadPorTelefono(_telefono: string) {
|
async buscarOportunidadPorTelefono(_telefono: string) {
|
||||||
return opts.duplicado ?? null
|
return opts.duplicado ?? null
|
||||||
},
|
},
|
||||||
async labelsDeColumna(_boardId: string, titulo: string) {
|
async labelsDeColumna(_boardId: string, titulo: string) {
|
||||||
if (titulo === 'Desarrollo') {
|
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
|
||||||
return opts.labelsDesarrollo ?? ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
|
if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO
|
||||||
}
|
return opts.labelsOrigen ?? LABELS_ORIGEN
|
||||||
if (titulo === 'Motivo No Compra') {
|
|
||||||
return opts.labelsMotivo ?? ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
|
|
||||||
}
|
|
||||||
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
|
|
||||||
},
|
},
|
||||||
async obtenerNombreItem(_itemId: string) {
|
async obtenerNombreItem(_itemId: string) {
|
||||||
return 'Lead Prueba'
|
return 'Lead Prueba'
|
||||||
@@ -69,46 +72,70 @@ function mondayMock(
|
|||||||
this.motivos.push(datos)
|
this.motivos.push(datos)
|
||||||
return 'motivo-item-1'
|
return 'motivo-item-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) {
|
||||||
/** Funnel mockeado: registra asegurados/movimientos; configurable para fallar */
|
return 'col_estado'
|
||||||
function funnelMock(opts: { oportunidadId?: string | null; fallaAsegurar?: boolean } = {}) {
|
},
|
||||||
|
async getItemConGrupo(itemId: string) {
|
||||||
return {
|
return {
|
||||||
asegurados: [] as Record<string, unknown>[],
|
id: itemId,
|
||||||
movimientos: [] as { leadId: string; etapa: string }[],
|
name: 'Lead Prueba',
|
||||||
async asegurarOportunidadAfluencia(lead: Record<string, unknown>) {
|
groupId: 'group_top',
|
||||||
if (opts.fallaAsegurar === true) throw new Error('Monday caído')
|
column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }],
|
||||||
this.asegurados.push(lead)
|
}
|
||||||
return 'op-1'
|
|
||||||
},
|
},
|
||||||
async moverOportunidadDeLead(leadId: string, etapa: string) {
|
async moverItemAGrupo(itemId: string, groupId: string) {
|
||||||
this.movimientos.push({ leadId, etapa })
|
this.movimientosGrupo.push({ itemId, groupId })
|
||||||
return true
|
|
||||||
},
|
|
||||||
async oportunidadDeLead(_leadId: string) {
|
|
||||||
return opts.oportunidadId === undefined ? 'op-1' : opts.oportunidadId
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
|
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
|
||||||
|
|
||||||
describe('crearLead (POST /api/leads)', () => {
|
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
|
||||||
it('crea el item en Monday con Estado NUEVO y devuelve su id', async () => {
|
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 monday = mondayMock()
|
||||||
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
|
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
|
||||||
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
|
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
|
||||||
expect(monday.leadsCreados).toEqual([
|
expect(monday.oportunidadesCreadas).toEqual([
|
||||||
{
|
{
|
||||||
nombre: 'Lead Prueba',
|
nombre: 'Lead Prueba',
|
||||||
telefono: '4771234567',
|
telefono: '4771234567',
|
||||||
email: undefined,
|
email: undefined,
|
||||||
origenLabel: 'Facebook',
|
|
||||||
desarrolloLabel: undefined,
|
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',
|
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 () => {
|
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',
|
email: 'lead@correo.com',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
})
|
})
|
||||||
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
|
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
|
||||||
expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
|
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 () => {
|
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í',
|
califica: 'Sí',
|
||||||
loQuierePara: 'Vivir ahí',
|
loQuierePara: 'Vivir ahí',
|
||||||
})
|
})
|
||||||
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
|
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
|
||||||
expect(monday.leadsCreados[0]).toMatchObject({
|
expect(monday.oportunidadesCreadas[0]).toMatchObject({
|
||||||
modeloLabel: 'HIMALAYA',
|
modeloLabel: 'HIMALAYA',
|
||||||
medioContacto: 'WhatsApp',
|
medioContacto: 'WhatsApp',
|
||||||
edad: 35,
|
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 () => {
|
it('duplicado por teléfono contra Oportunidades: no crea nada y devuelve el item existente', async () => {
|
||||||
const monday = mondayMock()
|
const existente = oportunidadMonday({ id: 'op-vieja-9', telefono: '4771234567' })
|
||||||
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: [],
|
|
||||||
}
|
|
||||||
const monday = mondayMock({ duplicado: existente })
|
const monday = mondayMock({ duplicado: existente })
|
||||||
const funnel = funnelMock()
|
const r = await crearLead(monday, datosBase)
|
||||||
const r = await crearLead(monday, datosBase, funnel)
|
|
||||||
expect(r).toEqual({ duplicado: existente })
|
expect(r).toEqual({ duplicado: existente })
|
||||||
expect(monday.leadsCreados).toEqual([])
|
expect(monday.oportunidadesCreadas).toEqual([])
|
||||||
expect(funnel.asegurados).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()
|
const monday = mondayMock()
|
||||||
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
|
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
|
||||||
/Desarrollo inválido/,
|
/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()
|
const monday = mondayMock()
|
||||||
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
|
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)', () => {
|
describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
|
||||||
it('la whitelist solo permite las transiciones del vendedor', () => {
|
it('la whitelist solo permite las transiciones del vendedor', () => {
|
||||||
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'])
|
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('DESCARTADO')
|
||||||
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
|
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()
|
const monday = mondayMock()
|
||||||
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO')
|
await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO')
|
||||||
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: '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()
|
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([
|
expect(monday.cambios).toEqual([
|
||||||
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' },
|
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Agendo Cita' },
|
||||||
{ boardId: '444', itemId: 'lead-1', columna: 'Fecha', valor: '2026-09-01' },
|
{ boardId: '555', itemId: 'op-1', columna: 'Fecha', valor: '2026-09-01' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
|
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
|
||||||
const monday = mondayMock()
|
const monday = mondayMock()
|
||||||
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA')
|
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA')
|
||||||
expect(monday.cambios).toHaveLength(1)
|
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 monday = mondayMock()
|
||||||
const funnel = funnelMock()
|
await cambiarEstadoLead(monday, 'op-1', 'CALIFICADO')
|
||||||
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', funnel)
|
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Calificado' }])
|
||||||
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' }])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
|
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 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(motivoItemId).toBe('motivo-item-1')
|
||||||
expect(monday.cambios).toEqual([
|
expect(monday.cambios).toEqual([
|
||||||
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' },
|
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' },
|
||||||
{ boardId: '444', itemId: 'lead-1', columna: 'Notas', valor: 'Se le hizo caro' },
|
{ 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([
|
expect(monday.motivos).toEqual([
|
||||||
{ nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' },
|
{ 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()
|
const monday = mondayMock()
|
||||||
await descartarLead(monday, 'lead-1', 'Timing')
|
await descartarLead(monday, 'op-1', 'Timing')
|
||||||
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }])
|
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' })
|
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 () => {
|
it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
|
||||||
const monday = mondayMock()
|
const monday = mondayMock()
|
||||||
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
|
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([])
|
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 () => {
|
it('si el motivo no es label del dropdown, solo mueve a Perdido (sin dropdown)', async () => {
|
||||||
const monday = mondayMock({ labelsMotivo: ['Otro'] })
|
const monday = mondayMock({ labelsMotivo: ['Otro'] })
|
||||||
const funnel = funnelMock({ oportunidadId: 'op-9' })
|
await descartarLead(monday, 'op-1', 'Precio')
|
||||||
await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel)
|
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' }])
|
||||||
expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' })
|
|
||||||
expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined()
|
expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined()
|
||||||
})
|
expect(monday.motivos).toHaveLength(1) // el registro en el catálogo se crea igual
|
||||||
|
|
||||||
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' })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('crearLeadEndpoint (POST /api/leads)', () => {
|
describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// El singleton Monday responde como si creara el lead; el funnel liga la oportunidad
|
// El singleton Monday responde como si creara la afluencia en Oportunidades
|
||||||
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
|
vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null)
|
||||||
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
|
vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9')
|
||||||
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-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)
|
vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined)
|
||||||
// Default: el capturista NO tiene Monday vinculado → asignación round-robin
|
// Default: el capturista NO tiene Monday vinculado → asignación round-robin
|
||||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||||
|
prismaMock.leadCaptura.create.mockResolvedValue({})
|
||||||
|
prismaMock.leadOportunidad.create.mockResolvedValue({})
|
||||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||||
})
|
})
|
||||||
@@ -355,27 +329,30 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
|||||||
return { res: { json, status } as unknown as Response, status, json }
|
return { res: { json, status } as unknown as Response, status, json }
|
||||||
}
|
}
|
||||||
|
|
||||||
it('201: registra la captura local con el usuario del token', async () => {
|
it('201: registra la captura local y el auto-mapping con el usuario del token', async () => {
|
||||||
prismaMock.leadCaptura.create.mockResolvedValue({})
|
|
||||||
const { res, status, json } = resFake()
|
const { res, status, json } = resFake()
|
||||||
|
|
||||||
await crearLeadEndpoint(reqFake(), res)
|
await crearLeadEndpoint(reqFake(), res)
|
||||||
|
|
||||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
leadId: 'lead-nuevo-9',
|
leadId: 'op-nueva-9',
|
||||||
oportunidadId: 'op-9',
|
oportunidadId: 'op-nueva-9',
|
||||||
usuarioId: 'u-1',
|
usuarioId: 'u-1',
|
||||||
nombreLead: 'Lead Prueba',
|
nombreLead: 'Lead Prueba',
|
||||||
telefono: '4771234567',
|
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(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 () => {
|
it('con Monday vinculado, la afluencia queda asignada al capturista (columna Asesor)', async () => {
|
||||||
prismaMock.leadCaptura.create.mockResolvedValue({})
|
|
||||||
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||||
const { res, status, json } = resFake()
|
const { res, status, json } = resFake()
|
||||||
|
|
||||||
@@ -383,15 +360,14 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
|||||||
|
|
||||||
// Bandera anti-carrera ANTES de asignar + asignación al capturista
|
// Bandera anti-carrera ANTES de asignar + asignación al capturista
|
||||||
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
|
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(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 () => {
|
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 })
|
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||||
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
|
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
|
||||||
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
@@ -400,7 +376,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
|||||||
await crearLeadEndpoint(reqFake(), res)
|
await crearLeadEndpoint(reqFake(), res)
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(201)
|
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()
|
espia.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -410,10 +386,12 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
|||||||
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
|
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
|
||||||
|
|
||||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
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)
|
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'))
|
prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
|
||||||
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const { res, status } = resFake()
|
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 => ({
|
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
||||||
id: 'lead-1',
|
id: 'lead-1',
|
||||||
nombre: 'Lead Prueba',
|
nombre: 'Lead Prueba',
|
||||||
@@ -439,31 +417,47 @@ describe('buscarLeads (GET /api/leads/buscar)', () => {
|
|||||||
...overrides,
|
...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 = [
|
const leads = [
|
||||||
lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
|
lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
|
||||||
lead({ id: 'b', telefono: '477 123 4567' }),
|
lead({ id: 'b', telefono: '477 123 4567' }),
|
||||||
lead({ id: 'c', email: 'Cliente@Correo.com' }),
|
|
||||||
lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
|
lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
|
||||||
]
|
]
|
||||||
expect(buscarLeads(leads, 'fernanda').map((l) => l.id)).toEqual(['a'])
|
const ops = [
|
||||||
expect(buscarLeads(leads, '123').map((l) => l.id)).toEqual(['b'])
|
oportunidadMonday({ id: 'op-c', email: 'Cliente@Correo.com' }),
|
||||||
expect(buscarLeads(leads, 'cliente@').map((l) => l.id)).toEqual(['c'])
|
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' })]
|
const leads = [lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ', email: 'andres@correo.com' })]
|
||||||
expect(buscarLeads(leads, 'maria')).toHaveLength(1)
|
expect(buscarLeads(leads, [], 'maria')).toHaveLength(1)
|
||||||
expect(buscarLeads(leads, 'andrés')).toHaveLength(1)
|
expect(buscarLeads(leads, [], 'andrés')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ordena por updatedAt descendente y limita a 15', () => {
|
it('ordena por updatedAt descendente (mezclando tableros) y limita a 15', () => {
|
||||||
const leads = Array.from({ length: 20 }, (_, i) =>
|
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` }),
|
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).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 () => {
|
it('el endpoint responde 400 con q de menos de 2 caracteres', async () => {
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
|
import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js'
|
||||||
import { funnelService, type FunnelService } from '../services/funnel.service.js'
|
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
||||||
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
|
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
|
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
|
||||||
import { AppError } from '../utils/errors.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
|
* 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
|
// 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
|
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({
|
const crearLeadSchema = z.object({
|
||||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||||
telefono: z.string().trim().min(1).optional(),
|
telefono: z.string().trim().min(1).optional(),
|
||||||
@@ -52,7 +65,7 @@ const descartarSchema = z.object({
|
|||||||
comentario: z.string().trim().min(1).optional(),
|
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
|
const LIMITE_BUSQUEDA_LEADS = 15
|
||||||
|
|
||||||
@@ -63,70 +76,109 @@ const sinAcentos = (s: string): string =>
|
|||||||
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
|
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
|
||||||
.toLowerCase()
|
.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),
|
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
|
||||||
* ordena por actualización reciente y limita a 15 resultados.
|
* 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)
|
const needle = sinAcentos(q)
|
||||||
return leads
|
const candidatos = [
|
||||||
.filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle)))
|
...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))
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
.slice(0, LIMITE_BUSQUEDA_LEADS)
|
.slice(0, LIMITE_BUSQUEDA_LEADS)
|
||||||
|
.map(({ updatedAt: _updatedAt, ...resto }) => resto)
|
||||||
}
|
}
|
||||||
|
|
||||||
type MondayLeads = Pick<
|
type MondayLeads = Pick<
|
||||||
MondayService,
|
MondayService,
|
||||||
| 'cambiarValorColumna'
|
| 'cambiarValorColumna'
|
||||||
| 'crearLead'
|
| 'crearOportunidadAfluencia'
|
||||||
| 'crearMotivoNoCompra'
|
| 'crearMotivoNoCompra'
|
||||||
| 'buscarLeadPorTelefono'
|
| 'buscarOportunidadPorTelefono'
|
||||||
| 'labelsDeColumna'
|
| 'labelsDeColumna'
|
||||||
| 'obtenerNombreItem'
|
| '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
|
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
|
||||||
* con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
|
* 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una
|
||||||
* El origen se valida contra los labels reales de la columna del board.
|
* oportunidad con ese número, no crea nada y devuelve { duplicado } (el
|
||||||
* Con `funnel`, el lead entra además al tablero Oportunidades en 'Afluencia';
|
* endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status
|
||||||
* un fallo de Monday en la oportunidad solo se loguea (el lead ya quedó).
|
* '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(
|
export async function crearLead(
|
||||||
monday: MondayLeads,
|
monday: MondayLeads,
|
||||||
datos: z.infer<typeof crearLeadSchema>,
|
datos: z.infer<typeof crearLeadSchema>,
|
||||||
funnel?: Pick<FunnelService, 'asegurarOportunidadAfluencia'>,
|
|
||||||
): Promise<ResultadoCrearLead> {
|
): Promise<ResultadoCrearLead> {
|
||||||
const boardLeads = config.monday.boards.leads
|
const boardOportunidades = config.monday.boards.oportunidades
|
||||||
|
|
||||||
if (datos.telefono !== undefined) {
|
if (datos.telefono !== undefined) {
|
||||||
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
|
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
|
||||||
if (existente) return { duplicado: existente }
|
if (existente) return { duplicado: existente }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (datos.origen !== undefined) {
|
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)) {
|
if (!labels.includes(datos.origen)) {
|
||||||
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
|
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (datos.desarrollo !== undefined) {
|
if (datos.desarrollo !== undefined) {
|
||||||
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
|
const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
|
||||||
if (!labels.includes(datos.desarrollo)) {
|
if (!labels.includes(datos.desarrollo)) {
|
||||||
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
|
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,
|
nombre: datos.nombre,
|
||||||
telefono: datos.telefono,
|
telefono: datos.telefono,
|
||||||
email: datos.email,
|
email: datos.email,
|
||||||
origenLabel: datos.origen,
|
|
||||||
desarrolloLabel: datos.desarrollo,
|
desarrolloLabel: datos.desarrollo,
|
||||||
notas: datos.notas,
|
origenLabel: datos.origen,
|
||||||
modeloLabel: datos.modelo,
|
modeloLabel: datos.modelo,
|
||||||
medioContacto: datos.medioContacto,
|
medioContacto: datos.medioContacto,
|
||||||
edad: datos.edad,
|
edad: datos.edad,
|
||||||
@@ -138,98 +190,67 @@ export async function crearLead(
|
|||||||
tipoEconomiaLabel: datos.tipoEconomia,
|
tipoEconomiaLabel: datos.tipoEconomia,
|
||||||
calificaLabel: datos.califica,
|
calificaLabel: datos.califica,
|
||||||
loQuierePara: datos.loQuierePara,
|
loQuierePara: datos.loQuierePara,
|
||||||
|
notas: datos.notas,
|
||||||
})
|
})
|
||||||
|
|
||||||
let oportunidadId: string | null = null
|
|
||||||
if (funnel !== undefined) {
|
|
||||||
try {
|
try {
|
||||||
oportunidadId = await funnel.asegurarOportunidadAfluencia({
|
await moverAGrupoSegunEstado(monday, boardOportunidades, id)
|
||||||
id,
|
|
||||||
nombre: datos.nombre,
|
|
||||||
telefono: datos.telefono,
|
|
||||||
email: datos.email,
|
|
||||||
desarrolloLabel: datos.desarrollo,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(`No se pudo crear la oportunidad de Afluencia del lead ${id}:`, err)
|
console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err)
|
||||||
}
|
}
|
||||||
}
|
return { id, oportunidadId: id }
|
||||||
return { id, oportunidadId }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
export async function cambiarEstadoLead(
|
||||||
monday: MondayLeads,
|
monday: MondayLeads,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
||||||
fecha?: string,
|
fecha?: string,
|
||||||
funnel?: Pick<FunnelService, 'moverOportunidadDeLead'>,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const boardLeads = config.monday.boards.leads
|
const boardOportunidades = config.monday.boards.oportunidades
|
||||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado)
|
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
|
||||||
if (estado === 'AGENDA CITA' && fecha !== undefined) {
|
if (estado === 'AGENDA CITA' && fecha !== undefined) {
|
||||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha)
|
await monday.cambiarValorColumna(boardOportunidades, 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
export async function descartarLead(
|
||||||
monday: MondayLeads,
|
monday: MondayLeads,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
motivo: string,
|
motivo: string,
|
||||||
comentario?: string,
|
comentario?: string,
|
||||||
funnel?: Pick<FunnelService, 'oportunidadDeLead'>,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const boardLeads = config.monday.boards.leads
|
|
||||||
const nombreItem = await monday.obtenerNombreItem(itemId)
|
|
||||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO')
|
|
||||||
if (comentario !== undefined) {
|
|
||||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
|
|
||||||
}
|
|
||||||
const motivoItemId = await 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
|
const boardOportunidades = config.monday.boards.oportunidades
|
||||||
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Estado', 'Perdido')
|
const nombreItem = await monday.obtenerNombreItem(itemId)
|
||||||
|
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
|
||||||
|
if (comentario !== undefined) {
|
||||||
|
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
|
||||||
|
}
|
||||||
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
|
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
|
||||||
if (labels.includes(motivo)) {
|
if (labels.includes(motivo)) {
|
||||||
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Motivo No Compra', motivo)
|
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
|
||||||
} else {
|
} else {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
|
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
|
||||||
}
|
}
|
||||||
}
|
return monday.crearMotivoNoCompra({
|
||||||
} catch (err) {
|
nombre: `${motivo} — ${nombreItem}`,
|
||||||
// eslint-disable-next-line no-console
|
categoria: motivo,
|
||||||
console.error(`No se pudo mover a 'Perdido' la oportunidad del lead ${itemId}:`, err)
|
...(comentario !== undefined ? { descripcion: comentario } : {}),
|
||||||
}
|
})
|
||||||
}
|
|
||||||
return motivoItemId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
|
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
|
||||||
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
|
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
const body = crearLeadSchema.parse(req.body)
|
const body = crearLeadSchema.parse(req.body)
|
||||||
const resultado = await crearLead(mondayService, body, funnelService)
|
const resultado = await crearLead(mondayService, body)
|
||||||
if ('duplicado' in resultado) {
|
if ('duplicado' in resultado) {
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
error: 'Ya existe un lead con ese teléfono',
|
error: 'Ya existe un lead con ese teléfono',
|
||||||
@@ -239,8 +260,9 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
|
// 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:
|
// no guarda quién dio de alta el lead). leadId = el id de la oportunidad
|
||||||
// el lead ya quedó creado en Monday.
|
// (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) {
|
if (req.usuarioToken?.sub) {
|
||||||
try {
|
try {
|
||||||
await prisma.leadCaptura.create({
|
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)
|
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
|
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
|
||||||
// queda asignado a él. La bandera rr:skip se prende ANTES de asignar para
|
// clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a
|
||||||
// que el webhook create_item no lo reparta por round-robin mientras Monday
|
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
|
||||||
// propaga la escritura (si la asignación falla, se apaga y cae al reparto).
|
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'
|
let asignacion: 'capturista' | 'round-robin' = 'round-robin'
|
||||||
if (req.usuarioToken?.sub) {
|
if (req.usuarioToken?.sub) {
|
||||||
try {
|
try {
|
||||||
@@ -280,17 +314,17 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
// 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? } */
|
/** POST /api/leads/:itemId/estado { estado, fecha? } */
|
||||||
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
|
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
const body = cambiarEstadoSchema.parse(req.body)
|
const body = cambiarEstadoSchema.parse(req.body)
|
||||||
const itemId = String(req.params['itemId'])
|
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 } : {}) })
|
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> {
|
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
const body = descartarSchema.parse(req.body)
|
const body = descartarSchema.parse(req.body)
|
||||||
const itemId = String(req.params['itemId'])
|
const itemId = String(req.params['itemId'])
|
||||||
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, funnelService)
|
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
|
||||||
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
|
res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
|
* 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> {
|
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
const q = String(req.query['q'] ?? '').trim()
|
const q = String(req.query['q'] ?? '').trim()
|
||||||
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
|
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
|
||||||
const encontrados = buscarLeads(await mondayService.getLeads(), q)
|
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
|
||||||
res.json(
|
res.json(buscarLeads(leads, oportunidades, q))
|
||||||
encontrados.map((l) => ({
|
|
||||||
id: l.id,
|
|
||||||
nombre: l.nombre,
|
|
||||||
telefono: l.telefono,
|
|
||||||
email: l.email,
|
|
||||||
estado: l.estado,
|
|
||||||
origen: l.origen,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Request, Response } from 'express'
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
runRoundRobin: vi.fn(),
|
runRoundRobin: vi.fn(),
|
||||||
escaneoOportunidades: vi.fn(),
|
escaneoOportunidades: vi.fn(),
|
||||||
asegurarAfluencia: vi.fn(),
|
|
||||||
moverAGrupo: 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', () => ({
|
vi.mock('../services/grupos-estado.service.js', () => ({
|
||||||
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
|
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
|
||||||
GRUPOS_POR_ESTADO: {
|
GRUPOS_POR_ESTADO: {
|
||||||
@@ -52,7 +45,6 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
|
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
|
||||||
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
|
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
|
||||||
mocks.asegurarAfluencia.mockResolvedValue('op-1')
|
|
||||||
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
|
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,31 +62,34 @@ describe('webhookMonday', () => {
|
|||||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||||
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
||||||
expect(mocks.escaneoOportunidades).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()
|
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(sendStatus).toHaveBeenCalledWith(200)
|
||||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999')
|
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', () => {
|
it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
|
||||||
const { res, sendStatus } = resFake()
|
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(sendStatus).toHaveBeenCalledWith(200)
|
||||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777')
|
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()
|
const { res, sendStatus } = resFake()
|
||||||
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
|
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
|
||||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||||
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
||||||
expect(mocks.asegurarAfluencia).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('change_status_column_value en Oportunidades dispara el escaneo', () => {
|
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 () => {
|
it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
|
||||||
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
|
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
|
||||||
const { res, sendStatus } = resFake()
|
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)
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||||
// Deja que el rechazo se procese para no ensuciar otros tests
|
// Deja que el rechazo se procese para no ensuciar otros tests
|
||||||
await new Promise((r) => setImmediate(r))
|
await new Promise((r) => setImmediate(r))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Request, Response } from 'express'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { automatizacionesService } from '../services/automatizaciones.service.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 { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
||||||
import { mondayService } from '../services/monday.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 { type, boardId, itemId, pulseId } = parsed.data.event
|
||||||
const esBoardLeads = String(boardId) === config.monday.boards.leads
|
|
||||||
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
|
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
|
||||||
const idEvento = itemId ?? pulseId
|
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
|
// Monday registra el webhook como 'create_item' pero el payload real llega
|
||||||
// como 'create_pulse'; se aceptan ambos.
|
// 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)
|
const id = String(idEvento)
|
||||||
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
|
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
|
||||||
void automatizacionesService
|
void automatizacionesService
|
||||||
@@ -68,18 +74,6 @@ export function webhookMonday(req: Request, res: Response): void {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(`[webhook monday] error en round-robin item=${id}:`, err)
|
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.
|
// Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica.
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
claveVentaCreada,
|
claveVentaCreada,
|
||||||
esEstadoTerminal,
|
esEstadoTerminal,
|
||||||
esEtapa,
|
esEtapa,
|
||||||
leadsInactivos,
|
oportunidadesInactivas,
|
||||||
leadsSinContactar,
|
oportunidadesSinContactar,
|
||||||
parseRrUserIds,
|
parseRrUserIds,
|
||||||
vendedorEnTurno,
|
vendedorEnTurno,
|
||||||
type StateStore,
|
type StateStore,
|
||||||
} from './automatizaciones.service.js'
|
} from './automatizaciones.service.js'
|
||||||
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
|
import type { OportunidadMonday, UsuarioMonday } from './monday.service.js'
|
||||||
|
|
||||||
// ---------- Fixtures ----------
|
// ---------- Fixtures ----------
|
||||||
|
|
||||||
@@ -24,27 +24,19 @@ const VENDEDORES: UsuarioMonday[] = [
|
|||||||
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
|
{ 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 {
|
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
nombre: `Cliente ${id}`,
|
nombre: `Cliente ${id}`,
|
||||||
createdAt: '2026-08-01T09:00:00Z',
|
createdAt: '2026-08-01T09:00:00Z',
|
||||||
etapa: 'Cotizador',
|
updatedAt: '2026-08-01T10:00:00Z',
|
||||||
|
etapa: 'Afluencia',
|
||||||
unidadCodigo: 'U-101',
|
unidadCodigo: 'U-101',
|
||||||
precioFinal: 2500000,
|
precioFinal: 2500000,
|
||||||
|
asesorIds: [],
|
||||||
|
telefono: '',
|
||||||
|
email: '',
|
||||||
|
origen: '',
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,8 +55,8 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
|
/** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */
|
||||||
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) {
|
function mondayMock(oportunidades: OportunidadMonday[] = []) {
|
||||||
return {
|
return {
|
||||||
asignaciones: [] as { itemId: string; userId: number }[],
|
asignaciones: [] as { itemId: string; userId: number }[],
|
||||||
updates: [] as { itemId: string; body: string }[],
|
updates: [] as { itemId: string; body: string }[],
|
||||||
@@ -78,13 +70,10 @@ function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []
|
|||||||
}[],
|
}[],
|
||||||
apartados: [] as Record<string, unknown>[],
|
apartados: [] as Record<string, unknown>[],
|
||||||
ventas: [] as Record<string, unknown>[],
|
ventas: [] as Record<string, unknown>[],
|
||||||
async getLeads() {
|
|
||||||
return leads
|
|
||||||
},
|
|
||||||
async getOportunidades() {
|
async getOportunidades() {
|
||||||
return oportunidades
|
return oportunidades
|
||||||
},
|
},
|
||||||
async asignarVendedorALead(itemId: string, userId: number) {
|
async asignarAsesorAOportunidad(itemId: string, userId: number) {
|
||||||
this.asignaciones.push({ itemId, userId })
|
this.asignaciones.push({ itemId, userId })
|
||||||
},
|
},
|
||||||
async crearUpdateEnItem(itemId: string, body: string) {
|
async crearUpdateEnItem(itemId: string, body: string) {
|
||||||
@@ -136,12 +125,14 @@ describe('parseRrUserIds', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('esEstadoTerminal', () => {
|
describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
|
||||||
it('reconoce DESCARTADO sin importar mayúsculas ni espacios', () => {
|
it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => {
|
||||||
expect(esEstadoTerminal('DESCARTADO')).toBe(true)
|
expect(esEstadoTerminal('GANADO')).toBe(true)
|
||||||
expect(esEstadoTerminal(' descartado ')).toBe(true)
|
expect(esEstadoTerminal(' perdido ')).toBe(true)
|
||||||
expect(esEstadoTerminal('NUEVO')).toBe(false)
|
expect(esEstadoTerminal('Abandono')).toBe(true)
|
||||||
expect(esEstadoTerminal('CONTACTADO')).toBe(false)
|
expect(esEstadoTerminal('Afluencia')).toBe(false)
|
||||||
|
expect(esEstadoTerminal('Contactado')).toBe(false)
|
||||||
|
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
|
||||||
expect(esEstadoTerminal('')).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')
|
const ahora = new Date('2026-08-22T00:00:00Z')
|
||||||
|
|
||||||
it('incluye leads activos que superan el umbral de días', () => {
|
it('incluye oportunidades en etapa temprana CON asesor que superan el umbral de días', () => {
|
||||||
const leads = [
|
const ops = [
|
||||||
lead('1', { updatedAt: '2026-08-10T00:00:00Z' }), // 12 días → inactivo (umbral 10)
|
oportunidad('1', { updatedAt: '2026-08-10T00:00:00Z', asesorIds: [101] }), // 12 días → inactiva (umbral 10)
|
||||||
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // 2 días → activo
|
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // 2 días → activa
|
||||||
lead('3', { updatedAt: '2026-07-01T00:00:00Z', estado: 'DESCARTADO' }), // terminal → excluido
|
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)
|
const inactivas = oportunidadesInactivas(ops, ahora, 10)
|
||||||
expect(inactivos.map((l) => l.id)).toEqual(['1'])
|
expect(inactivas.map((o) => o.id)).toEqual(['1', '6'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('un lead actualizado hace exactamente N días aún no supera el umbral', () => {
|
it('una oportunidad actualizada 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
|
const ops = [oportunidad('1', { updatedAt: '2026-08-12T00:00:00Z', asesorIds: [101] })] // justo 10 días
|
||||||
expect(leadsInactivos(leads, ahora, 10)).toEqual([])
|
expect(oportunidadesInactivas(ops, ahora, 10)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignora fechas inválidas', () => {
|
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)', () => {
|
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-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-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')
|
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) ----------
|
// ---------- Servicio (monday mockeado, sin red) ----------
|
||||||
|
|
||||||
describe('AutomatizacionesService.runRoundRobin', () => {
|
describe('AutomatizacionesService.runRoundRobin', () => {
|
||||||
it('asigna vendedores en rotación a leads sin persona y persiste el índice', async () => {
|
it('asigna asesores en rotación a afluencias sin persona y persiste el índice', async () => {
|
||||||
const monday = mondayMock([lead('1'), lead('2'), lead('3'), lead('4')])
|
const monday = mondayMock([oportunidad('1'), oportunidad('2'), oportunidad('3'), oportunidad('4')])
|
||||||
const state = stateEnMemoria()
|
const state = stateEnMemoria()
|
||||||
const svc = crearServicio(monday, state)
|
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(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
|
||||||
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
|
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)
|
// Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
|
||||||
const monday2 = mondayMock([lead('5')])
|
const monday2 = mondayMock([oportunidad('5')])
|
||||||
const svc2 = new AutomatizacionesService(monday2, state)
|
const svc2 = new AutomatizacionesService(monday2, state)
|
||||||
const r2 = await svc2.runRoundRobin()
|
const r2 = await svc2.runRoundRobin()
|
||||||
expect(r2.asignados).toBe(1)
|
expect(r2.asignados).toBe(1)
|
||||||
@@ -213,22 +207,28 @@ describe('AutomatizacionesService.runRoundRobin', () => {
|
|||||||
expect(state.mapa.get('rr:indice')).toBe('2')
|
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([
|
const monday = mondayMock([
|
||||||
lead('1'),
|
oportunidad('1'),
|
||||||
lead('2', { vendedorIds: [101] }), // ya asignado
|
oportunidad('2', { asesorIds: [101] }), // ya asignada
|
||||||
lead('3', { estado: 'DESCARTADO' }), // terminal
|
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 svc = crearServicio(monday, stateEnMemoria())
|
||||||
const r = await svc.runRoundRobin()
|
const r = await svc.runRoundRobin()
|
||||||
expect(r.asignados).toBe(1)
|
expect(r.asignados).toBe(2)
|
||||||
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
|
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 () => {
|
it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => {
|
||||||
const monday = mondayMock([lead('1'), lead('2')])
|
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
|
||||||
const state = stateEnMemoria()
|
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
|
state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
|
||||||
const svc = crearServicio(monday, state)
|
const svc = crearServicio(monday, state)
|
||||||
const r = await svc.runRoundRobin()
|
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 () => {
|
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 svc = crearServicio(monday, stateEnMemoria())
|
||||||
const r = await svc.runRoundRobin('2')
|
const r = await svc.runRoundRobin('2')
|
||||||
expect(r.asignados).toBe(1)
|
expect(r.asignados).toBe(1)
|
||||||
@@ -271,13 +271,13 @@ describe('AutomatizacionesService.runRoundRobin', () => {
|
|||||||
|
|
||||||
describe('AutomatizacionesService.escaneoInactividad', () => {
|
describe('AutomatizacionesService.escaneoInactividad', () => {
|
||||||
const ahora = new Date('2026-08-22T09:00:00Z')
|
const ahora = new Date('2026-08-22T09:00:00Z')
|
||||||
const leadsViejos = () => [
|
const oportunidadesViejas = () => [
|
||||||
lead('1', { updatedAt: '2026-08-01T00:00:00Z' }), // 21 días inactivo
|
oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] }), // 21 días inactiva
|
||||||
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // activo
|
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // activa
|
||||||
]
|
]
|
||||||
|
|
||||||
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
|
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 state = stateEnMemoria()
|
||||||
const svc = crearServicio(monday, state)
|
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 () => {
|
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 state = stateEnMemoria()
|
||||||
const svc = crearServicio(monday, state)
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
|
|||||||
expect(monday.updates[0]?.body).toContain('21 días')
|
expect(monday.updates[0]?.body).toContain('21 días')
|
||||||
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined()
|
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') })
|
const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
|
||||||
expect(r2.recordatoriosEnviados).toBe(0)
|
expect(r2.recordatoriosEnviados).toBe(0)
|
||||||
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
|
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
|
||||||
@@ -319,15 +319,15 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
|
|||||||
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
||||||
const ahora = new Date('2026-08-22T09:00:00Z')
|
const ahora = new Date('2026-08-22T09:00:00Z')
|
||||||
|
|
||||||
it('el round-robin crea "Contactar a {lead}" tipo Llamada, Abierto, ligada al lead', async () => {
|
it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
|
||||||
const monday = mondayMock([lead('1'), lead('2')])
|
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
|
||||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||||
|
|
||||||
const r = await svc.runRoundRobin()
|
const r = await svc.runRoundRobin()
|
||||||
expect(r.asignados).toBe(2)
|
expect(r.asignados).toBe(2)
|
||||||
expect(monday.actividades).toHaveLength(2)
|
expect(monday.actividades).toHaveLength(2)
|
||||||
expect(monday.actividades[0]).toMatchObject({
|
expect(monday.actividades[0]).toMatchObject({
|
||||||
nombre: 'Contactar a Lead 1',
|
nombre: 'Contactar a Cliente 1',
|
||||||
tipoLabel: 'Llamada',
|
tipoLabel: 'Llamada',
|
||||||
responsableId: 101,
|
responsableId: 101,
|
||||||
estadoLabel: 'Abierto',
|
estadoLabel: 'Abierto',
|
||||||
@@ -343,24 +343,24 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
|
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 })
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
|
||||||
await svc.runRoundRobin()
|
await svc.runRoundRobin()
|
||||||
expect(monday.actividades).toEqual([])
|
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 monday = mondayMock([])
|
||||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
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({
|
expect(monday.actividades[0]).toMatchObject({
|
||||||
nombre: 'Contactar a Lead Propio',
|
nombre: 'Contactar a Lead Propio',
|
||||||
responsableId: 555,
|
responsableId: 555,
|
||||||
estadoLabel: 'Abierto',
|
estadoLabel: 'Abierto',
|
||||||
leadItemId: 'lead-77',
|
leadItemId: 'op-77',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -368,24 +368,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
|||||||
const monday = mondayMock([])
|
const monday = mondayMock([])
|
||||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
|
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([])
|
expect(monday.actividades).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => {
|
it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => {
|
||||||
const monday = mondayMock([
|
const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
|
||||||
lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor
|
|
||||||
lead('2', { updatedAt: '2026-08-01T00:00:00Z' }), // inactivo sin vendedor
|
|
||||||
])
|
|
||||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||||
|
|
||||||
const r = await svc.escaneoInactividad({ preview: false, ahora })
|
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).toHaveLength(1)
|
||||||
expect(monday.actividades[0]).toMatchObject({
|
expect(monday.actividades[0]).toMatchObject({
|
||||||
nombre: 'Retomar contacto con Lead 1',
|
nombre: 'Retomar contacto con Cliente 1',
|
||||||
tipoLabel: 'Llamada',
|
tipoLabel: 'Llamada',
|
||||||
responsableId: 103,
|
responsableId: 103,
|
||||||
estadoLabel: 'Abierto',
|
estadoLabel: 'Abierto',
|
||||||
@@ -394,14 +391,14 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('preview de inactividad no crea actividades', async () => {
|
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 })
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||||
await svc.escaneoInactividad({ preview: true, ahora })
|
await svc.escaneoInactividad({ preview: true, ahora })
|
||||||
expect(monday.actividades).toEqual([])
|
expect(monday.actividades).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('un fallo al crear la actividad no rompe la asignación', async () => {
|
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'))
|
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
|
||||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
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', () => {
|
// ---------- Escalado de afluencias sin contactar ----------
|
||||||
it('ABANDONO también es terminal', () => {
|
|
||||||
expect(esEstadoTerminal('ABANDONO')).toBe(true)
|
|
||||||
expect(esEstadoTerminal('Abandono')).toBe(true)
|
|
||||||
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- Ronda 2: escalado de leads sin contactar ----------
|
describe('oportunidadesSinContactar (regla de escalado por horas)', () => {
|
||||||
|
|
||||||
describe('leadsSinContactar (regla de escalado por horas)', () => {
|
|
||||||
const ahora = new Date('2026-08-22T12:00:00Z')
|
const ahora = new Date('2026-08-22T12:00:00Z')
|
||||||
|
|
||||||
it('incluye leads asignados en NUEVO / Intento de contacto que superan el umbral', () => {
|
it('incluye oportunidades asignadas en Afluencia / Intento de contacto que superan el umbral', () => {
|
||||||
const leads = [
|
const ops = [
|
||||||
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
|
oportunidad('1', { asesorIds: [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
|
oportunidad('2', { asesorIds: [102], etapa: '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
|
oportunidad('3', { asesorIds: [103], etapa: 'Contactado', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactada
|
||||||
lead('4', { estado: 'NUEVO', updatedAt: '2026-08-01T12:00:00Z' }), // sin vendedor → no aplica
|
oportunidad('4', { etapa: 'Afluencia', updatedAt: '2026-08-01T12:00:00Z' }), // sin asesor → no aplica
|
||||||
lead('5', { vendedorIds: [101], estado: 'DESCARTADO', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
|
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', () => {
|
it('una oportunidad con exactamente N horas aún no supera el umbral', () => {
|
||||||
const leads = [lead('1', { vendedorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
|
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
|
||||||
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
|
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignora fechas inválidas', () => {
|
it('ignora fechas inválidas', () => {
|
||||||
const leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })]
|
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })]
|
||||||
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
|
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('claves de dedup de ronda 2', () => {
|
describe('claves de dedup', () => {
|
||||||
it('una clave por lead/oportunidad', () => {
|
it('una clave por oportunidad', () => {
|
||||||
expect(claveEscalado('123')).toBe('escalado:123')
|
expect(claveEscalado('123')).toBe('escalado:123')
|
||||||
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
|
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
|
||||||
expect(claveVentaCreada('456')).toBe('venta-creada: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 ahora = new Date('2026-08-22T12:00:00Z')
|
||||||
const configAuto = config.automatizaciones as Record<string, unknown>
|
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']
|
const anterior = configAuto['coordinadorUserId']
|
||||||
configAuto['coordinadorUserId'] = 0
|
configAuto['coordinadorUserId'] = 0
|
||||||
try {
|
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 state = stateEnMemoria()
|
||||||
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
||||||
|
|
||||||
@@ -481,7 +470,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
|||||||
expect(r.escalados).toBe(1)
|
expect(r.escalados).toBe(1)
|
||||||
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
|
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
|
||||||
expect(monday.actividades[0]).toMatchObject({
|
expect(monday.actividades[0]).toMatchObject({
|
||||||
nombre: 'URGENTE: lead sin contactar — Lead 1',
|
nombre: 'URGENTE: oportunidad sin contactar — Cliente 1',
|
||||||
responsableId: 102,
|
responsableId: 102,
|
||||||
estadoLabel: 'Abierto',
|
estadoLabel: 'Abierto',
|
||||||
leadItemId: '1',
|
leadItemId: '1',
|
||||||
@@ -498,7 +487,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
|||||||
const anterior = configAuto['coordinadorUserId']
|
const anterior = configAuto['coordinadorUserId']
|
||||||
configAuto['coordinadorUserId'] = 999
|
configAuto['coordinadorUserId'] = 999
|
||||||
try {
|
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 svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||||
const r = await svc.escaneoEscalado({ ahora })
|
const r = await svc.escaneoEscalado({ ahora })
|
||||||
expect(r.escalados).toBe(1)
|
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']
|
const anterior = configAuto['coordinadorUserId']
|
||||||
configAuto['coordinadorUserId'] = 0
|
configAuto['coordinadorUserId'] = 0
|
||||||
try {
|
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 state = stateEnMemoria()
|
||||||
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
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']
|
const anterior = configAuto['coordinadorUserId']
|
||||||
configAuto['coordinadorUserId'] = 0
|
configAuto['coordinadorUserId'] = 0
|
||||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
try {
|
try {
|
||||||
const monday = mondayMock([
|
const monday = mondayMock([
|
||||||
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
|
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
|
||||||
lead('2', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
|
oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
|
||||||
])
|
])
|
||||||
vi.spyOn(monday, 'crearUpdateEnItem')
|
vi.spyOn(monday, 'crearUpdateEnItem')
|
||||||
.mockRejectedValueOnce(new Error('Monday caído'))
|
.mockRejectedValueOnce(new Error('Monday caído'))
|
||||||
@@ -544,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
|||||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||||
|
|
||||||
const r = await svc.escaneoEscalado({ ahora })
|
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)
|
expect(r.candidatos).toHaveLength(2)
|
||||||
} finally {
|
} finally {
|
||||||
configAuto['coordinadorUserId'] = anterior
|
configAuto['coordinadorUserId'] = anterior
|
||||||
@@ -553,11 +542,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- Ronda 2: Oportunidad → Aparto / Ganado ----------
|
// ---------- Oportunidad → Aparto / Ganado ----------
|
||||||
|
|
||||||
describe('AutomatizacionesService.escaneoOportunidades', () => {
|
describe('AutomatizacionesService.escaneoOportunidades', () => {
|
||||||
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
|
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 state = stateEnMemoria()
|
||||||
const svc = crearServicio(monday, state)
|
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 () => {
|
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 state = stateEnMemoria()
|
||||||
const svc = crearServicio(monday, state)
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
@@ -584,7 +573,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
|
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 state = stateEnMemoria()
|
||||||
const svc = crearServicio(monday, state)
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
@@ -599,7 +588,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ignora oportunidades en otras etapas', async () => {
|
it('ignora oportunidades en otras etapas', async () => {
|
||||||
const monday = mondayMock([], [
|
const monday = mondayMock([
|
||||||
oportunidad('10', { etapa: 'Cotizador' }),
|
oportunidad('10', { etapa: 'Cotizador' }),
|
||||||
oportunidad('11', { etapa: 'Perdido' }),
|
oportunidad('11', { etapa: 'Perdido' }),
|
||||||
oportunidad('12', { etapa: 'Afluencia' }),
|
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 () => {
|
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'))
|
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
|
||||||
const state = stateEnMemoria()
|
const state = stateEnMemoria()
|
||||||
const svc = crearServicio(monday, state)
|
const svc = crearServicio(monday, state)
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { AppError } from '../utils/errors.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'
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatizaciones sobre el tablero Leads de Monday:
|
* Automatizaciones sobre el tablero Oportunidades de Monday (la afluencia ES
|
||||||
* (a) asignación round-robin de vendedores a leads sin persona asignada
|
* la oportunidad en etapa 'Afluencia'):
|
||||||
* (b) recordatorio de leads inactivos (> INACTIVIDAD_DIAS sin actualizarse)
|
* (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
|
* El estado (índice RR, cache de vendedores, recordatorios enviados) vive en la
|
||||||
* tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir
|
* tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir
|
||||||
* recordatorios el mismo día.
|
* recordatorios el mismo día.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO
|
// Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo
|
||||||
// más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado,
|
// activo del asesor (fuera de seguimiento).
|
||||||
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
|
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const
|
||||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
|
|
||||||
|
|
||||||
// Estados en los que un lead asignado aún no ha sido contactado de verdad;
|
// Etapas tempranas que vigilan los escaneos de inactividad/escalado
|
||||||
// si permanece así más de LEAD_ESCALADO_HORAS horas se escala al coordinador.
|
export const ETAPAS_SEGUIMIENTO = ['AFLUENCIA', 'CONTACTADO', 'INTENTO DE CONTACTO'] as const
|
||||||
export const ESTADOS_SIN_CONTACTAR = ['NUEVO', '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
|
// Etapas del tablero Oportunidades que disparan las reglas de ronda 2
|
||||||
export const ETAPA_APARTO = 'APARTO'
|
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()
|
const normalizado = estado.trim().toUpperCase()
|
||||||
return terminales.some((t) => t.toUpperCase() === normalizado)
|
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`.
|
* `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
|
const limiteMs = diasInactividad * DIA_MS
|
||||||
return leads.filter((lead) => {
|
return oportunidades.filter((op) => {
|
||||||
if (esEstadoTerminal(lead.estado)) return false
|
if (op.asesorIds.length === 0) return false
|
||||||
const t = Date.parse(lead.updatedAt)
|
if (!ETAPAS_SEGUIMIENTO.some((e) => e === op.etapa.trim().toUpperCase())) return false
|
||||||
|
const t = Date.parse(op.updatedAt)
|
||||||
if (Number.isNaN(t)) return false
|
if (Number.isNaN(t)) return false
|
||||||
return ahora.getTime() - t > limiteMs
|
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 {
|
export function claveRecordatorio(itemId: string, fecha: Date): string {
|
||||||
return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}`
|
return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
|
export function diasSinActividad(op: Pick<OportunidadMonday, 'updatedAt'>, ahora: Date): number {
|
||||||
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS)
|
return Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / DIA_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leads ASIGNADOS (con vendedor) que siguen en estado NUEVO / Intento de contacto
|
* Oportunidades ASIGNADAS (con asesor) que siguen en Afluencia /
|
||||||
* y llevan más de `horasUmbral` horas sin actualizarse: candidatos a escalar.
|
* Intento de contacto y llevan más de `horasUmbral` horas sin actualizarse:
|
||||||
* (El round-robin actualiza el item al asignar, así que el reloj arranca ahí.)
|
* 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
|
const limiteMs = horasUmbral * HORA_MS
|
||||||
return leads.filter((lead) => {
|
return oportunidades.filter((op) => {
|
||||||
if (lead.vendedorIds.length === 0) return false
|
if (op.asesorIds.length === 0) return false
|
||||||
if (esEstadoTerminal(lead.estado)) return false
|
if (!ETAPAS_SIN_CONTACTAR.some((e) => e === op.etapa.trim().toUpperCase())) return false
|
||||||
if (!ESTADOS_SIN_CONTACTAR.some((e) => e === lead.estado.trim().toUpperCase())) return false
|
const t = Date.parse(op.updatedAt)
|
||||||
const t = Date.parse(lead.updatedAt)
|
|
||||||
if (Number.isNaN(t)) return false
|
if (Number.isNaN(t)) return false
|
||||||
return ahora.getTime() - t > limiteMs
|
return ahora.getTime() - t > limiteMs
|
||||||
})
|
})
|
||||||
@@ -141,9 +157,8 @@ export function esEtapa(etapa: string, esperada: string): boolean {
|
|||||||
|
|
||||||
type MondayLeads = Pick<
|
type MondayLeads = Pick<
|
||||||
MondayService,
|
MondayService,
|
||||||
| 'getLeads'
|
|
||||||
| 'getOportunidades'
|
| 'getOportunidades'
|
||||||
| 'asignarVendedorALead'
|
| 'asignarAsesorAOportunidad'
|
||||||
| 'crearUpdateEnItem'
|
| 'crearUpdateEnItem'
|
||||||
| 'getUsuariosEquipo'
|
| 'getUsuariosEquipo'
|
||||||
| 'crearActividad'
|
| 'crearActividad'
|
||||||
@@ -279,24 +294,25 @@ export class AutomatizacionesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Una pasada de round-robin: asigna vendedor a todo lead sin persona asignada
|
* Una pasada de round-robin: asigna asesor a toda oportunidad en etapa
|
||||||
* y con estado no terminal. Si se pasa `soloItemId`, solo se evalúa ese item
|
* 'Afluencia' sin persona asignada (las creadas por el cotizador nacen en
|
||||||
* (ruta del webhook create_item).
|
* '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> {
|
async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
|
||||||
const leads = await this.monday.getLeads()
|
const oportunidades = await this.monday.getOportunidades()
|
||||||
const sinAsignar = leads.filter(
|
const sinAsignar = oportunidades.filter(
|
||||||
(l) =>
|
(o) =>
|
||||||
l.vendedorIds.length === 0 &&
|
o.asesorIds.length === 0 &&
|
||||||
!esEstadoTerminal(l.estado) &&
|
o.etapa.trim().toUpperCase() === 'AFLUENCIA' &&
|
||||||
(soloItemId === undefined || l.id === soloItemId),
|
(soloItemId === undefined || o.id === soloItemId),
|
||||||
)
|
)
|
||||||
// Los leads capturados desde una app con sesión se asignan a su capturista
|
// Las afluencias capturadas desde una app con sesión se asignan a su
|
||||||
// (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday
|
// capturista (bandera rr:skip='capturista'): sin este filtro, la latencia
|
||||||
// hacía que el webhook los viera sin vendedor y pisara la asignación.
|
// de Monday hacía que el webhook las viera sin asesor y pisara la asignación.
|
||||||
const pendientes: typeof sinAsignar = []
|
const pendientes: typeof sinAsignar = []
|
||||||
for (const l of sinAsignar) {
|
for (const o of sinAsignar) {
|
||||||
if ((await this.state.get(`${CLAVE_RR_SKIP}${l.id}`)) !== 'capturista') pendientes.push(l)
|
if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o)
|
||||||
}
|
}
|
||||||
if (pendientes.length === 0) {
|
if (pendientes.length === 0) {
|
||||||
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
|
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
|
if (!Number.isInteger(indice) || indice < 0) indice = 0
|
||||||
|
|
||||||
const detalle: AsignacionRR[] = []
|
const detalle: AsignacionRR[] = []
|
||||||
for (const lead of pendientes) {
|
for (const op of pendientes) {
|
||||||
const vendedor = vendedorEnTurno(vendedores, indice)
|
const vendedor = vendedorEnTurno(vendedores, indice)
|
||||||
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
|
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) {
|
if (this.actividadesEnabled) {
|
||||||
await this.crearActividadSegura({
|
await this.crearActividadSegura({
|
||||||
nombre: `Contactar a ${lead.nombre}`,
|
nombre: `Contactar a ${op.nombre}`,
|
||||||
responsableId: vendedor.id,
|
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++
|
indice++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,18 +345,19 @@ export class AutomatizacionesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asigna el lead a quien lo capturó desde una app del ecosistema (su usuario
|
* Asigna la afluencia a quien la capturó desde una app del ecosistema (su
|
||||||
* Monday vinculado) y le crea la actividad de contacto — el mismo paquete
|
* usuario Monday vinculado, columna Asesor de Oportunidades) y le crea la
|
||||||
* que haría el round-robin, pero para el capturista. No hay carrera con el
|
* actividad de contacto — el mismo paquete que haría el round-robin, pero
|
||||||
* webhook create_item: runRoundRobin solo toma leads sin persona asignada.
|
* 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> {
|
async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise<void> {
|
||||||
await this.monday.asignarVendedorALead(leadId, vendedorId)
|
await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId)
|
||||||
if (this.actividadesEnabled) {
|
if (this.actividadesEnabled) {
|
||||||
await this.crearActividadSegura({
|
await this.crearActividadSegura({
|
||||||
nombre: `Contactar a ${leadNombre}`,
|
nombre: `Contactar a ${nombre}`,
|
||||||
responsableId: vendedorId,
|
responsableId: vendedorId,
|
||||||
leadItemId: leadId,
|
leadItemId: oportunidadId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,38 +369,38 @@ export class AutomatizacionesService {
|
|||||||
async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
|
async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
|
||||||
const ahora = opciones.ahora ?? new Date()
|
const ahora = opciones.ahora ?? new Date()
|
||||||
const diasUmbral = config.automatizaciones.inactividadDias
|
const diasUmbral = config.automatizaciones.inactividadDias
|
||||||
const leads = await this.monday.getLeads()
|
const oportunidades = await this.monday.getOportunidades()
|
||||||
const inactivos = leadsInactivos(leads, ahora, diasUmbral)
|
const inactivas = oportunidadesInactivas(oportunidades, ahora, diasUmbral)
|
||||||
|
|
||||||
const candidatos: CandidatoInactividad[] = []
|
const candidatos: CandidatoInactividad[] = []
|
||||||
let recordatoriosEnviados = 0
|
let recordatoriosEnviados = 0
|
||||||
for (const lead of inactivos) {
|
for (const op of inactivas) {
|
||||||
const clave = claveRecordatorio(lead.id, ahora)
|
const clave = claveRecordatorio(op.id, ahora)
|
||||||
const yaEnviado = (await this.state.get(clave)) !== null
|
const yaEnviado = (await this.state.get(clave)) !== null
|
||||||
const dias = diasSinActividad(lead, ahora)
|
const dias = diasSinActividad(op, ahora)
|
||||||
candidatos.push({
|
candidatos.push({
|
||||||
itemId: lead.id,
|
itemId: op.id,
|
||||||
lead: lead.nombre,
|
lead: op.nombre,
|
||||||
estado: lead.estado,
|
estado: op.etapa,
|
||||||
updatedAt: lead.updatedAt,
|
updatedAt: op.updatedAt,
|
||||||
diasInactivo: dias,
|
diasInactivo: dias,
|
||||||
yaRecordadoHoy: yaEnviado,
|
yaRecordadoHoy: yaEnviado,
|
||||||
})
|
})
|
||||||
if (opciones.preview || yaEnviado) continue
|
if (opciones.preview || yaEnviado) continue
|
||||||
const body =
|
const body =
|
||||||
`⚠️ Recordatorio automático: este lead lleva ${dias} días sin actualizarse ` +
|
`⚠️ Recordatorio automático: esta oportunidad lleva ${dias} días sin actualizarse ` +
|
||||||
`(última actividad: ${lead.updatedAt.slice(0, 10)}). ` +
|
`(última actividad: ${op.updatedAt.slice(0, 10)}). ` +
|
||||||
`Favor de darle seguimiento o marcarlo como DESCARTADO.`
|
`Favor de darle seguimiento o marcarla como Perdido.`
|
||||||
await this.monday.crearUpdateEnItem(lead.id, body)
|
await this.monday.crearUpdateEnItem(op.id, body)
|
||||||
await this.state.set(clave, ahora.toISOString())
|
await this.state.set(clave, ahora.toISOString())
|
||||||
recordatoriosEnviados++
|
recordatoriosEnviados++
|
||||||
// Además del update, deja una actividad al vendedor asignado (si tiene)
|
// Además del update, deja una actividad al asesor asignado (si tiene)
|
||||||
const vendedorId = lead.vendedorIds[0]
|
const asesorId = op.asesorIds[0]
|
||||||
if (this.actividadesEnabled && vendedorId !== undefined) {
|
if (this.actividadesEnabled && asesorId !== undefined) {
|
||||||
await this.crearActividadSegura({
|
await this.crearActividadSegura({
|
||||||
nombre: `Retomar contacto con ${lead.nombre}`,
|
nombre: `Retomar contacto con ${op.nombre}`,
|
||||||
responsableId: vendedorId,
|
responsableId: asesorId,
|
||||||
leadItemId: lead.id,
|
leadItemId: op.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,36 +416,36 @@ export class AutomatizacionesService {
|
|||||||
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
|
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Responsable del escalado: coordinador (env) o, si no hay, el vendedor asignado */
|
/** Responsable del escalado: coordinador (env) o, si no hay, el asesor asignado */
|
||||||
private responsableEscalado(lead: LeadMonday): number | null {
|
private responsableEscalado(op: OportunidadMonday): number | null {
|
||||||
const coordinador = config.automatizaciones.coordinadorUserId
|
const coordinador = config.automatizaciones.coordinadorUserId
|
||||||
if (Number.isInteger(coordinador) && coordinador > 0) return coordinador
|
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
|
* Escalado de afluencias sin contactar: una oportunidad asignada que sigue en
|
||||||
* NUEVO / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
|
* Afluencia / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
|
||||||
* actividad URGENTE al coordinador (o al vendedor) + update en el item.
|
* actividad URGENTE al coordinador (o al asesor) + update en el item.
|
||||||
* Un escalado por lead (dedup `escalado:<itemId>`); un fallo de Monday en
|
* Un escalado por oportunidad (dedup `escalado:<itemId>`); un fallo de Monday
|
||||||
* un lead no detiene a los demás.
|
* en una oportunidad no detiene a las demás.
|
||||||
*/
|
*/
|
||||||
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
|
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
|
||||||
const ahora = opciones.ahora ?? new Date()
|
const ahora = opciones.ahora ?? new Date()
|
||||||
const horasUmbral = config.automatizaciones.escaladoHoras
|
const horasUmbral = config.automatizaciones.escaladoHoras
|
||||||
const leads = await this.monday.getLeads()
|
const oportunidades = await this.monday.getOportunidades()
|
||||||
const pendientes = leadsSinContactar(leads, ahora, horasUmbral)
|
const pendientes = oportunidadesSinContactar(oportunidades, ahora, horasUmbral)
|
||||||
|
|
||||||
const candidatos: CandidatoEscalado[] = []
|
const candidatos: CandidatoEscalado[] = []
|
||||||
let escalados = 0
|
let escalados = 0
|
||||||
for (const lead of pendientes) {
|
for (const op of pendientes) {
|
||||||
const responsableId = this.responsableEscalado(lead)
|
const responsableId = this.responsableEscalado(op)
|
||||||
const horas = Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / HORA_MS)
|
const horas = Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / HORA_MS)
|
||||||
const yaEscalado = (await this.state.get(claveEscalado(lead.id))) !== null
|
const yaEscalado = (await this.state.get(claveEscalado(op.id))) !== null
|
||||||
candidatos.push({
|
candidatos.push({
|
||||||
itemId: lead.id,
|
itemId: op.id,
|
||||||
lead: lead.nombre,
|
lead: op.nombre,
|
||||||
estado: lead.estado,
|
estado: op.etapa,
|
||||||
horasSinContactar: horas,
|
horasSinContactar: horas,
|
||||||
responsableId,
|
responsableId,
|
||||||
yaEscalado,
|
yaEscalado,
|
||||||
@@ -437,24 +454,24 @@ export class AutomatizacionesService {
|
|||||||
try {
|
try {
|
||||||
if (this.actividadesEnabled && responsableId !== null) {
|
if (this.actividadesEnabled && responsableId !== null) {
|
||||||
await this.monday.crearActividad({
|
await this.monday.crearActividad({
|
||||||
nombre: `URGENTE: lead sin contactar — ${lead.nombre}`,
|
nombre: `URGENTE: oportunidad sin contactar — ${op.nombre}`,
|
||||||
tipoLabel: 'Llamada',
|
tipoLabel: 'Llamada',
|
||||||
responsableId,
|
responsableId,
|
||||||
fecha: AutomatizacionesService.fechaHoyMas(0),
|
fecha: AutomatizacionesService.fechaHoyMas(0),
|
||||||
estadoLabel: 'Abierto',
|
estadoLabel: 'Abierto',
|
||||||
leadItemId: lead.id,
|
leadItemId: op.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await this.monday.crearUpdateEnItem(
|
await this.monday.crearUpdateEnItem(
|
||||||
lead.id,
|
op.id,
|
||||||
`🚨 Escalado automático: el lead lleva ${horas} h asignado en estado "${lead.estado}" sin contacto. ` +
|
`🚨 Escalado automático: la oportunidad lleva ${horas} h asignada en etapa "${op.etapa}" sin contacto. ` +
|
||||||
`Se notificó a ${responsableId !== null && responsableId !== lead.vendedorIds[0] ? 'la coordinación' : 'el vendedor asignado'}.`,
|
`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++
|
escalados++
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,21 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { FunnelService, type LeadOportunidadStore } from './funnel.service.js'
|
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 */
|
/** Monday mockeado: registra cambios de columna y devuelve búsquedas controladas */
|
||||||
function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: LeadMonday | null } = {}) {
|
function mondayMock(
|
||||||
|
opts: { leadPorTelefono?: LeadMonday | null; oportunidadPorTelefono?: OportunidadMonday | null } = {},
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
oportunidadesCreadas: [] as Record<string, unknown>[],
|
|
||||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: 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) {
|
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||||
this.cambios.push({ boardId, itemId, columna, valor })
|
this.cambios.push({ boardId, itemId, columna, valor })
|
||||||
},
|
},
|
||||||
async buscarLeadPorTelefono(_telefono: string) {
|
async buscarLeadPorTelefono(_telefono: string) {
|
||||||
return opts.leadPorTelefono ?? null
|
return opts.leadPorTelefono ?? null
|
||||||
},
|
},
|
||||||
async getLeadPorId(_itemId: string) {
|
async buscarOportunidadPorTelefono(_telefono: string) {
|
||||||
return opts.leadPorId ?? null
|
return opts.oportunidadPorTelefono ?? 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 })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,14 +28,18 @@ function storeMock(): LeadOportunidadStore & { mapping: Map<string, string> } {
|
|||||||
async buscarPorLeadId(leadId: string) {
|
async buscarPorLeadId(leadId: string) {
|
||||||
return mapping.get(leadId) ?? null
|
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) {
|
async guardar(leadId: string, oportunidadId: string) {
|
||||||
mapping.set(leadId, oportunidadId)
|
mapping.set(leadId, oportunidadId)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadBase = { id: 'lead-1', nombre: 'Lead Prueba', telefono: '4771234567', desarrolloLabel: 'SENDA' }
|
|
||||||
|
|
||||||
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
||||||
id: 'lead-1',
|
id: 'lead-1',
|
||||||
nombre: 'Lead Prueba',
|
nombre: 'Lead Prueba',
|
||||||
@@ -68,47 +53,19 @@ const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('FunnelService.asegurarOportunidadAfluencia', () => {
|
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
|
||||||
it('crea la oportunidad en Monday y guarda el mapping lead ↔ oportunidad', async () => {
|
id: 'op-1',
|
||||||
const monday = mondayMock()
|
nombre: 'Cliente Prueba',
|
||||||
const store = storeMock()
|
createdAt: '',
|
||||||
const funnel = new FunnelService(monday, store)
|
updatedAt: '',
|
||||||
const opId = await funnel.asegurarOportunidadAfluencia(leadBase)
|
etapa: 'Afluencia',
|
||||||
expect(opId).toBe('op-1')
|
unidadCodigo: '',
|
||||||
expect(monday.oportunidadesCreadas).toEqual([
|
precioFinal: 0,
|
||||||
{ nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, desarrolloLabel: 'SENDA' },
|
asesorIds: [],
|
||||||
])
|
telefono: '4771234567',
|
||||||
expect(store.mapping.get('lead-1')).toBe('op-1')
|
email: '',
|
||||||
// Y la mueve al grupo que corresponde a 'Afluencia' (nace en el grupo top del tablero)
|
origen: '',
|
||||||
expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-1', groupId: 'topics' }])
|
...overrides,
|
||||||
})
|
|
||||||
|
|
||||||
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([])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('FunnelService.moverOportunidadDeLead', () => {
|
describe('FunnelService.moverOportunidadDeLead', () => {
|
||||||
@@ -130,7 +87,7 @@ describe('FunnelService.moverOportunidadDeLead', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('FunnelService.oportunidadPorTelefono', () => {
|
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 monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'lead-7' }) })
|
||||||
const store = storeMock()
|
const store = storeMock()
|
||||||
store.mapping.set('lead-7', 'op-7')
|
store.mapping.set('lead-7', 'op-7')
|
||||||
@@ -138,11 +95,24 @@ describe('FunnelService.oportunidadPorTelefono', () => {
|
|||||||
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7')
|
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())
|
const funnel = new FunnelService(mondayMock(), storeMock())
|
||||||
await expect(funnel.oportunidadPorTelefono('4770000000')).resolves.toBeNull()
|
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'])
|
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 () => {
|
it('sin mapping no escribe dedup ni mueve nada (null)', async () => {
|
||||||
const monday = mondayMock()
|
const monday = mondayMock()
|
||||||
const funnel = new FunnelService(monday, storeMock())
|
const funnel = new FunnelService(monday, storeMock())
|
||||||
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
|
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(state.set).not.toHaveBeenCalled()
|
||||||
expect(monday.cambios).toEqual([])
|
expect(monday.cambios).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,44 +1,33 @@
|
|||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { claveApartadoCreado, type StateStore } from './automatizaciones.service.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 { mondayService, type MondayService } from './monday.service.js'
|
||||||
import { prisma } from './prisma.js'
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Funnel comercial Leads → Oportunidades:
|
* Funnel comercial sobre el tablero Oportunidades:
|
||||||
* todo lead capturado entra al tablero Oportunidades en etapa 'Afluencia' y la
|
* la captura de afluencia crea la oportunidad directamente en etapa
|
||||||
* oportunidad avanza de etapa sola conforme avanza el proceso (Agendo Cita,
|
* 'Afluencia' (ver leads.controller), y la oportunidad avanza de etapa sola
|
||||||
* Cotizador, Aparto, Ganado / Perdido).
|
* conforme avanza el proceso (Agendo Cita, Cotizador, Aparto, Ganado /
|
||||||
|
* Perdido).
|
||||||
*
|
*
|
||||||
* La vinculación lead ↔ oportunidad vive en la tabla LeadOportunidad (dedup
|
* La tabla LeadOportunidad (mapping lead ↔ oportunidad) queda para las ligas
|
||||||
* persistente: una oportunidad por lead), inyectada como store para testear.
|
* 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
|
* 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) */
|
/** Store del mapping lead ↔ oportunidad (Prisma en prod, memoria en tests) */
|
||||||
export interface LeadOportunidadStore {
|
export interface LeadOportunidadStore {
|
||||||
buscarPorLeadId(leadId: string): Promise<string | null> // oportunidadId
|
buscarPorLeadId(leadId: string): Promise<string | null> // oportunidadId
|
||||||
|
buscarPorOportunidadId(oportunidadId: string): Promise<string | null> // oportunidadId si hay mapping
|
||||||
guardar(leadId: string, oportunidadId: string): Promise<void>
|
guardar(leadId: string, oportunidadId: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type MondayFunnel = Pick<
|
type MondayFunnel = Pick<
|
||||||
MondayService,
|
MondayService,
|
||||||
| 'crearOportunidadAfluencia'
|
'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono'
|
||||||
| 'cambiarValorColumna'
|
|
||||||
| 'buscarLeadPorTelefono'
|
|
||||||
| 'getLeadPorId'
|
|
||||||
| 'columnaIdPorTitulo'
|
|
||||||
| 'getItemConGrupo'
|
|
||||||
| 'moverItemAGrupo'
|
|
||||||
>
|
>
|
||||||
|
|
||||||
export class FunnelService {
|
export class FunnelService {
|
||||||
@@ -47,49 +36,6 @@ export class FunnelService {
|
|||||||
private readonly store: LeadOportunidadStore,
|
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). */
|
/** Mueve la oportunidad del lead a otra etapa. Sin mapping → no-op (false). */
|
||||||
async moverOportunidadDeLead(leadId: string, etapa: string): Promise<boolean> {
|
async moverOportunidadDeLead(leadId: string, etapa: string): Promise<boolean> {
|
||||||
const oportunidadId = await this.store.buscarPorLeadId(leadId)
|
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:
|
* Oportunidad que tenga ese teléfono (match de cotizaciones). Convierte con
|
||||||
* busca el lead por teléfono en Monday y de ahí el mapping).
|
* 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> {
|
async oportunidadPorTelefono(telefono: string): Promise<string | null> {
|
||||||
const lead = await this.monday.buscarLeadPorTelefono(telefono)
|
const lead = await this.monday.buscarLeadPorTelefono(telefono)
|
||||||
if (!lead) return null
|
if (lead) {
|
||||||
return this.store.buscarPorLeadId(lead.id)
|
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>`
|
* Alta de cliente: escribe el dedup `apartado-creado:<opId>` ANTES de mover
|
||||||
* ANTES de mover la oportunidad a 'Aparto'. Este orden evita la carrera con
|
* la oportunidad a 'Aparto'. Este orden evita la carrera con el webhook/cron
|
||||||
* el webhook/cron (escaneoOportunidades vería 'Aparto' y crearía un Apartado
|
* (escaneoOportunidades vería 'Aparto' y crearía un Apartado duplicado; con
|
||||||
* duplicado; con el dedup previo lo salta). Devuelve la oportunidadId movida
|
* el dedup previo lo salta). Devuelve la oportunidadId movida o null si no
|
||||||
* o null si el lead no tiene mapping.
|
* 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> {
|
async moverAApartoConDedup(id: string, state: StateStore): Promise<string | null> {
|
||||||
const oportunidadId = await this.store.buscarPorLeadId(leadId)
|
const comoOportunidad = await this.store.buscarPorOportunidadId(id)
|
||||||
|
const oportunidadId = comoOportunidad ?? (await this.store.buscarPorLeadId(id))
|
||||||
if (oportunidadId === null) return null
|
if (oportunidadId === null) return null
|
||||||
await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString())
|
await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString())
|
||||||
await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', 'Aparto')
|
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 } })
|
const row = await prisma.leadOportunidad.findUnique({ where: { leadId } })
|
||||||
return row?.oportunidadId ?? null
|
return row?.oportunidadId ?? null
|
||||||
},
|
},
|
||||||
|
async buscarPorOportunidadId(oportunidadId) {
|
||||||
|
const row = await prisma.leadOportunidad.findUnique({ where: { oportunidadId } })
|
||||||
|
return row?.oportunidadId ?? null
|
||||||
|
},
|
||||||
async guardar(leadId, oportunidadId) {
|
async guardar(leadId, oportunidadId) {
|
||||||
await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } })
|
await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
|
|||||||
Abandono: 'group_mm6s4cgq',
|
Abandono: 'group_mm6s4cgq',
|
||||||
'No Asistio': 'group_mm6s4cgq',
|
'No Asistio': 'group_mm6s4cgq',
|
||||||
Afluencia: 'topics',
|
Afluencia: 'topics',
|
||||||
|
Contactado: 'topics',
|
||||||
|
'Intento de contacto': 'topics',
|
||||||
|
Calificado: 'topics',
|
||||||
Cotizador: 'topics',
|
Cotizador: 'topics',
|
||||||
'Agendo Cita': 'topics',
|
'Agendo Cita': 'topics',
|
||||||
Aparto: 'topics',
|
Aparto: 'topics',
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
esEstadoFinalApartado,
|
esEstadoFinalApartado,
|
||||||
esEstadoTerminalLead,
|
esEtapaTerminal,
|
||||||
resumenMiDia,
|
resumenMiDia,
|
||||||
type UsuarioMiDia,
|
type UsuarioMiDia,
|
||||||
} from './mi-dia.service.js'
|
} 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 MI_ID = 106736727
|
||||||
const OTRO_ID = 111111111
|
const OTRO_ID = 111111111
|
||||||
@@ -14,16 +14,18 @@ const AHORA = new Date('2026-08-28T12:00:00') // viernes
|
|||||||
const HOY = '2026-08-28'
|
const HOY = '2026-08-28'
|
||||||
const MANANA = '2026-08-29'
|
const MANANA = '2026-08-29'
|
||||||
|
|
||||||
function lead(parcial: Partial<LeadMonday> & { id: string }): LeadMonday {
|
function oportunidad(parcial: Partial<OportunidadMonday> & { id: string }): OportunidadMonday {
|
||||||
return {
|
return {
|
||||||
nombre: `Lead ${parcial.id}`,
|
nombre: `Lead ${parcial.id}`,
|
||||||
createdAt: '2026-08-25T10:00:00Z',
|
createdAt: '2026-08-25T10:00:00Z',
|
||||||
updatedAt: '2026-08-27T10:00:00Z',
|
updatedAt: '2026-08-27T10:00:00Z',
|
||||||
estado: 'NUEVO',
|
etapa: 'Afluencia',
|
||||||
origen: 'Facebook',
|
origen: 'Facebook',
|
||||||
|
unidadCodigo: '',
|
||||||
|
precioFinal: 0,
|
||||||
|
asesorIds: [MI_ID],
|
||||||
telefono: '',
|
telefono: '',
|
||||||
email: '',
|
email: '',
|
||||||
vendedorIds: [MI_ID],
|
|
||||||
...parcial,
|
...parcial,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,13 +52,13 @@ function apartado(parcial: Partial<ApartadoMonday> & { id: string }): ApartadoMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mondayMock(datos: {
|
function mondayMock(datos: {
|
||||||
leads?: LeadMonday[]
|
oportunidades?: OportunidadMonday[]
|
||||||
actividades?: ActividadMonday[]
|
actividades?: ActividadMonday[]
|
||||||
apartados?: ApartadoMonday[]
|
apartados?: ApartadoMonday[]
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
async getLeads() {
|
async getOportunidades() {
|
||||||
return datos.leads ?? []
|
return datos.oportunidades ?? []
|
||||||
},
|
},
|
||||||
async getActividades() {
|
async getActividades() {
|
||||||
return datos.actividades ?? []
|
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 }
|
const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null }
|
||||||
|
|
||||||
describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
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({
|
const monday = mondayMock({
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead({ id: '1', estado: 'NUEVO' }),
|
oportunidad({ id: '1', etapa: 'Afluencia' }),
|
||||||
lead({ id: '2', estado: 'CONTACTADO', telefono: '4771234567' }),
|
oportunidad({ id: '2', etapa: 'Contactado', telefono: '4771234567' }),
|
||||||
lead({ id: '3', estado: 'DESCARTADO' }),
|
oportunidad({ id: '3', etapa: 'Perdido' }),
|
||||||
lead({ id: '4', estado: 'CALIFICADO' }),
|
oportunidad({ id: '4', etapa: 'Calificado' }), // NO es terminal en el nuevo modelo
|
||||||
lead({ id: '5', estado: 'Abandono' }),
|
oportunidad({ id: '5', etapa: 'Abandono' }),
|
||||||
lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }),
|
oportunidad({ id: '6', etapa: 'Ganado' }),
|
||||||
|
oportunidad({ id: '7', etapa: 'Afluencia', asesorIds: [OTRO_ID] }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
||||||
expect(r.sinMapeoMonday).toBe(false)
|
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')
|
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 () => {
|
it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
|
||||||
const monday = mondayMock({
|
const monday = mondayMock({
|
||||||
actividades: [
|
actividades: [
|
||||||
@@ -126,13 +142,14 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
|
|
||||||
it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => {
|
it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => {
|
||||||
const monday = mondayMock({
|
const monday = mondayMock({
|
||||||
leads: [lead({ id: '1' })],
|
oportunidades: [oportunidad({ id: '1' })],
|
||||||
actividades: [actividad({ id: 'a1' })],
|
actividades: [actividad({ id: 'a1' })],
|
||||||
apartados: [apartado({ id: 'p1' })],
|
apartados: [apartado({ id: 'p1' })],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
|
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
|
||||||
expect(r.sinMapeoMonday).toBe(true)
|
expect(r.sinMapeoMonday).toBe(true)
|
||||||
expect(r.misLeads).toEqual([])
|
expect(r.misLeads).toEqual([])
|
||||||
|
expect(r.misOportunidades).toEqual({ porEtapa: {} })
|
||||||
expect(r.misActividadesHoy).toEqual([])
|
expect(r.misActividadesHoy).toEqual([])
|
||||||
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
|
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
|
||||||
// Los apartados por vencer son del pipeline completo: sí se muestran
|
// 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 () => {
|
it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => {
|
||||||
const monday = mondayMock({
|
const monday = mondayMock({
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
|
oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
|
||||||
lead({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
|
oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
|
||||||
lead({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
|
oportunidad({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
|
||||||
lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro
|
oportunidad({ id: '4', createdAt: '2026-08-27T08:00:00', asesorIds: [OTRO_ID] }), // de otro
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
||||||
expect(r.conteos.leadsNuevosSemana).toBe(2)
|
expect(r.conteos.leadsNuevosSemana).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => {
|
it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', 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 () => {
|
|
||||||
const monday = mondayMock({
|
const monday = mondayMock({
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead({ id: '1', vendedorIds: [] }), // sin asignar, NUEVO
|
oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia
|
||||||
lead({ id: '2', vendedorIds: [], estado: 'CONTACTADO' }), // sin asignar, no NUEVO
|
oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia
|
||||||
lead({ id: '3', vendedorIds: [MI_ID] }),
|
oportunidad({ id: '3', asesorIds: [MI_ID] }),
|
||||||
lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta
|
oportunidad({ id: '4', asesorIds: [MI_ID], etapa: 'Perdido' }), // terminal: no cuenta
|
||||||
lead({ id: '5', vendedorIds: [OTRO_ID] }),
|
oportunidad({ id: '5', asesorIds: [OTRO_ID] }),
|
||||||
lead({ id: '6', vendedorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
|
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']])
|
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 () => {
|
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']) {
|
for (const rol of ['corporativo', 'admin']) {
|
||||||
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA)
|
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA)
|
||||||
expect(r.equipo).toBeDefined()
|
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({
|
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 = [
|
const capturas = [
|
||||||
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') },
|
{ 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)
|
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([
|
expect(r.misCapturas).toEqual([
|
||||||
{
|
{
|
||||||
leadId: '100',
|
leadId: '100',
|
||||||
nombre: 'Ana López',
|
nombre: 'Ana López',
|
||||||
telefono: '6641112233',
|
telefono: '6641112233',
|
||||||
estado: 'CONTACTADO',
|
estado: 'Contactado',
|
||||||
origen: 'WhatsApp',
|
origen: 'WhatsApp',
|
||||||
capturadoEn: '2026-08-27T09:00:00.000Z',
|
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 monday = mondayMock({})
|
||||||
const capturas = [
|
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)
|
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
|
||||||
expect(r.misCapturas).toEqual([
|
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', () => {
|
describe('clasificadores de estado', () => {
|
||||||
it('estados terminales de lead (case-insensitive)', () => {
|
it('etapas terminales de oportunidad (case-insensitive)', () => {
|
||||||
expect(esEstadoTerminalLead('DESCARTADO')).toBe(true)
|
expect(esEtapaTerminal('GANADO')).toBe(true)
|
||||||
expect(esEstadoTerminalLead('calificado')).toBe(true)
|
expect(esEtapaTerminal('perdido')).toBe(true)
|
||||||
expect(esEstadoTerminalLead('Abandono')).toBe(true)
|
expect(esEtapaTerminal('Abandono')).toBe(true)
|
||||||
expect(esEstadoTerminalLead('NUEVO')).toBe(false)
|
expect(esEtapaTerminal('Afluencia')).toBe(false)
|
||||||
expect(esEstadoTerminalLead('Intento de contacto')).toBe(false)
|
expect(esEtapaTerminal('Calificado')).toBe(false)
|
||||||
|
expect(esEtapaTerminal('Intento de contacto')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('estados finales de apartado', () => {
|
it('estados finales de apartado', () => {
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import type { MondayService } from './monday.service.js'
|
|||||||
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
|
* 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
|
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
|
||||||
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
|
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
|
||||||
* "Mis capturas" viene del registro local (tabla LeadCaptura), no de Monday:
|
* La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia
|
||||||
* es la lista de leads que el usuario dio de alta, con su estado actual.
|
* 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
|
// Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor
|
||||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
|
||||||
|
|
||||||
// Estados del board Apartado que cierran el pipeline Enkontrol
|
// Estados del board Apartado que cierran el pipeline Enkontrol
|
||||||
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
|
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
|
||||||
@@ -76,9 +79,8 @@ export interface ResumenMiDia {
|
|||||||
misClientes: ClienteMiDia[]
|
misClientes: ClienteMiDia[]
|
||||||
misActividadesHoy: ActividadMiDia[]
|
misActividadesHoy: ActividadMiDia[]
|
||||||
apartadosPorVencer: ApartadoPorVencer[]
|
apartadosPorVencer: ApartadoPorVencer[]
|
||||||
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
|
/** Conteo de MIS oportunidades por etapa (columna Estado del board) */
|
||||||
misOportunidades: { porEtapa: Record<string, number> } | null
|
misOportunidades: { porEtapa: Record<string, number> }
|
||||||
notaOportunidades: string | null
|
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
||||||
actividadesAbiertas: number // mías, Estado Abierto
|
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
|
const MS_DIA = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
@@ -109,8 +111,8 @@ function fechaLocal(base: Date, dias: number): string {
|
|||||||
return `${y}-${m}-${dd}`
|
return `${y}-${m}-${dd}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function esEstadoTerminalLead(estado: string): boolean {
|
export function esEtapaTerminal(etapa: string): boolean {
|
||||||
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
|
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function esEstadoFinalApartado(estado: string): boolean {
|
export function esEstadoFinalApartado(estado: string): boolean {
|
||||||
@@ -131,8 +133,8 @@ export async function resumenMiDia(
|
|||||||
const miId = usuario.mondayUserId
|
const miId = usuario.mondayUserId
|
||||||
const sinMapeo = miId === null
|
const sinMapeo = miId === null
|
||||||
|
|
||||||
const [leads, actividades, apartados] = await Promise.all([
|
const [oportunidades, actividades, apartados] = await Promise.all([
|
||||||
monday.getLeads(),
|
monday.getOportunidades(),
|
||||||
monday.getActividades(),
|
monday.getActividades(),
|
||||||
monday.getApartados(),
|
monday.getApartados(),
|
||||||
])
|
])
|
||||||
@@ -143,31 +145,40 @@ export async function resumenMiDia(
|
|||||||
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
|
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
|
||||||
|
|
||||||
// --- Listas personales (vacías si no hay mapeo Monday) ---
|
// --- 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
|
const misLeads: LeadMiDia[] = mios
|
||||||
.filter((l) => !esEstadoTerminalLead(l.estado))
|
.filter((o) => !esEtapaTerminal(o.etapa))
|
||||||
.map((l) => ({
|
.map((o) => ({
|
||||||
id: l.id,
|
id: o.id,
|
||||||
nombre: l.nombre,
|
nombre: o.nombre,
|
||||||
telefono: l.telefono,
|
telefono: o.telefono,
|
||||||
email: l.email,
|
email: o.email,
|
||||||
estado: l.estado,
|
estado: o.etapa,
|
||||||
origen: l.origen,
|
origen: o.origen,
|
||||||
updatedAt: l.updatedAt,
|
updatedAt: o.updatedAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday
|
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
|
||||||
// (si ya no existe en el board, estado y origen van ''), más reciente primero
|
// del pipeline personal, no la lista de trabajo activo)
|
||||||
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
|
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
|
const misCapturas: CapturaMiDia[] = capturas
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const lead = leadsPorId.get(c.leadId)
|
const op = oportunidadesPorId.get(c.leadId)
|
||||||
return {
|
return {
|
||||||
leadId: c.leadId,
|
leadId: c.leadId,
|
||||||
nombre: c.nombreLead,
|
nombre: c.nombreLead,
|
||||||
telefono: c.telefono ?? '',
|
telefono: c.telefono ?? '',
|
||||||
estado: lead?.estado ?? '',
|
estado: op?.etapa ?? '',
|
||||||
origen: lead?.origen ?? '',
|
origen: op?.origen ?? '',
|
||||||
capturadoEn: c.createdAt.toISOString(),
|
capturadoEn: c.createdAt.toISOString(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -224,27 +235,25 @@ export async function resumenMiDia(
|
|||||||
misClientes,
|
misClientes,
|
||||||
misActividadesHoy,
|
misActividadesHoy,
|
||||||
apartadosPorVencer,
|
apartadosPorVencer,
|
||||||
// El tablero Oportunidades no tiene columna people de asesor: no se puede
|
misOportunidades: { porEtapa },
|
||||||
// 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',
|
|
||||||
conteos,
|
conteos,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
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 conteoPorId = new Map<number, number>()
|
||||||
const nuevosPorId = new Map<number, number>()
|
const nuevosPorId = new Map<number, number>()
|
||||||
for (const l of leads) {
|
for (const o of oportunidades) {
|
||||||
if (esEstadoTerminalLead(l.estado)) continue
|
if (esEtapaTerminal(o.etapa)) continue
|
||||||
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
|
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
|
||||||
for (const id of l.vendedorIds) {
|
for (const id of o.asesorIds) {
|
||||||
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
|
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
|
||||||
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
|
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resumen.equipo = {
|
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()]
|
porVendedor: [...conteoPorId.entries()]
|
||||||
.map(([mondayUserId, leadsAsignados]) => ({
|
.map(([mondayUserId, leadsAsignados]) => ({
|
||||||
mondayUserId,
|
mondayUserId,
|
||||||
|
|||||||
@@ -415,5 +415,241 @@ describe('MondayService', () => {
|
|||||||
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
|
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
|
||||||
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ label: 'Nuevo' })
|
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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,9 +86,27 @@ export interface OportunidadMonday {
|
|||||||
id: string
|
id: string
|
||||||
nombre: string // nombre del cliente
|
nombre: string // nombre del cliente
|
||||||
createdAt: string // ISO
|
createdAt: string // ISO
|
||||||
|
updatedAt: string // ISO (updated_at del item en Monday)
|
||||||
etapa: string // label de la columna status 'Estado'
|
etapa: string // label de la columna status 'Estado'
|
||||||
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
||||||
precioFinal: number // columna numbers 'Precio final' (0 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) */
|
/** 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
|
* Resuelve el label real de una columna status/dropdown tolerando mayúsculas
|
||||||
* minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
|
* 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á
|
* un rename en la UI; los dropdown NO auto-crean labels al escribir). Sin
|
||||||
* que el label no existe, como antes).
|
* match devuelve el valor tal cual (Monday dirá que el label no existe).
|
||||||
*/
|
*/
|
||||||
private async resolverLabelStatus(
|
private async resolverLabel(
|
||||||
boardId: string,
|
boardId: string,
|
||||||
col: ColumnaMeta,
|
col: ColumnaMeta,
|
||||||
valor: string | number | boolean,
|
valor: string | number | boolean,
|
||||||
): Promise<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 buscado = String(valor).trim().toUpperCase()
|
||||||
const labels = await this.labelsDeColumna(boardId, col.title)
|
const labels = await this.labelsDeColumna(boardId, col.title)
|
||||||
return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor
|
return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor
|
||||||
@@ -395,7 +413,7 @@ export class MondayService {
|
|||||||
for (const [titulo, crudo] of Object.entries(valores)) {
|
for (const [titulo, crudo] of Object.entries(valores)) {
|
||||||
if (crudo === undefined || crudo === '') continue
|
if (crudo === undefined || crudo === '') continue
|
||||||
const col = await this.columnaId(boardId, titulo)
|
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)
|
return JSON.stringify(out)
|
||||||
}
|
}
|
||||||
@@ -429,16 +447,33 @@ export class MondayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado
|
* Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la
|
||||||
* (entrada al funnel; sin unidad ni precios todavía).
|
* 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> {
|
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
|
||||||
const boardId = config.monday.boards.oportunidades
|
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, {
|
const columnValues = await this.construirColumnValues(boardId, {
|
||||||
|
Estado: 'Afluencia',
|
||||||
|
Fecha: fechaHoy,
|
||||||
'Cliente teléfono': datos.telefono,
|
'Cliente teléfono': datos.telefono,
|
||||||
'Cliente email': datos.email,
|
'Cliente email': datos.email,
|
||||||
Desarrollo: datos.desarrolloLabel,
|
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)
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||||
}
|
}
|
||||||
@@ -561,7 +596,7 @@ export class MondayService {
|
|||||||
valor: string | number | boolean,
|
valor: string | number | boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const col = await this.columnaId(boardId, tituloColumna)
|
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(
|
await this.graphql(
|
||||||
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
||||||
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
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))
|
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 {
|
private mapearLead(item: ItemConFechas, cols: Map<string, ColumnaMeta>): LeadMonday {
|
||||||
const colVendedor = cols.get('Vendedor')
|
const colVendedor = cols.get('Vendedor')
|
||||||
if (!colVendedor || colVendedor.type !== 'people') {
|
if (!colVendedor || colVendedor.type !== 'people') {
|
||||||
@@ -761,16 +781,16 @@ export class MondayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Items del tablero Oportunidades con etapa, unidad y precio final
|
* Items del tablero Oportunidades con etapa, unidad, precio final, asesor
|
||||||
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
|
* asignado y perfil de afluencia (los consumen las automatizaciones
|
||||||
|
* Aparto/Ganado, el round-robin, Mi día y los reportes).
|
||||||
*/
|
*/
|
||||||
async getOportunidades(): Promise<OportunidadMonday[]> {
|
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||||
const boardId = config.monday.boards.oportunidades
|
const boardId = config.monday.boards.oportunidades
|
||||||
const cols = await this.columnasDe(boardId)
|
const cols = await this.columnasDe(boardId)
|
||||||
const colEstado = cols.get('Estado')
|
if (!cols.has('Estado')) {
|
||||||
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
|
throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
|
||||||
const colUnidad = cols.get('Unidad')
|
}
|
||||||
const colPrecio = cols.get('Precio final')
|
|
||||||
|
|
||||||
const items: ItemConFechas[] = []
|
const items: ItemConFechas[] = []
|
||||||
let cursor: string | null = null
|
let cursor: string | null = null
|
||||||
@@ -805,18 +825,68 @@ export class MondayService {
|
|||||||
cursor = page.cursor
|
cursor = page.cursor
|
||||||
} while (cursor !== null)
|
} while (cursor !== null)
|
||||||
|
|
||||||
return items.map((item) => {
|
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]))
|
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||||
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
|
// 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 {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
nombre: item.name,
|
nombre: item.name,
|
||||||
createdAt: item.created_at,
|
createdAt: item.created_at,
|
||||||
etapa: porId.get(colEstado.id)?.text ?? '',
|
updatedAt: item.updated_at,
|
||||||
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
etapa,
|
||||||
|
unidadCodigo: textoDe('Unidad'),
|
||||||
precioFinal: Number.isFinite(precio) ? precio : 0,
|
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) ----------
|
// ---------- 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
|
* 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.
|
* y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
|
||||||
@@ -1066,10 +1111,10 @@ export class MondayService {
|
|||||||
return abiertas
|
return abiertas
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
|
||||||
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
|
||||||
const boardId = config.monday.boards.leads
|
const boardId = config.monday.boards.oportunidades
|
||||||
const col = await this.columnaId(boardId, 'Vendedor')
|
const col = await this.columnaId(boardId, 'Asesor')
|
||||||
await this.graphql(
|
await this.graphql(
|
||||||
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
||||||
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
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 {
|
export interface DatosOportunidadAfluencia {
|
||||||
nombre: string // name del item = nombre del lead
|
nombre: string // name del item = nombre del interesado
|
||||||
telefono?: string | undefined
|
telefono?: string | undefined
|
||||||
email?: string | undefined
|
email?: string | undefined
|
||||||
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
|
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 {
|
export interface DatosContacto {
|
||||||
@@ -1195,27 +1255,6 @@ export interface DatosActividad {
|
|||||||
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
|
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) */
|
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
||||||
export const mondayService = new MondayService()
|
export const mondayService = new MondayService()
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
|
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'
|
import type { PlanPago } from './cobranza.service.js'
|
||||||
|
|
||||||
// ---------- Fixtures ----------
|
// ---------- 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 {
|
return {
|
||||||
id,
|
id,
|
||||||
nombre: `Lead ${id}`,
|
nombre: `Lead ${id}`,
|
||||||
createdAt: '2026-08-20T10:00:00Z',
|
createdAt: '2026-08-20T10:00:00Z',
|
||||||
updatedAt: '2026-08-20T10:00:00Z',
|
updatedAt: '2026-08-20T10:00:00Z',
|
||||||
estado: 'NUEVO',
|
etapa: 'Afluencia',
|
||||||
origen: 'Facebook',
|
origen: 'Facebook',
|
||||||
vendedorIds: [],
|
unidadCodigo: '',
|
||||||
|
precioFinal: 0,
|
||||||
|
asesorIds: [],
|
||||||
|
telefono: '',
|
||||||
|
email: '',
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function op(id: string, etapa: string): OportunidadMonday {
|
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 {
|
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 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) ----------
|
// ---------- construirResumen (puro) ----------
|
||||||
|
|
||||||
describe('construirResumen', () => {
|
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(
|
const r = construirResumen(
|
||||||
{
|
{
|
||||||
...DATOS_BASE,
|
...DATOS_BASE,
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead('1', { estado: 'NUEVO', origen: 'Facebook' }),
|
afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }),
|
||||||
lead('2', { estado: 'NUEVO', origen: 'Facebook' }),
|
afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }),
|
||||||
lead('3', { estado: 'CONTACTADO', origen: 'Referido' }),
|
afluencia('3', { etapa: 'Contactado', origen: 'Referido' }),
|
||||||
lead('4', { estado: '', origen: '' }),
|
afluencia('4', { etapa: '', origen: '' }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
AHORA,
|
AHORA,
|
||||||
)
|
)
|
||||||
expect(r.leads.total).toBe(4)
|
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 })
|
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(
|
const r = construirResumen(
|
||||||
{
|
{
|
||||||
...DATOS_BASE,
|
...DATOS_BASE,
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
|
afluencia('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
|
||||||
lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
|
afluencia('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
|
||||||
lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
|
afluencia('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
AHORA,
|
AHORA,
|
||||||
@@ -218,10 +223,10 @@ describe('construirResumen', () => {
|
|||||||
const r = construirResumen(
|
const r = construirResumen(
|
||||||
{
|
{
|
||||||
...DATOS_BASE,
|
...DATOS_BASE,
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }),
|
afluencia('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Sí' }),
|
||||||
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
|
afluencia('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
|
||||||
lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
|
afluencia('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
AHORA,
|
AHORA,
|
||||||
@@ -229,7 +234,7 @@ describe('construirResumen', () => {
|
|||||||
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
|
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
|
||||||
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
|
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
|
||||||
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 })
|
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
|
expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k
|
||||||
// Dimensiones sin ningún dato van como objeto vacío
|
// Dimensiones sin ningún dato van como objeto vacío
|
||||||
expect(r.leads.perfil.porModelo).toEqual({})
|
expect(r.leads.perfil.porModelo).toEqual({})
|
||||||
@@ -243,16 +248,16 @@ describe('construirResumen', () => {
|
|||||||
const r = construirResumen(
|
const r = construirResumen(
|
||||||
{
|
{
|
||||||
...DATOS_BASE,
|
...DATOS_BASE,
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
|
afluencia('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
|
||||||
lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
|
afluencia('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
|
||||||
lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
|
afluencia('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)
|
afluencia('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)
|
afluencia('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
|
||||||
lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
|
afluencia('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
|
||||||
lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
|
afluencia('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
|
||||||
lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
|
afluencia('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
|
||||||
lead('9', { rangoSalarial: '' }), // vacío: se salta
|
afluencia('9', { rangoSalarial: '' }), // vacío: se salta
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
AHORA,
|
AHORA,
|
||||||
@@ -272,16 +277,16 @@ describe('construirResumen', () => {
|
|||||||
const r = construirResumen(
|
const r = construirResumen(
|
||||||
{
|
{
|
||||||
...DATOS_BASE,
|
...DATOS_BASE,
|
||||||
leads: [
|
oportunidades: [
|
||||||
lead('1', { edad: 18 }), // 15–24
|
afluencia('1', { edad: 18 }), // 15–24
|
||||||
lead('2', { edad: 24 }), // 15–24
|
afluencia('2', { edad: 24 }), // 15–24
|
||||||
lead('3', { edad: 25 }), // 25–34
|
afluencia('3', { edad: 25 }), // 25–34
|
||||||
lead('4', { edad: 39 }), // 35–44
|
afluencia('4', { edad: 39 }), // 35–44
|
||||||
lead('5', { edad: 50 }), // 45–54
|
afluencia('5', { edad: 50 }), // 45–54
|
||||||
lead('6', { edad: 55 }), // 55+
|
afluencia('6', { edad: 55 }), // 55+
|
||||||
lead('7', { edad: 70 }), // 55+
|
afluencia('7', { edad: 70 }), // 55+
|
||||||
lead('8', { edad: null }), // sin dato: se salta
|
afluencia('8', { edad: null }), // sin dato: se salta
|
||||||
lead('9'), // edad undefined: se salta
|
afluencia('9'), // edad undefined: se salta
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
AHORA,
|
AHORA,
|
||||||
@@ -295,14 +300,10 @@ describe('construirResumen', () => {
|
|||||||
describe('ReportesService', () => {
|
describe('ReportesService', () => {
|
||||||
function mondayMock() {
|
function mondayMock() {
|
||||||
return {
|
return {
|
||||||
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
|
llamadas: { oportunidades: 0, unidades: 0, actividades: 0 },
|
||||||
async getLeads(): Promise<LeadMonday[]> {
|
|
||||||
this.llamadas.leads++
|
|
||||||
return [lead('1')]
|
|
||||||
},
|
|
||||||
async getOportunidades(): Promise<OportunidadMonday[]> {
|
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||||
this.llamadas.oportunidades++
|
this.llamadas.oportunidades++
|
||||||
return [op('1', 'Aparto')]
|
return [afluencia('1')]
|
||||||
},
|
},
|
||||||
async getUnidades(): Promise<Unidad[]> {
|
async getUnidades(): Promise<Unidad[]> {
|
||||||
this.llamadas.unidades++
|
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 monday = mondayMock()
|
||||||
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
|
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
|
||||||
const r = await svc.resumen()
|
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.oportunidades.total).toBe(1)
|
||||||
expect(r.unidades.total).toBe(1)
|
expect(r.unidades.total).toBe(1)
|
||||||
expect(r.cobranza.planes.activos).toBe(1)
|
expect(r.cobranza.planes.activos).toBe(1)
|
||||||
expect(r.actividadesAbiertas).toBe(7)
|
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 () => {
|
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()
|
||||||
await svc.resumen()
|
await svc.resumen()
|
||||||
expect(monday.llamadas.leads).toBe(1)
|
|
||||||
expect(monday.llamadas.oportunidades).toBe(1)
|
expect(monday.llamadas.oportunidades).toBe(1)
|
||||||
expect(cargarPlanes).toHaveBeenCalledTimes(1)
|
expect(cargarPlanes).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
// 61 s después el cache expira y se vuelve a consultar
|
// 61 s después el cache expira y se vuelve a consultar
|
||||||
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
|
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
|
||||||
await svc.resumen()
|
await svc.resumen()
|
||||||
expect(monday.llamadas.leads).toBe(2)
|
expect(monday.llamadas.oportunidades).toBe(2)
|
||||||
expect(cargarPlanes).toHaveBeenCalledTimes(2)
|
expect(cargarPlanes).toHaveBeenCalledTimes(2)
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
|
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'
|
import { ETAPAS_PIPELINE } from './etapas.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agregador de reportes para la app de tableros (/dash/).
|
* Agregador de reportes para la app de tableros (/dash/).
|
||||||
* En UNA respuesta junta: leads (Monday), oportunidades (Monday), unidades
|
* En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la
|
||||||
* (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
|
* 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.
|
* 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) */
|
/** Datos crudos que necesita el agregador (inyectables en tests) */
|
||||||
export interface DatosResumen {
|
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[]
|
oportunidades: OportunidadMonday[]
|
||||||
unidades: Unidad[]
|
unidades: Unidad[]
|
||||||
planes: PlanPago[] // dominio de cobranza (con pagos)
|
planes: PlanPago[] // dominio de cobranza (con pagos)
|
||||||
@@ -162,8 +164,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
|||||||
const hoy = inicioDelDia(ahora)
|
const hoy = inicioDelDia(ahora)
|
||||||
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
const creadosEn = (desde: Date): number =>
|
const creadosEn = (desde: Date): number =>
|
||||||
datos.leads.filter((l) => {
|
datos.oportunidades.filter((o) => {
|
||||||
const t = Date.parse(l.createdAt)
|
const t = Date.parse(o.createdAt)
|
||||||
return !Number.isNaN(t) && t >= desde.getTime()
|
return !Number.isNaN(t) && t >= desde.getTime()
|
||||||
}).length
|
}).length
|
||||||
|
|
||||||
@@ -219,29 +221,31 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
generadoEn: ahora.toISOString(),
|
generadoEn: ahora.toISOString(),
|
||||||
|
// Sección "leads" = afluencia sobre el tablero Oportunidades (modelo
|
||||||
|
// nuevo: la afluencia ES la oportunidad en etapa 'Afluencia')
|
||||||
leads: {
|
leads: {
|
||||||
total: datos.leads.length,
|
total: datos.oportunidades.length,
|
||||||
nuevosHoy: creadosEn(hoy),
|
nuevosHoy: creadosEn(hoy),
|
||||||
nuevosSemana: creadosEn(hace7dias),
|
nuevosSemana: creadosEn(hace7dias),
|
||||||
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
|
||||||
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
|
||||||
perfil: {
|
perfil: {
|
||||||
porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')),
|
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
|
||||||
porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')),
|
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
|
||||||
porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')),
|
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
|
||||||
porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')),
|
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
|
||||||
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')),
|
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
|
||||||
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
|
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
|
||||||
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')),
|
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
|
||||||
porRangoSalarial: contarPorNoVacios(
|
porRangoSalarial: contarPorNoVacios(
|
||||||
datos.leads
|
datos.oportunidades
|
||||||
.map((l) => bucketSalario(l.rangoSalarial ?? ''))
|
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
|
||||||
.filter((b): b is string => b !== null),
|
.filter((b): b is string => b !== null),
|
||||||
),
|
),
|
||||||
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')),
|
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
|
||||||
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
|
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
|
||||||
porEdad: contarPorNoVacios(
|
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 ----------
|
// ---------- 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) */
|
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
|
||||||
export type CargadorPlanes = () => Promise<PlanPago[]>
|
export type CargadorPlanes = () => Promise<PlanPago[]>
|
||||||
@@ -309,14 +313,13 @@ export class ReportesService {
|
|||||||
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
|
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
|
||||||
|
|
||||||
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
||||||
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
|
const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
|
||||||
this.monday.getLeads(),
|
|
||||||
this.monday.getOportunidades(),
|
this.monday.getOportunidades(),
|
||||||
this.monday.getUnidades(),
|
this.monday.getUnidades(),
|
||||||
this.monday.getActividadesAbiertas(),
|
this.monday.getActividadesAbiertas(),
|
||||||
this.cargarPlanes(),
|
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 }
|
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
|
||||||
return resumen
|
return resumen
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ describe('AltaAfluenciaForm', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('tras el éxito, "Pasar a Alta de Cliente" entrega el lead recién creado', async () => {
|
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()
|
const onPasar = vi.fn()
|
||||||
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={onPasar} />)
|
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={onPasar} />)
|
||||||
llenarRequeridos()
|
llenarRequeridos()
|
||||||
@@ -216,12 +216,13 @@ describe('AltaAfluenciaForm', () => {
|
|||||||
fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ }))
|
fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ }))
|
||||||
|
|
||||||
expect(onPasar).toHaveBeenCalledWith({
|
expect(onPasar).toHaveBeenCalledWith({
|
||||||
id: 'l-1',
|
id: 'op-1',
|
||||||
nombre: 'Ana Demo',
|
nombre: 'Ana Demo',
|
||||||
telefono: '6641234567',
|
telefono: '6641234567',
|
||||||
email: 'ana@correo.com',
|
email: 'ana@correo.com',
|
||||||
estado: 'NUEVO',
|
estado: 'Afluencia',
|
||||||
origen: '',
|
origen: '',
|
||||||
|
fuente: 'oportunidad', // la captura nueva vive en Oportunidades
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
estado: res.lead.estado,
|
estado: res.lead.estado,
|
||||||
origen: form.origen,
|
origen: form.origen,
|
||||||
|
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
|
||||||
})
|
})
|
||||||
setAsignacion(res.lead.asignacion ?? 'round-robin')
|
setAsignacion(res.lead.asignacion ?? 'round-robin')
|
||||||
} else if (res.motivo === 'duplicado') {
|
} else if (res.motivo === 'duplicado') {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { LeadResumen } from '../services/api'
|
|||||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
const LEADS: LeadResumen[] = [
|
const LEADS: LeadResumen[] = [
|
||||||
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web' },
|
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web', fuente: 'lead' },
|
||||||
{ id: 'lead-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'CONTACTADO', origen: '' },
|
{ id: 'op-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'Afluencia', origen: '', fuente: 'oportunidad' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const respuestaOk = (data: unknown) =>
|
const respuestaOk = (data: unknown) =>
|
||||||
@@ -35,13 +35,16 @@ describe('BusquedaLead', () => {
|
|||||||
expect(llamadasBusqueda()).toHaveLength(0)
|
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} />)
|
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
|
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
|
||||||
|
|
||||||
expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy()
|
expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy()
|
||||||
expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy()
|
expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy()
|
||||||
expect(screen.getByText('Juan Pérez')).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]
|
const [url] = llamadasBusqueda()[0] as [string]
|
||||||
expect(url).toContain('/leads/buscar?q=maria')
|
expect(url).toContain('/leads/buscar?q=maria')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buscador con debounce de leads de Monday (GET /leads/buscar). No llama al
|
* Buscador con debounce de leads/afluencias de Monday (GET /leads/buscar,
|
||||||
* API con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
|
* 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) {
|
export default function BusquedaLead({ onSeleccion }: Props) {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
@@ -71,13 +72,25 @@ export default function BusquedaLead({ onSeleccion }: Props) {
|
|||||||
{resultados !== null && resultados.length > 0 && (
|
{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">
|
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||||
{resultados.map((lead) => (
|
{resultados.map((lead) => (
|
||||||
<li key={lead.id}>
|
<li key={`${lead.fuente}-${lead.id}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSeleccion(lead)}
|
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]"
|
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]">
|
<span className="text-xs text-[#64748B]">
|
||||||
{lead.telefono || 'sin teléfono'}
|
{lead.telefono || 'sin teléfono'}
|
||||||
{lead.estado ? ` · ${lead.estado}` : ''}
|
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const LEAD: LeadResumen = {
|
|||||||
email: 'maria@correo.com',
|
email: 'maria@correo.com',
|
||||||
estado: 'NUEVO',
|
estado: 'NUEVO',
|
||||||
origen: 'Web',
|
origen: 'Web',
|
||||||
|
fuente: 'lead',
|
||||||
}
|
}
|
||||||
|
|
||||||
const respuestaOk = (data: unknown) =>
|
const respuestaOk = (data: unknown) =>
|
||||||
|
|||||||
@@ -225,9 +225,11 @@ export interface LeadResumen {
|
|||||||
email: string
|
email: string
|
||||||
estado: string
|
estado: string
|
||||||
origen: 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[]> {
|
export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
||||||
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
|
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
|
||||||
manejar401(res)
|
manejar401(res)
|
||||||
@@ -236,8 +238,9 @@ export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LeadCreado {
|
export interface LeadCreado {
|
||||||
|
/** id de la oportunidad creada (la afluencia ES la oportunidad: id === oportunidadId) */
|
||||||
id: string
|
id: string
|
||||||
estado: string
|
estado: string // 'Afluencia'
|
||||||
oportunidadId: string | null
|
oportunidadId: string | null
|
||||||
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
||||||
asignacion?: 'capturista' | 'round-robin'
|
asignacion?: 'capturista' | 'round-robin'
|
||||||
@@ -250,9 +253,10 @@ export type ResultadoCrearLead =
|
|||||||
| { ok: false; motivo: 'conexion' }
|
| { ok: false; motivo: 'conexion' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /leads — captura ligera de afluencia: crea el lead en Monday y su
|
* POST /leads — captura ligera de afluencia: crea UN item en el tablero
|
||||||
* oportunidad en etapa Afluencia (round-robin). Si el teléfono ya existe el
|
* Oportunidades (etapa Afluencia, con todo el perfil; la asigna el capturista
|
||||||
* API responde 409 con el lead existente (motivo 'duplicado').
|
* 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: {
|
export async function crearLead(payload: {
|
||||||
nombre: string
|
nombre: string
|
||||||
|
|||||||
@@ -115,20 +115,19 @@ describe('unidadesEnEtapa', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('datosFunnel', () => {
|
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())
|
const funnel = datosFunnel(resumen())
|
||||||
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Oportunidades', 'Aparto o más', 'Ganado'])
|
expect(funnel.map((e) => e.etapa)).toEqual(['Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
|
||||||
expect(funnel.map((e) => e.valor)).toEqual([40, 20, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
expect(funnel.map((e) => e.valor)).toEqual([8, 0, 5, 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]!)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('tolera etiquetas con otra capitalización', () => {
|
it('tolera etiquetas con otra capitalización', () => {
|
||||||
const r = resumen()
|
const r = resumen()
|
||||||
r.oportunidades.porEtapa = { aparto: 1, ' GANADO ': 3 }
|
r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
|
||||||
const funnel = datosFunnel(r)
|
const funnel = datosFunnel(r)
|
||||||
expect(funnel[2]?.valor).toBe(4)
|
expect(funnel[0]?.valor).toBe(3)
|
||||||
expect(funnel[3]?.valor).toBe(3)
|
expect(funnel[3]?.valor).toBe(4)
|
||||||
|
expect(funnel[4]?.valor).toBe(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datos del funnel comercial: Leads → Oportunidades → Aparto o más → Ganado.
|
* Datos del funnel comercial, todo desde el tablero Oportunidades (la
|
||||||
* "Aparto o más" es acumulado (Aparto + Ganado) para que el funnel no suba.
|
* 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[] {
|
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
||||||
const porEtapa = resumen.oportunidades.porEtapa
|
const porEtapa = resumen.oportunidades.porEtapa
|
||||||
@@ -115,8 +117,9 @@ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
|||||||
.reduce((acc, [, v]) => acc + v, 0)
|
.reduce((acc, [, v]) => acc + v, 0)
|
||||||
const apartoOMas = conteo('APARTO') + conteo('GANADO')
|
const apartoOMas = conteo('APARTO') + conteo('GANADO')
|
||||||
return [
|
return [
|
||||||
{ etapa: 'Leads', valor: resumen.leads.total },
|
{ etapa: 'Afluencia', valor: conteo('AFLUENCIA') },
|
||||||
{ etapa: 'Oportunidades', valor: resumen.oportunidades.total },
|
{ etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') },
|
||||||
|
{ etapa: 'Cotizador', valor: conteo('COTIZADOR') },
|
||||||
{ etapa: 'Aparto o más', valor: apartoOMas },
|
{ etapa: 'Aparto o más', valor: apartoOMas },
|
||||||
{ etapa: 'Ganado', valor: conteo('GANADO') },
|
{ etapa: 'Ganado', valor: conteo('GANADO') },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ export interface ResumenMiDia {
|
|||||||
misClientes: ClienteMiDia[]
|
misClientes: ClienteMiDia[]
|
||||||
misActividadesHoy: ActividadMiDia[]
|
misActividadesHoy: ActividadMiDia[]
|
||||||
apartadosPorVencer: ApartadoPorVencer[]
|
apartadosPorVencer: ApartadoPorVencer[]
|
||||||
misOportunidades: { porEtapa: Record<string, number> } | null
|
misOportunidades: { porEtapa: Record<string, number> } // conteo de MIS oportunidades por etapa
|
||||||
notaOportunidades: string | null
|
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: number
|
leadsNuevosSemana: number
|
||||||
actividadesAbiertas: number
|
actividadesAbiertas: number
|
||||||
|
|||||||
Reference in New Issue
Block a user