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