feat(leads): doble via de captura — lead rapido a Leads, afluencia a Oportunidades
- POST /api/leads: captura ligera al tablero Leads (sin oportunidad), self-assign a Vendedor o round-robin; tambien cubre la captura manual desde la UI de Monday (webhook create_item re-registrado). - POST /api/afluencias (nuevo): la forma completa sigue cayendo en Oportunidades con todo el perfil (etapa Afluencia, columna Asesor). - Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades con insignia de origen, acciones con parametro fuente, reportes: leads desde Leads, perfilamiento desde Oportunidades, funnel Leads->Afluencia->...->Ganado.
This commit is contained in:
@@ -4,6 +4,8 @@ import {
|
||||
buscarLeads,
|
||||
buscarLeadsEndpoint,
|
||||
cambiarEstadoLead,
|
||||
crearAfluencia,
|
||||
crearAfluenciaEndpoint,
|
||||
crearLead,
|
||||
crearLeadEndpoint,
|
||||
descartarLead,
|
||||
@@ -42,23 +44,32 @@ function mondayMock(
|
||||
labelsOrigen?: string[]
|
||||
labelsDesarrollo?: string[]
|
||||
labelsMotivo?: string[]
|
||||
duplicado?: OportunidadMonday | null
|
||||
duplicadoLead?: LeadMonday | null
|
||||
duplicadoOportunidad?: OportunidadMonday | null
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
|
||||
leadsCreados: [] as Record<string, unknown>[],
|
||||
oportunidadesCreadas: [] as Record<string, unknown>[],
|
||||
movimientosGrupo: [] as { itemId: string; groupId: string }[],
|
||||
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||
this.cambios.push({ boardId, itemId, columna, valor })
|
||||
},
|
||||
async crearLead(datos: Record<string, unknown>) {
|
||||
this.leadsCreados.push(datos)
|
||||
return 'lead-nuevo-1'
|
||||
},
|
||||
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
|
||||
this.oportunidadesCreadas.push(datos)
|
||||
return 'op-nueva-1'
|
||||
},
|
||||
async buscarLeadPorTelefono(_telefono: string) {
|
||||
return opts.duplicadoLead ?? null
|
||||
},
|
||||
async buscarOportunidadPorTelefono(_telefono: string) {
|
||||
return opts.duplicado ?? null
|
||||
return opts.duplicadoOportunidad ?? null
|
||||
},
|
||||
async labelsDeColumna(_boardId: string, titulo: string) {
|
||||
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
|
||||
@@ -93,6 +104,19 @@ function mondayMock(
|
||||
|
||||
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
|
||||
|
||||
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
||||
id: 'lead-1',
|
||||
nombre: 'Lead Prueba',
|
||||
createdAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
estado: 'Nuevo',
|
||||
origen: '',
|
||||
telefono: '',
|
||||
email: '',
|
||||
vendedorIds: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
|
||||
id: 'op-1',
|
||||
nombre: 'Lead Prueba',
|
||||
@@ -108,10 +132,57 @@ const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMo
|
||||
...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 () => {
|
||||
describe('crearLead (POST /api/leads) — captura rápida en el tablero Leads', () => {
|
||||
it('crea el item en Leads (ligero, SIN oportunidad) y devuelve su id', async () => {
|
||||
const monday = mondayMock()
|
||||
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
|
||||
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
|
||||
expect(monday.leadsCreados).toEqual([
|
||||
{
|
||||
nombre: 'Lead Prueba',
|
||||
telefono: '4771234567',
|
||||
email: undefined,
|
||||
origenLabel: 'Facebook',
|
||||
desarrolloLabel: undefined,
|
||||
notas: 'Vio el volante',
|
||||
},
|
||||
])
|
||||
expect(monday.oportunidadesCreadas).toEqual([])
|
||||
expect(monday.movimientosGrupo).toEqual([]) // el grupo lo arregla el webhook de change
|
||||
})
|
||||
|
||||
it('pasa email y desarrollo al item de Monday cuando vienen en la captura', async () => {
|
||||
const monday = mondayMock()
|
||||
const r = await crearLead(monday, { ...datosBase, email: 'lead@correo.com', desarrollo: 'SENDA' })
|
||||
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
|
||||
expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
|
||||
})
|
||||
|
||||
it('duplicado por teléfono contra Leads: no crea nada y devuelve el item existente', async () => {
|
||||
const existente = leadMonday({ id: 'lead-viejo-9', telefono: '4771234567' })
|
||||
const monday = mondayMock({ duplicadoLead: existente })
|
||||
const r = await crearLead(monday, datosBase)
|
||||
expect(r).toEqual({ duplicado: { id: 'lead-viejo-9', nombre: 'Lead Prueba' } })
|
||||
expect(monday.leadsCreados).toEqual([])
|
||||
})
|
||||
|
||||
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Leads)', async () => {
|
||||
const monday = mondayMock()
|
||||
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(/Desarrollo inválido/)
|
||||
expect(monday.leadsCreados).toEqual([])
|
||||
})
|
||||
|
||||
it('rechaza un origen que no sea label real de la columna Origen (board Leads)', async () => {
|
||||
const monday = mondayMock()
|
||||
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
|
||||
expect(monday.leadsCreados).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunidad', () => {
|
||||
it('crea UN item en Oportunidades (Estado Afluencia) y devuelve su id como id y oportunidadId', async () => {
|
||||
const monday = mondayMock()
|
||||
const r = await crearAfluencia(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
|
||||
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
|
||||
expect(monday.oportunidadesCreadas).toEqual([
|
||||
{
|
||||
@@ -134,24 +205,14 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
|
||||
notas: 'Vio el volante',
|
||||
},
|
||||
])
|
||||
expect(monday.leadsCreados).toEqual([]) // NO se crea item en Leads
|
||||
// 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 () => {
|
||||
const monday = mondayMock()
|
||||
const r = await crearLead(monday, {
|
||||
...datosBase,
|
||||
email: 'lead@correo.com',
|
||||
desarrollo: 'SENDA',
|
||||
})
|
||||
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
|
||||
expect(monday.oportunidadesCreadas[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
|
||||
})
|
||||
|
||||
it('pasa los campos de calificación al item de Monday (labels listos para el board)', async () => {
|
||||
const monday = mondayMock()
|
||||
const r = await crearLead(monday, {
|
||||
const r = await crearAfluencia(monday, {
|
||||
...datosBase,
|
||||
modelo: 'HIMALAYA',
|
||||
medioContacto: 'WhatsApp',
|
||||
@@ -183,9 +244,9 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
|
||||
|
||||
it('duplicado por teléfono contra Oportunidades: no crea nada y devuelve el item existente', async () => {
|
||||
const existente = oportunidadMonday({ id: 'op-vieja-9', telefono: '4771234567' })
|
||||
const monday = mondayMock({ duplicado: existente })
|
||||
const r = await crearLead(monday, datosBase)
|
||||
expect(r).toEqual({ duplicado: existente })
|
||||
const monday = mondayMock({ duplicadoOportunidad: existente })
|
||||
const r = await crearAfluencia(monday, datosBase)
|
||||
expect(r).toEqual({ duplicado: { id: 'op-vieja-9', nombre: 'Lead Prueba' } })
|
||||
expect(monday.oportunidadesCreadas).toEqual([])
|
||||
expect(monday.movimientosGrupo).toEqual([])
|
||||
})
|
||||
@@ -194,14 +255,14 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
|
||||
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)
|
||||
const r = await crearAfluencia(monday, datosBase)
|
||||
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
|
||||
espia.mockRestore()
|
||||
})
|
||||
|
||||
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => {
|
||||
const monday = mondayMock()
|
||||
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
|
||||
await expect(crearAfluencia(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
|
||||
/Desarrollo inválido/,
|
||||
)
|
||||
expect(monday.oportunidadesCreadas).toEqual([])
|
||||
@@ -209,7 +270,7 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
|
||||
|
||||
it('rechaza un origen que no sea label real del dropdown Origen del Lead', async () => {
|
||||
const monday = mondayMock()
|
||||
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
|
||||
await expect(crearAfluencia(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
|
||||
expect(monday.oportunidadesCreadas).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -217,18 +278,18 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
|
||||
describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
|
||||
it('la whitelist solo permite las transiciones del vendedor', () => {
|
||||
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'])
|
||||
// Perdido tiene su propio endpoint (lleva motivo)
|
||||
// El descarte tiene su propio endpoint (lleva motivo)
|
||||
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO')
|
||||
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
|
||||
})
|
||||
|
||||
it('escribe la columna Estado en Oportunidades (555 en tests) con el label real del board', async () => {
|
||||
it('default (fuente oportunidad): escribe Estado en Oportunidades (555) con el label real del board', async () => {
|
||||
const monday = mondayMock()
|
||||
await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO')
|
||||
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Contactado' }])
|
||||
})
|
||||
|
||||
it('AGENDA CITA se traduce al label "Agendo Cita" y con fecha también setea la columna Fecha', async () => {
|
||||
it('AGENDA CITA se traduce a "Agendo Cita" (Oportunidades) y con fecha también setea la columna Fecha', async () => {
|
||||
const monday = mondayMock()
|
||||
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA', '2026-09-01')
|
||||
expect(monday.cambios).toEqual([
|
||||
@@ -243,15 +304,28 @@ describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
|
||||
expect(monday.cambios).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('CALIFICADO se escribe como "Calificado" (label real del board)', async () => {
|
||||
it('fuente lead: escribe en el tablero Leads (444) con labels title case ("Agenda Cita")', async () => {
|
||||
const monday = mondayMock()
|
||||
await cambiarEstadoLead(monday, 'op-1', 'CALIFICADO')
|
||||
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Calificado' }])
|
||||
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', 'lead')
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Agenda Cita' },
|
||||
{ boardId: '444', itemId: 'lead-1', columna: 'Fecha', valor: '2026-09-01' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fuente lead: CONTACTADO / CALIFICADO van title case al board Leads', async () => {
|
||||
const monday = mondayMock()
|
||||
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO', undefined, 'lead')
|
||||
await cambiarEstadoLead(monday, 'lead-1', 'CALIFICADO', undefined, 'lead')
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Contactado' },
|
||||
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Calificado' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
|
||||
it('Estado Perdido + Motivo No Compra + COMENTARIOS + registro en Motivos No Compra', async () => {
|
||||
it('default (oportunidad): Estado Perdido + Motivo No Compra + COMENTARIOS + registro en catálogo', async () => {
|
||||
const monday = mondayMock()
|
||||
const motivoItemId = await descartarLead(monday, 'op-1', 'Precio', 'Se le hizo caro')
|
||||
expect(motivoItemId).toBe('motivo-item-1')
|
||||
@@ -275,6 +349,27 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
|
||||
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
|
||||
})
|
||||
|
||||
it('fuente lead: Estado Descartado + Notas (sin dropdown Motivo) + registro en catálogo', async () => {
|
||||
const monday = mondayMock()
|
||||
const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', 'Se le hizo caro', 'lead')
|
||||
expect(motivoItemId).toBe('motivo-item-1')
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Descartado' },
|
||||
{ boardId: '444', itemId: 'lead-1', columna: 'Notas', valor: 'Se le hizo caro' },
|
||||
])
|
||||
expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined()
|
||||
expect(monday.motivos).toEqual([
|
||||
{ nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fuente lead sin comentario no escribe Notas', async () => {
|
||||
const monday = mondayMock()
|
||||
await descartarLead(monday, 'lead-1', 'Timing', undefined, 'lead')
|
||||
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Descartado' }])
|
||||
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
|
||||
})
|
||||
|
||||
it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
|
||||
const monday = mondayMock()
|
||||
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
|
||||
@@ -292,6 +387,102 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
|
||||
})
|
||||
|
||||
describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// El singleton Monday responde como si creara el lead en el tablero Leads
|
||||
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
|
||||
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
|
||||
vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined)
|
||||
// Default: el capturista NO tiene Monday vinculado → asignación round-robin
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.create.mockResolvedValue({})
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
})
|
||||
|
||||
function reqFake(overrides: Partial<Request> = {}): Request {
|
||||
return {
|
||||
body: { nombre: 'Lead Prueba', telefono: '4771234567' },
|
||||
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
|
||||
...overrides,
|
||||
} as Request
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
return { res: { json, status } as unknown as Response, status, json }
|
||||
}
|
||||
|
||||
it('201: registra la captura local (sin oportunidad) con el usuario del token', async () => {
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
leadId: 'lead-nuevo-9',
|
||||
oportunidadId: null,
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Lead Prueba',
|
||||
telefono: '4771234567',
|
||||
},
|
||||
})
|
||||
expect(prismaMock.leadOportunidad.create).not.toHaveBeenCalled() // los leads ya no generan oportunidad
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'round-robin' })
|
||||
})
|
||||
|
||||
it('con Monday vinculado, el lead queda asignado al capturista (columna Vendedor del tablero Leads)', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
|
||||
// Bandera anti-carrera ANTES de asignar + asignación al capturista
|
||||
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clave: 'rr:skip:lead-nuevo-9' }, create: { clave: 'rr:skip:lead-nuevo-9', valor: 'capturista' } }),
|
||||
)
|
||||
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('lead-nuevo-9', 'Lead Prueba', 12345, 'leads')
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' })
|
||||
})
|
||||
|
||||
it('un fallo al asignar al capturista no rompe la captura (cae al round-robin)', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
|
||||
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'round-robin' })
|
||||
espia.mockRestore()
|
||||
})
|
||||
|
||||
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
|
||||
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
})
|
||||
|
||||
it('un fallo del registro local no rompe la respuesta (el lead ya quedó en Monday)', async () => {
|
||||
prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
|
||||
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
espia.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearAfluenciaEndpoint (POST /api/afluencias)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// El singleton Monday responde como si creara la afluencia en Oportunidades
|
||||
@@ -332,7 +523,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
it('201: registra la captura local y el auto-mapping con el usuario del token', async () => {
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
await crearAfluenciaEndpoint(reqFake(), res)
|
||||
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
@@ -356,13 +547,13 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
await crearAfluenciaEndpoint(reqFake(), res)
|
||||
|
||||
// Bandera anti-carrera ANTES de asignar + asignación al capturista
|
||||
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clave: 'rr:skip:op-nueva-9' }, create: { clave: 'rr:skip:op-nueva-9', valor: 'capturista' } }),
|
||||
)
|
||||
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345)
|
||||
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345, 'oportunidades')
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'capturista' })
|
||||
})
|
||||
@@ -373,7 +564,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
await crearAfluenciaEndpoint(reqFake(), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' })
|
||||
@@ -383,7 +574,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
|
||||
await crearAfluenciaEndpoint(reqFake({ usuarioToken: undefined }), res)
|
||||
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
// El auto-mapping sí se escribe (no depende del usuario)
|
||||
@@ -396,7 +587,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
await crearAfluenciaEndpoint(reqFake(), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
espia.mockRestore()
|
||||
@@ -404,24 +595,11 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
})
|
||||
|
||||
describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportunidades', () => {
|
||||
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
||||
id: 'lead-1',
|
||||
nombre: 'Lead Prueba',
|
||||
createdAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
estado: 'NUEVO',
|
||||
origen: '',
|
||||
telefono: '',
|
||||
email: '',
|
||||
vendedorIds: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('matchea por nombre, teléfono y email (case-insensitive) en ambos tableros, con su fuente', () => {
|
||||
const leads = [
|
||||
lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
|
||||
lead({ id: 'b', telefono: '477 123 4567' }),
|
||||
lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
|
||||
leadMonday({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
|
||||
leadMonday({ id: 'b', telefono: '477 123 4567' }),
|
||||
leadMonday({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
|
||||
]
|
||||
const ops = [
|
||||
oportunidadMonday({ id: 'op-c', email: 'Cliente@Correo.com' }),
|
||||
@@ -436,14 +614,14 @@ describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportun
|
||||
})
|
||||
|
||||
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 = [leadMonday({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ', email: 'andres@correo.com' })]
|
||||
expect(buscarLeads(leads, [], 'maria')).toHaveLength(1)
|
||||
expect(buscarLeads(leads, [], 'andrés')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ordena por updatedAt descendente (mezclando tableros) y limita a 15', () => {
|
||||
const leads = Array.from({ length: 10 }, (_, i) =>
|
||||
lead({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }),
|
||||
leadMonday({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }),
|
||||
)
|
||||
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` }),
|
||||
|
||||
@@ -9,20 +9,30 @@ import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* Captura y acciones del CRM ligero sobre el tablero Oportunidades: la
|
||||
* afluencia ES la oportunidad en etapa 'Afluencia' (UN solo registro con todo
|
||||
* el perfil). El tablero Leads queda como archivo histórico: solo se consulta
|
||||
* en la búsqueda dual del picker del alta de clientes.
|
||||
* Los labels de estado/origen son los reales del board (verificados contra
|
||||
* Monday); la whitelist evita escribir labels que el status no tenga dados de
|
||||
* alta, y cada valor de la whitelist se traduce al label real del board.
|
||||
* Captura y acciones del CRM ligero, sobre DOS tableros:
|
||||
* - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual
|
||||
* desde la UI de Monday. Item ligero (nombre, teléfono, email, origen,
|
||||
* desarrollo, notas). Los leads NO crean oportunidad.
|
||||
* - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma
|
||||
* completa de /client) — la afluencia ES la oportunidad en etapa
|
||||
* 'Afluencia', con todo el perfil.
|
||||
* Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del
|
||||
* alta conviven con ambos tableros vía el parámetro `fuente`.
|
||||
* Los labels se validan/resuelven contra los reales de cada board (los
|
||||
* dropdown y status de Monday no auto-crean labels al escribir).
|
||||
*/
|
||||
|
||||
// Transiciones que el vendedor puede hacer desde la app Mi día
|
||||
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
|
||||
|
||||
// Traducción de la whitelist (valores que mandan los frontends) a los labels
|
||||
// reales de la columna status 'Estado' del board Oportunidades
|
||||
// reales de la columna status 'Estado' de cada board
|
||||
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
|
||||
CONTACTADO: 'Contactado',
|
||||
'Intento de contacto': 'Intento de contacto',
|
||||
'AGENDA CITA': 'Agenda Cita',
|
||||
CALIFICADO: 'Calificado',
|
||||
}
|
||||
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
|
||||
CONTACTADO: 'Contactado',
|
||||
'Intento de contacto': 'Intento de contacto',
|
||||
@@ -30,6 +40,11 @@ const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
||||
CALIFICADO: 'Calificado',
|
||||
}
|
||||
|
||||
export type FuenteLead = 'lead' | 'oportunidad'
|
||||
|
||||
const fuenteSchema = z.enum(['lead', 'oportunidad']).optional()
|
||||
|
||||
/** Captura rápida de lead (tablero Leads): solo los campos ligeros */
|
||||
const crearLeadSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
telefono: z.string().trim().min(1).optional(),
|
||||
@@ -37,6 +52,16 @@ const crearLeadSchema = z.object({
|
||||
origen: z.string().trim().min(1).optional(),
|
||||
desarrollo: z.string().trim().min(1).optional(),
|
||||
notas: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
|
||||
const crearAfluenciaSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
telefono: z.string().trim().min(1).optional(),
|
||||
email: z.string().trim().email('Email inválido').optional(),
|
||||
origen: z.string().trim().min(1).optional(),
|
||||
desarrollo: z.string().trim().min(1).optional(),
|
||||
notas: z.string().trim().min(1).optional(),
|
||||
// Campos de calificación de la afluencia (opcionales; los labels fijos del
|
||||
// front coinciden con los sembrados en Monday, no se validan contra el board)
|
||||
modelo: z.string().trim().min(1).optional(),
|
||||
@@ -58,11 +83,15 @@ const cambiarEstadoSchema = z.object({
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
|
||||
.optional(),
|
||||
// Tablero del item: 'lead' = tablero Leads; 'oportunidad' = Oportunidades
|
||||
// (default por compat: las afluencias nuevas viven ahí)
|
||||
fuente: fuenteSchema,
|
||||
})
|
||||
|
||||
const descartarSchema = z.object({
|
||||
motivo: z.enum(MOTIVOS_NO_COMPRA),
|
||||
comentario: z.string().trim().min(1).optional(),
|
||||
fuente: fuenteSchema,
|
||||
})
|
||||
|
||||
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
|
||||
@@ -76,7 +105,7 @@ const sinAcentos = (s: string): string =>
|
||||
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
|
||||
.toLowerCase()
|
||||
|
||||
/** Resultado del picker del alta: conviven el archivo histórico (Leads) y el modelo nuevo (Oportunidades) */
|
||||
/** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */
|
||||
export interface ResultadoBusquedaLead {
|
||||
id: string
|
||||
nombre: string
|
||||
@@ -84,13 +113,13 @@ export interface ResultadoBusquedaLead {
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
fuente: FuenteLead
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
|
||||
* AMBOS tableros — Leads (histórico) y Oportunidades (modelo nuevo) —, ordena
|
||||
* por actualización reciente y limita a 15 resultados combinados.
|
||||
* AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente
|
||||
* y limita a 15 resultados combinados.
|
||||
*/
|
||||
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
|
||||
const needle = sinAcentos(q)
|
||||
@@ -126,8 +155,10 @@ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonda
|
||||
type MondayLeads = Pick<
|
||||
MondayService,
|
||||
| 'cambiarValorColumna'
|
||||
| 'crearLead'
|
||||
| 'crearOportunidadAfluencia'
|
||||
| 'crearMotivoNoCompra'
|
||||
| 'buscarLeadPorTelefono'
|
||||
| 'buscarOportunidadPorTelefono'
|
||||
| 'labelsDeColumna'
|
||||
| 'obtenerNombreItem'
|
||||
@@ -136,7 +167,51 @@ type MondayLeads = Pick<
|
||||
| 'moverItemAGrupo'
|
||||
>
|
||||
|
||||
export type ResultadoCrearLead = { id: string; oportunidadId: string } | { duplicado: OportunidadMonday }
|
||||
export type ResultadoCrearLead =
|
||||
| { id: string; oportunidadId: string | null }
|
||||
| { duplicado: { id: string; nombre: string } }
|
||||
|
||||
/**
|
||||
* Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura
|
||||
* rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con
|
||||
* ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
|
||||
* Origen y desarrollo se validan contra los labels reales del board Leads.
|
||||
*/
|
||||
export async function crearLead(
|
||||
monday: MondayLeads,
|
||||
datos: z.infer<typeof crearLeadSchema>,
|
||||
): Promise<ResultadoCrearLead> {
|
||||
const boardLeads = config.monday.boards.leads
|
||||
|
||||
if (datos.telefono !== undefined) {
|
||||
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
|
||||
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
|
||||
}
|
||||
|
||||
if (datos.origen !== undefined) {
|
||||
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
|
||||
if (!labels.includes(datos.origen)) {
|
||||
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (datos.desarrollo !== undefined) {
|
||||
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
|
||||
if (!labels.includes(datos.desarrollo)) {
|
||||
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
const id = await monday.crearLead({
|
||||
nombre: datos.nombre,
|
||||
telefono: datos.telefono,
|
||||
email: datos.email,
|
||||
origenLabel: datos.origen,
|
||||
desarrolloLabel: datos.desarrollo,
|
||||
notas: datos.notas,
|
||||
})
|
||||
return { id, oportunidadId: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
|
||||
@@ -148,15 +223,15 @@ export type ResultadoCrearLead = { id: string; oportunidadId: string } | { dupli
|
||||
* 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 crearAfluencia(
|
||||
monday: MondayLeads,
|
||||
datos: z.infer<typeof crearLeadSchema>,
|
||||
datos: z.infer<typeof crearAfluenciaSchema>,
|
||||
): Promise<ResultadoCrearLead> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
|
||||
if (datos.telefono !== undefined) {
|
||||
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
|
||||
if (existente) return { duplicado: existente }
|
||||
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
|
||||
}
|
||||
|
||||
if (datos.origen !== undefined) {
|
||||
@@ -202,33 +277,49 @@ export async function crearLead(
|
||||
return { id, oportunidadId: id }
|
||||
}
|
||||
|
||||
/** Cambia el estado de la afluencia (whitelist → label real del board); en AGENDA CITA puede setear la Fecha */
|
||||
/**
|
||||
* Cambia el estado del item (whitelist → label real del board correspondiente
|
||||
* según `fuente`); en AGENDA CITA puede setear la columna Fecha.
|
||||
*/
|
||||
export async function cambiarEstadoLead(
|
||||
monday: MondayLeads,
|
||||
itemId: string,
|
||||
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
||||
fecha?: string,
|
||||
fuente: FuenteLead = 'oportunidad',
|
||||
): Promise<void> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
|
||||
const esLead = fuente === 'lead'
|
||||
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
|
||||
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
|
||||
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
|
||||
if (estado === 'AGENDA CITA' && fecha !== undefined) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
|
||||
await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Descarte del item según `fuente`:
|
||||
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
|
||||
* sembrado; si no existiera se omite) + comentario en COMENTARIOS.
|
||||
* - 'lead': Estado 'Descartado' + comentario en Notas.
|
||||
* Ambos registran el motivo en el tablero catálogo Motivos No Compra.
|
||||
*/
|
||||
export async function descartarLead(
|
||||
monday: MondayLeads,
|
||||
itemId: string,
|
||||
motivo: string,
|
||||
comentario?: string,
|
||||
fuente: FuenteLead = 'oportunidad',
|
||||
): Promise<string> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
const nombreItem = await monday.obtenerNombreItem(itemId)
|
||||
if (fuente === 'lead') {
|
||||
const boardLeads = config.monday.boards.leads
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado')
|
||||
if (comentario !== undefined) {
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
|
||||
}
|
||||
} else {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
|
||||
if (comentario !== undefined) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
|
||||
@@ -240,6 +331,7 @@ export async function descartarLead(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
|
||||
}
|
||||
}
|
||||
return monday.crearMotivoNoCompra({
|
||||
nombre: `${motivo} — ${nombreItem}`,
|
||||
categoria: motivo,
|
||||
@@ -247,7 +339,42 @@ export async function descartarLead(
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
|
||||
/**
|
||||
* Self-assign del capturista con bandera anti-carrera rr:skip (para que el
|
||||
* webhook create_item no lo reparta por round-robin mientras Monday propaga la
|
||||
* escritura; si la asignación falla, se apaga y cae al reparto).
|
||||
* Devuelve cómo quedó la asignación.
|
||||
*/
|
||||
async function asignarCapturistaSiAplica(
|
||||
usuarioId: string | undefined,
|
||||
itemId: string,
|
||||
nombre: string,
|
||||
board: 'leads' | 'oportunidades',
|
||||
): Promise<'capturista' | 'round-robin'> {
|
||||
if (usuarioId === undefined) return 'round-robin'
|
||||
try {
|
||||
const capturista = await prisma.usuario.findUnique({
|
||||
where: { id: usuarioId },
|
||||
select: { mondayUserId: true },
|
||||
})
|
||||
if (capturista?.mondayUserId != null) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista')
|
||||
try {
|
||||
await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board)
|
||||
return 'capturista'
|
||||
} catch (err) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo asignar el item ${itemId} al capturista:`, err)
|
||||
}
|
||||
return 'round-robin'
|
||||
}
|
||||
|
||||
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
|
||||
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearLeadSchema.parse(req.body)
|
||||
const resultado = await crearLead(mondayService, body)
|
||||
@@ -260,9 +387,43 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
return
|
||||
}
|
||||
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
|
||||
// no guarda quién dio de alta el lead). leadId = el id de la oportunidad
|
||||
// (modelo nuevo: la afluencia ES la oportunidad). Un fallo aquí no rompe la
|
||||
// respuesta: la afluencia ya quedó creada en Monday.
|
||||
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
|
||||
// el lead ya quedó creado en Monday.
|
||||
if (req.usuarioToken?.sub) {
|
||||
try {
|
||||
await prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: resultado.id,
|
||||
oportunidadId: null, // los leads ya no generan oportunidad
|
||||
usuarioId: req.usuarioToken.sub,
|
||||
nombreLead: body.nombre,
|
||||
telefono: body.telefono ?? null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
|
||||
}
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'leads')
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
|
||||
}
|
||||
|
||||
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
|
||||
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearAfluenciaSchema.parse(req.body)
|
||||
const resultado = await crearAfluencia(mondayService, body)
|
||||
if ('duplicado' in resultado) {
|
||||
res.status(409).json({
|
||||
error: 'Ya existe un lead con ese teléfono',
|
||||
leadId: resultado.duplicado.id,
|
||||
nombre: resultado.duplicado.nombre,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Registro local del capturista para "Mis capturas" del panel Mi día.
|
||||
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
|
||||
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
|
||||
if (req.usuarioToken?.sub) {
|
||||
try {
|
||||
await prisma.leadCaptura.create({
|
||||
@@ -276,7 +437,7 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
|
||||
console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err)
|
||||
}
|
||||
}
|
||||
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
|
||||
@@ -284,62 +445,36 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
|
||||
try {
|
||||
await prisma.leadOportunidad.create({
|
||||
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId },
|
||||
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId ?? resultado.id },
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
|
||||
}
|
||||
// Asignación: si el capturista tiene su usuario Monday vinculado, la
|
||||
// oportunidad queda asignada a él (columna Asesor). La bandera rr:skip se
|
||||
// prende ANTES de asignar para que el webhook create_item no la reparta por
|
||||
// round-robin mientras Monday propaga la escritura (si la asignación falla,
|
||||
// se apaga y cae al reparto).
|
||||
let asignacion: 'capturista' | 'round-robin' = 'round-robin'
|
||||
if (req.usuarioToken?.sub) {
|
||||
try {
|
||||
const capturista = await prisma.usuario.findUnique({
|
||||
where: { id: req.usuarioToken.sub },
|
||||
select: { mondayUserId: true },
|
||||
})
|
||||
if (capturista?.mondayUserId != null) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'capturista')
|
||||
try {
|
||||
await automatizacionesService.asignarACapturista(resultado.id, body.nombre, capturista.mondayUserId)
|
||||
asignacion = 'capturista'
|
||||
} catch (err) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'round-robin')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo asignar la afluencia ${resultado.id} al capturista:`, err)
|
||||
}
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'oportunidades')
|
||||
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?, fuente? } */
|
||||
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
|
||||
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/descartar { motivo, comentario? } */
|
||||
/** POST /api/leads/:itemId/descartar { motivo, comentario?, fuente? } */
|
||||
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = descartarSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
|
||||
res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente)
|
||||
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
|
||||
* clientes. Busca en AMBOS tableros (Leads histórico + Oportunidades); q
|
||||
* mínimo 2 caracteres; devuelve máximo 15, cada uno con su `fuente`.
|
||||
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
|
||||
* caracteres; devuelve máximo 15, cada uno con su `fuente`.
|
||||
*/
|
||||
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const q = String(req.query['q'] ?? '').trim()
|
||||
|
||||
@@ -64,25 +64,25 @@ describe('webhookMonday', () => {
|
||||
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('create_item en Oportunidades dispara el round-robin del item', () => {
|
||||
it('create_item en Oportunidades dispara el round-robin del item (tablero oportunidades)', () => {
|
||||
const { res, sendStatus } = resFake()
|
||||
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res)
|
||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999')
|
||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'oportunidades')
|
||||
})
|
||||
|
||||
it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
|
||||
const { res, sendStatus } = resFake()
|
||||
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res)
|
||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777')
|
||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777', 'oportunidades')
|
||||
})
|
||||
|
||||
it('create_item en Leads ya NO dispara nada (tablero archivo histórico)', () => {
|
||||
it('create_item en Leads dispara el round-robin del tablero Leads (SIN crear oportunidad)', () => {
|
||||
const { res, sendStatus } = resFake()
|
||||
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
|
||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'leads')
|
||||
})
|
||||
|
||||
it('create_item en otro board no dispara round-robin', () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ export function webhookMonday(req: Request, res: Response): void {
|
||||
}
|
||||
|
||||
const { type, boardId, itemId, pulseId } = parsed.data.event
|
||||
const esBoardLeads = String(boardId) === config.monday.boards.leads
|
||||
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
|
||||
const idEvento = itemId ?? pulseId
|
||||
|
||||
@@ -53,26 +54,26 @@ export function webhookMonday(req: Request, res: Response): void {
|
||||
|
||||
// Monday registra el webhook como 'create_item' pero el payload real llega
|
||||
// como 'create_pulse'; se aceptan ambos.
|
||||
// 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) {
|
||||
// Item nuevo en Leads u Oportunidades → solo round-robin del tablero
|
||||
// correspondiente (los leads NO crean oportunidad; el Alta Afluencia entra
|
||||
// directo a Oportunidades vía POST /api/afluencias). 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.
|
||||
if ((type === 'create_item' || type === 'create_pulse') && (esBoardLeads || esBoardOportunidades) && idEvento !== undefined) {
|
||||
const id = String(idEvento)
|
||||
const board = esBoardLeads ? 'leads' : 'oportunidades'
|
||||
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
|
||||
void automatizacionesService
|
||||
.runRoundRobin(id)
|
||||
.runRoundRobin(id, board)
|
||||
.then((r) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[webhook monday] round-robin item=${id}: ${r.asignados} asignado(s)`)
|
||||
console.log(`[webhook monday] round-robin ${board} item=${id}: ${r.asignados} asignado(s)`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// 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 ${board} item=${id}:`, err)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { perderOportunidadEndpoint } from '../controllers/oportunidades.controll
|
||||
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
|
||||
import {
|
||||
cambiarEstadoLeadEndpoint,
|
||||
crearAfluenciaEndpoint,
|
||||
crearLeadEndpoint,
|
||||
buscarLeadsEndpoint,
|
||||
descartarLeadEndpoint,
|
||||
@@ -140,6 +141,9 @@ apiRouter.post('/leads', crearLeadEndpoint)
|
||||
apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint)
|
||||
apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint)
|
||||
|
||||
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
||||
|
||||
apiRouter.get('/monday/usuarios', listarUsuariosMondayEndpoint)
|
||||
apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
claveVentaCreada,
|
||||
esEstadoTerminal,
|
||||
esEtapa,
|
||||
ESTADOS_TERMINALES_LEAD,
|
||||
oportunidadesInactivas,
|
||||
oportunidadesSinContactar,
|
||||
parseRrUserIds,
|
||||
vendedorEnTurno,
|
||||
type StateStore,
|
||||
} from './automatizaciones.service.js'
|
||||
import type { OportunidadMonday, UsuarioMonday } from './monday.service.js'
|
||||
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
@@ -24,6 +25,21 @@ const VENDEDORES: UsuarioMonday[] = [
|
||||
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
|
||||
]
|
||||
|
||||
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
||||
return {
|
||||
id,
|
||||
nombre: `Lead ${id}`,
|
||||
createdAt: '2026-08-01T09:00:00Z',
|
||||
updatedAt: '2026-08-01T10:00:00Z',
|
||||
estado: 'Nuevo',
|
||||
origen: '',
|
||||
vendedorIds: [],
|
||||
telefono: '',
|
||||
email: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
|
||||
return {
|
||||
id,
|
||||
@@ -55,10 +71,10 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
||||
}
|
||||
}
|
||||
|
||||
/** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */
|
||||
function mondayMock(oportunidades: OportunidadMonday[] = []) {
|
||||
/** Monday mockeado: leads y oportunidades fijos, registra asignaciones, updates y actividades */
|
||||
function mondayMock(leads: LeadMonday[] = [], oportunidades: OportunidadMonday[] = []) {
|
||||
return {
|
||||
asignaciones: [] as { itemId: string; userId: number }[],
|
||||
asignaciones: [] as { itemId: string; userId: number; tablero: string }[],
|
||||
updates: [] as { itemId: string; body: string }[],
|
||||
actividades: [] as {
|
||||
nombre: string
|
||||
@@ -70,11 +86,17 @@ function mondayMock(oportunidades: OportunidadMonday[] = []) {
|
||||
}[],
|
||||
apartados: [] as Record<string, unknown>[],
|
||||
ventas: [] as Record<string, unknown>[],
|
||||
async getLeads() {
|
||||
return leads
|
||||
},
|
||||
async getOportunidades() {
|
||||
return oportunidades
|
||||
},
|
||||
async asignarVendedorALead(itemId: string, userId: number) {
|
||||
this.asignaciones.push({ itemId, userId, tablero: 'leads' })
|
||||
},
|
||||
async asignarAsesorAOportunidad(itemId: string, userId: number) {
|
||||
this.asignaciones.push({ itemId, userId })
|
||||
this.asignaciones.push({ itemId, userId, tablero: 'oportunidades' })
|
||||
},
|
||||
async crearUpdateEnItem(itemId: string, body: string) {
|
||||
this.updates.push({ itemId, body })
|
||||
@@ -125,8 +147,8 @@ describe('parseRrUserIds', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
|
||||
it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => {
|
||||
describe('esEstadoTerminal (terminales por tablero)', () => {
|
||||
it('Ganado / Perdido / Abandono son terminales en Oportunidades (default)', () => {
|
||||
expect(esEstadoTerminal('GANADO')).toBe(true)
|
||||
expect(esEstadoTerminal(' perdido ')).toBe(true)
|
||||
expect(esEstadoTerminal('Abandono')).toBe(true)
|
||||
@@ -135,6 +157,14 @@ describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
|
||||
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
|
||||
expect(esEstadoTerminal('')).toBe(false)
|
||||
})
|
||||
|
||||
it('Descartado / Calificado / Abandono son terminales en Leads (title case, case-insensitive)', () => {
|
||||
expect(esEstadoTerminal('Descartado', ESTADOS_TERMINALES_LEAD)).toBe(true)
|
||||
expect(esEstadoTerminal('calificado', ESTADOS_TERMINALES_LEAD)).toBe(true)
|
||||
expect(esEstadoTerminal('ABANDONO', ESTADOS_TERMINALES_LEAD)).toBe(true)
|
||||
expect(esEstadoTerminal('Nuevo', ESTADOS_TERMINALES_LEAD)).toBe(false)
|
||||
expect(esEstadoTerminal('Contactado', ESTADOS_TERMINALES_LEAD)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vendedorEnTurno (rotación round-robin)', () => {
|
||||
@@ -188,18 +218,24 @@ describe('claveRecordatorio (dedup por día)', () => {
|
||||
// ---------- Servicio (monday mockeado, sin red) ----------
|
||||
|
||||
describe('AutomatizacionesService.runRoundRobin', () => {
|
||||
it('asigna asesores en rotación a afluencias sin persona y persiste el índice', async () => {
|
||||
const monday = mondayMock([oportunidad('1'), oportunidad('2'), oportunidad('3'), oportunidad('4')])
|
||||
it('asigna en rotación sobre AMBOS tableros y persiste el índice (Leads → Vendedor, Oportunidades → Asesor)', async () => {
|
||||
const monday = mondayMock([lead('l1'), lead('l2')], [oportunidad('o1'), oportunidad('o2')])
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
const r1 = await svc.runRoundRobin()
|
||||
expect(r1.asignados).toBe(4)
|
||||
expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
|
||||
expect(monday.asignaciones).toEqual([
|
||||
{ itemId: 'l1', userId: 101, tablero: 'leads' },
|
||||
{ itemId: 'l2', userId: 102, tablero: 'leads' },
|
||||
{ itemId: 'o1', userId: 103, tablero: 'oportunidades' },
|
||||
{ itemId: 'o2', userId: 101, tablero: 'oportunidades' },
|
||||
])
|
||||
expect(r1.detalle.map((d) => d.tablero)).toEqual(['leads', 'leads', 'oportunidades', 'oportunidades'])
|
||||
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
|
||||
|
||||
// Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
|
||||
const monday2 = mondayMock([oportunidad('5')])
|
||||
const monday2 = mondayMock([], [oportunidad('o5')])
|
||||
const svc2 = new AutomatizacionesService(monday2, state)
|
||||
const r2 = await svc2.runRoundRobin()
|
||||
expect(r2.asignados).toBe(1)
|
||||
@@ -207,8 +243,25 @@ describe('AutomatizacionesService.runRoundRobin', () => {
|
||||
expect(state.mapa.get('rr:indice')).toBe('2')
|
||||
})
|
||||
|
||||
it('solo reparte etapa Afluencia: Cotizador/Contactado NO entran al reparto', async () => {
|
||||
it('Leads: reparte los sin Vendedor y no terminales (labels title case, case-insensitive)', async () => {
|
||||
const monday = mondayMock([
|
||||
lead('1'),
|
||||
lead('2', { vendedorIds: [101] }), // ya asignado
|
||||
lead('3', { estado: 'Descartado' }), // terminal
|
||||
lead('4', { estado: 'Calificado' }), // terminal
|
||||
lead('5', { estado: 'Contactado' }), // en seguimiento: sí entra
|
||||
])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin(undefined, 'leads')
|
||||
expect(r.asignados).toBe(2)
|
||||
expect(monday.asignaciones).toEqual([
|
||||
{ itemId: '1', userId: 101, tablero: 'leads' },
|
||||
{ itemId: '5', userId: 102, tablero: 'leads' },
|
||||
])
|
||||
})
|
||||
|
||||
it('Oportunidades: solo reparte etapa Afluencia (Cotizador/Contactado NO entran)', async () => {
|
||||
const monday = mondayMock([], [
|
||||
oportunidad('1'),
|
||||
oportunidad('2', { asesorIds: [101] }), // ya asignada
|
||||
oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra
|
||||
@@ -217,35 +270,43 @@ describe('AutomatizacionesService.runRoundRobin', () => {
|
||||
oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra
|
||||
])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin()
|
||||
const r = await svc.runRoundRobin(undefined, 'oportunidades')
|
||||
expect(r.asignados).toBe(2)
|
||||
expect(monday.asignaciones).toEqual([
|
||||
{ itemId: '1', userId: 101 },
|
||||
{ itemId: '6', userId: 102 },
|
||||
{ itemId: '1', userId: 101, tablero: 'oportunidades' },
|
||||
{ itemId: '6', userId: 102, tablero: 'oportunidades' },
|
||||
])
|
||||
})
|
||||
|
||||
it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => {
|
||||
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
|
||||
it('omite items con bandera rr:skip=capturista (los asigna su capturista)', async () => {
|
||||
const monday = mondayMock([lead('1')], [oportunidad('2')])
|
||||
const state = stateEnMemoria()
|
||||
state.mapa.set('rr:skip:1', 'capturista') // el capturista la asigna
|
||||
state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna
|
||||
state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
|
||||
const svc = crearServicio(monday, state)
|
||||
const r = await svc.runRoundRobin()
|
||||
expect(r.asignados).toBe(1)
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }])
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'oportunidades' }])
|
||||
})
|
||||
|
||||
it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => {
|
||||
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
|
||||
it('con soloItemId + board solo evalúa ese item (ruta de los webhooks create_item)', async () => {
|
||||
const monday = mondayMock([lead('1'), lead('2')], [oportunidad('9')])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin('2')
|
||||
const r = await svc.runRoundRobin('2', 'leads')
|
||||
expect(r.asignados).toBe(1)
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }])
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'leads' }])
|
||||
})
|
||||
|
||||
it('con soloItemId de otro tablero no asigna nada', async () => {
|
||||
const monday = mondayMock([lead('1')], [oportunidad('9')])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin('9', 'leads') // '9' no está en Leads
|
||||
expect(r.asignados).toBe(0)
|
||||
expect(monday.asignaciones).toEqual([])
|
||||
})
|
||||
|
||||
it('cachea los vendedores del equipo en el estado (una sola llamada dentro del TTL)', async () => {
|
||||
const monday = mondayMock([])
|
||||
const monday = mondayMock()
|
||||
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
@@ -258,7 +319,7 @@ describe('AutomatizacionesService.runRoundRobin', () => {
|
||||
})
|
||||
|
||||
it('cache con formato viejo (arreglo sin ts) se descarta y re-consulta', async () => {
|
||||
const monday = mondayMock([])
|
||||
const monday = mondayMock()
|
||||
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
|
||||
const state = stateEnMemoria()
|
||||
state.mapa.set('rr:vendedores', JSON.stringify([{ id: 999, nombre: 'Viejo', email: '' }]))
|
||||
@@ -277,7 +338,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
|
||||
]
|
||||
|
||||
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
|
||||
const monday = mondayMock(oportunidadesViejas())
|
||||
const monday = mondayMock([], oportunidadesViejas())
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
@@ -291,7 +352,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
|
||||
})
|
||||
|
||||
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
|
||||
const monday = mondayMock(oportunidadesViejas())
|
||||
const monday = mondayMock([], oportunidadesViejas())
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
@@ -320,7 +381,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
||||
const ahora = new Date('2026-08-22T09:00:00Z')
|
||||
|
||||
it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
|
||||
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
|
||||
const monday = mondayMock([], [oportunidad('1'), oportunidad('2')])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||
|
||||
const r = await svc.runRoundRobin()
|
||||
@@ -343,7 +404,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
||||
})
|
||||
|
||||
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
|
||||
const monday = mondayMock([oportunidad('1')])
|
||||
const monday = mondayMock([], [oportunidad('1')])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
|
||||
await svc.runRoundRobin()
|
||||
expect(monday.actividades).toEqual([])
|
||||
@@ -355,7 +416,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
||||
|
||||
await svc.asignarACapturista('op-77', 'Lead Propio', 555)
|
||||
|
||||
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555 }])
|
||||
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555, tablero: 'oportunidades' }])
|
||||
expect(monday.actividades[0]).toMatchObject({
|
||||
nombre: 'Contactar a Lead Propio',
|
||||
responsableId: 555,
|
||||
@@ -364,18 +425,28 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('asignarACapturista con board "leads" escribe la columna Vendedor del tablero Leads', async () => {
|
||||
const monday = mondayMock([])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||
|
||||
await svc.asignarACapturista('lead-77', 'Lead Propio', 555, 'leads')
|
||||
|
||||
expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555, tablero: 'leads' }])
|
||||
expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', leadItemId: 'lead-77' })
|
||||
})
|
||||
|
||||
it('asignarACapturista con actividadesEnabled=false solo asigna', async () => {
|
||||
const monday = mondayMock([])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
|
||||
|
||||
await svc.asignarACapturista('op-78', 'Lead Propio', 555)
|
||||
|
||||
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555 }])
|
||||
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555, tablero: 'oportunidades' }])
|
||||
expect(monday.actividades).toEqual([])
|
||||
})
|
||||
|
||||
it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => {
|
||||
const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
|
||||
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||
|
||||
const r = await svc.escaneoInactividad({ preview: false, ahora })
|
||||
@@ -391,21 +462,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
||||
})
|
||||
|
||||
it('preview de inactividad no crea actividades', async () => {
|
||||
const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })])
|
||||
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||
await svc.escaneoInactividad({ preview: true, ahora })
|
||||
expect(monday.actividades).toEqual([])
|
||||
})
|
||||
|
||||
it('un fallo al crear la actividad no rompe la asignación', async () => {
|
||||
const monday = mondayMock([oportunidad('1')])
|
||||
const monday = mondayMock([], [oportunidad('1')])
|
||||
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||
|
||||
const r = await svc.runRoundRobin()
|
||||
expect(r.asignados).toBe(1) // la asignación se hizo igual
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101, tablero: 'oportunidades' }])
|
||||
spyError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -462,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
||||
const anterior = configAuto['coordinadorUserId']
|
||||
configAuto['coordinadorUserId'] = 0
|
||||
try {
|
||||
const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||
const state = stateEnMemoria()
|
||||
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
||||
|
||||
@@ -487,7 +558,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
||||
const anterior = configAuto['coordinadorUserId']
|
||||
configAuto['coordinadorUserId'] = 999
|
||||
try {
|
||||
const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||
const r = await svc.escaneoEscalado({ ahora })
|
||||
expect(r.escalados).toBe(1)
|
||||
@@ -502,7 +573,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
||||
const anterior = configAuto['coordinadorUserId']
|
||||
configAuto['coordinadorUserId'] = 0
|
||||
try {
|
||||
const monday = mondayMock([oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||
const monday = mondayMock([], [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||
const state = stateEnMemoria()
|
||||
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
||||
|
||||
@@ -523,7 +594,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
||||
configAuto['coordinadorUserId'] = 0
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const monday = mondayMock([
|
||||
const monday = mondayMock([], [
|
||||
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
|
||||
oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
|
||||
])
|
||||
@@ -546,7 +617,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
|
||||
|
||||
describe('AutomatizacionesService.escaneoOportunidades', () => {
|
||||
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
|
||||
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' })])
|
||||
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
@@ -562,7 +633,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
|
||||
})
|
||||
|
||||
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
|
||||
const monday = mondayMock([oportunidad('11', { etapa: 'GANADO' })])
|
||||
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
@@ -573,7 +644,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
|
||||
})
|
||||
|
||||
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
|
||||
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
||||
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
@@ -588,7 +659,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
|
||||
})
|
||||
|
||||
it('ignora oportunidades en otras etapas', async () => {
|
||||
const monday = mondayMock([
|
||||
const monday = mondayMock([], [
|
||||
oportunidad('10', { etapa: 'Cotizador' }),
|
||||
oportunidad('11', { etapa: 'Perdido' }),
|
||||
oportunidad('12', { etapa: 'Afluencia' }),
|
||||
@@ -602,7 +673,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
|
||||
})
|
||||
|
||||
it('un fallo de Monday en una oportunidad no detiene a las demás', async () => {
|
||||
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
||||
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
||||
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { config } from '../config/index.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import { mondayService, type MondayService, type OportunidadMonday, type UsuarioMonday } from './monday.service.js'
|
||||
import {
|
||||
mondayService,
|
||||
type LeadMonday,
|
||||
type MondayService,
|
||||
type OportunidadMonday,
|
||||
type UsuarioMonday,
|
||||
} from './monday.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
* Automatizaciones sobre el tablero Oportunidades de Monday (la afluencia ES
|
||||
* la oportunidad en etapa 'Afluencia'):
|
||||
* (a) asignación round-robin de asesores a afluencias sin persona asignada
|
||||
* Automatizaciones sobre Monday:
|
||||
* (a) asignación round-robin en AMBOS tableros del embudo: Leads (captura
|
||||
* ligera, columna Vendedor, cualquier estado no terminal sin asignar) y
|
||||
* Oportunidades (Alta Afluencia, columna Asesor, solo etapa 'Afluencia'
|
||||
* sin asignar — las del cotizador nacen en 'Cotizador' y no entran)
|
||||
* (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'
|
||||
@@ -16,6 +24,10 @@ import { prisma } from './prisma.js'
|
||||
* recordatorios el mismo día.
|
||||
*/
|
||||
|
||||
// Estados del tablero Leads en los que el lead ya no entra al reparto ni es
|
||||
// trabajo activo (labels title case en el board; se comparan case-insensitive)
|
||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] as const
|
||||
|
||||
// Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo
|
||||
// activo del asesor (fuera de seguimiento).
|
||||
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const
|
||||
@@ -157,7 +169,9 @@ export function esEtapa(etapa: string, esperada: string): boolean {
|
||||
|
||||
type MondayLeads = Pick<
|
||||
MondayService,
|
||||
| 'getLeads'
|
||||
| 'getOportunidades'
|
||||
| 'asignarVendedorALead'
|
||||
| 'asignarAsesorAOportunidad'
|
||||
| 'crearUpdateEnItem'
|
||||
| 'getUsuariosEquipo'
|
||||
@@ -176,8 +190,13 @@ export interface AsignacionRR {
|
||||
lead: string
|
||||
vendedorId: number
|
||||
vendedor: string
|
||||
/** Tablero del item asignado: 'leads' (Vendedor) u 'oportunidades' (Asesor) */
|
||||
tablero: TableroRoundRobin
|
||||
}
|
||||
|
||||
/** Tablero del embudo que cubre el round-robin / la asignación al capturista */
|
||||
export type TableroRoundRobin = 'leads' | 'oportunidades'
|
||||
|
||||
export interface ResultadoRoundRobin {
|
||||
asignados: number
|
||||
detalle: AsignacionRR[]
|
||||
@@ -294,25 +313,45 @@ export class AutomatizacionesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Una pasada de round-robin: asigna asesor a toda oportunidad en etapa
|
||||
* 'Afluencia' sin persona asignada (las creadas por el cotizador nacen en
|
||||
* 'Cotizador' y NO entran al reparto). Si se pasa `soloItemId`, solo se
|
||||
* evalúa ese item (ruta del webhook create_item).
|
||||
* Una pasada de round-robin sobre AMBOS tableros del embudo:
|
||||
* - Leads: todo lead sin Vendedor asignado y con estado no terminal
|
||||
* (Descartado/Calificado/Abandono quedan fuera).
|
||||
* - Oportunidades: solo etapa 'Afluencia' sin Asesor (las creadas por el
|
||||
* cotizador nacen en 'Cotizador' y NO entran al reparto).
|
||||
* Si se pasa `soloItemId`, solo se evalúa ese item, y `board` limita la
|
||||
* pasada a un tablero (rutas de los webhooks create_item).
|
||||
*/
|
||||
async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
|
||||
async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise<ResultadoRoundRobin> {
|
||||
interface Candidato {
|
||||
id: string
|
||||
nombre: string
|
||||
tablero: TableroRoundRobin
|
||||
}
|
||||
const candidatos: Candidato[] = []
|
||||
if (board === undefined || board === 'leads') {
|
||||
const leads: LeadMonday[] = await this.monday.getLeads()
|
||||
for (const l of leads) {
|
||||
if (l.vendedorIds.length > 0) continue
|
||||
if (esEstadoTerminal(l.estado, ESTADOS_TERMINALES_LEAD)) continue
|
||||
if (soloItemId !== undefined && l.id !== soloItemId) continue
|
||||
candidatos.push({ id: l.id, nombre: l.nombre, tablero: 'leads' })
|
||||
}
|
||||
}
|
||||
if (board === undefined || board === 'oportunidades') {
|
||||
const oportunidades = await this.monday.getOportunidades()
|
||||
const sinAsignar = oportunidades.filter(
|
||||
(o) =>
|
||||
o.asesorIds.length === 0 &&
|
||||
o.etapa.trim().toUpperCase() === 'AFLUENCIA' &&
|
||||
(soloItemId === undefined || o.id === soloItemId),
|
||||
)
|
||||
// Las afluencias capturadas desde una app con sesión se asignan a su
|
||||
// capturista (bandera rr:skip='capturista'): sin este filtro, la latencia
|
||||
// de Monday hacía que el webhook las viera sin asesor y pisara la asignación.
|
||||
const pendientes: typeof sinAsignar = []
|
||||
for (const o of sinAsignar) {
|
||||
if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o)
|
||||
for (const o of oportunidades) {
|
||||
if (o.asesorIds.length > 0) continue
|
||||
if (o.etapa.trim().toUpperCase() !== 'AFLUENCIA') continue
|
||||
if (soloItemId !== undefined && o.id !== soloItemId) continue
|
||||
candidatos.push({ id: o.id, nombre: o.nombre, tablero: 'oportunidades' })
|
||||
}
|
||||
}
|
||||
// Los items capturados desde una app con sesión se asignan a su capturista
|
||||
// (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday
|
||||
// hacía que el webhook los viera sin persona asignada y pisara la asignación.
|
||||
const pendientes: Candidato[] = []
|
||||
for (const c of candidatos) {
|
||||
if ((await this.state.get(`${CLAVE_RR_SKIP}${c.id}`)) !== 'capturista') pendientes.push(c)
|
||||
}
|
||||
if (pendientes.length === 0) {
|
||||
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
|
||||
@@ -324,18 +363,19 @@ export class AutomatizacionesService {
|
||||
if (!Number.isInteger(indice) || indice < 0) indice = 0
|
||||
|
||||
const detalle: AsignacionRR[] = []
|
||||
for (const op of pendientes) {
|
||||
for (const c of pendientes) {
|
||||
const vendedor = vendedorEnTurno(vendedores, indice)
|
||||
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
|
||||
await this.monday.asignarAsesorAOportunidad(op.id, vendedor.id)
|
||||
if (c.tablero === 'leads') await this.monday.asignarVendedorALead(c.id, vendedor.id)
|
||||
else await this.monday.asignarAsesorAOportunidad(c.id, vendedor.id)
|
||||
if (this.actividadesEnabled) {
|
||||
await this.crearActividadSegura({
|
||||
nombre: `Contactar a ${op.nombre}`,
|
||||
nombre: `Contactar a ${c.nombre}`,
|
||||
responsableId: vendedor.id,
|
||||
leadItemId: op.id,
|
||||
leadItemId: c.id,
|
||||
})
|
||||
}
|
||||
detalle.push({ itemId: op.id, lead: op.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
|
||||
detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero })
|
||||
indice++
|
||||
}
|
||||
|
||||
@@ -345,19 +385,25 @@ export class AutomatizacionesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Asigna la afluencia a quien la capturó desde una app del ecosistema (su
|
||||
* usuario Monday vinculado, columna Asesor de Oportunidades) y le crea la
|
||||
* actividad de contacto — el mismo paquete que haría el round-robin, pero
|
||||
* para el capturista. No hay carrera con el webhook create_item:
|
||||
* runRoundRobin solo toma oportunidades sin persona asignada.
|
||||
* Asigna el item a quien lo capturó desde una app del ecosistema (su usuario
|
||||
* Monday vinculado) y le crea la actividad de contacto — el mismo paquete
|
||||
* que haría el round-robin, pero para el capturista. `board` decide la
|
||||
* columna: Vendedor en Leads, Asesor en Oportunidades. No hay carrera con el
|
||||
* webhook create_item: runRoundRobin solo toma items sin persona asignada.
|
||||
*/
|
||||
async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise<void> {
|
||||
await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId)
|
||||
async asignarACapturista(
|
||||
itemId: string,
|
||||
nombre: string,
|
||||
vendedorId: number,
|
||||
board: TableroRoundRobin = 'oportunidades',
|
||||
): Promise<void> {
|
||||
if (board === 'leads') await this.monday.asignarVendedorALead(itemId, vendedorId)
|
||||
else await this.monday.asignarAsesorAOportunidad(itemId, vendedorId)
|
||||
if (this.actividadesEnabled) {
|
||||
await this.crearActividadSegura({
|
||||
nombre: `Contactar a ${nombre}`,
|
||||
responsableId: vendedorId,
|
||||
leadItemId: oportunidadId,
|
||||
leadItemId: itemId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
esEstadoFinalApartado,
|
||||
esEstadoTerminalLead,
|
||||
esEtapaTerminal,
|
||||
resumenMiDia,
|
||||
type UsuarioMiDia,
|
||||
} from './mi-dia.service.js'
|
||||
import type { ActividadMonday, ApartadoMonday, OportunidadMonday } from './monday.service.js'
|
||||
import type { ActividadMonday, ApartadoMonday, LeadMonday, OportunidadMonday } from './monday.service.js'
|
||||
|
||||
const MI_ID = 106736727
|
||||
const OTRO_ID = 111111111
|
||||
@@ -14,6 +15,20 @@ const AHORA = new Date('2026-08-28T12:00:00') // viernes
|
||||
const HOY = '2026-08-28'
|
||||
const MANANA = '2026-08-29'
|
||||
|
||||
function lead(parcial: Partial<LeadMonday> & { id: string }): LeadMonday {
|
||||
return {
|
||||
nombre: `Lead ${parcial.id}`,
|
||||
createdAt: '2026-08-25T10:00:00Z',
|
||||
updatedAt: '2026-08-27T10:00:00Z',
|
||||
estado: 'Nuevo',
|
||||
origen: 'Facebook',
|
||||
telefono: '',
|
||||
email: '',
|
||||
vendedorIds: [MI_ID],
|
||||
...parcial,
|
||||
}
|
||||
}
|
||||
|
||||
function oportunidad(parcial: Partial<OportunidadMonday> & { id: string }): OportunidadMonday {
|
||||
return {
|
||||
nombre: `Lead ${parcial.id}`,
|
||||
@@ -52,11 +67,15 @@ function apartado(parcial: Partial<ApartadoMonday> & { id: string }): ApartadoMo
|
||||
}
|
||||
|
||||
function mondayMock(datos: {
|
||||
leads?: LeadMonday[]
|
||||
oportunidades?: OportunidadMonday[]
|
||||
actividades?: ActividadMonday[]
|
||||
apartados?: ApartadoMonday[]
|
||||
}) {
|
||||
return {
|
||||
async getLeads() {
|
||||
return datos.leads ?? []
|
||||
},
|
||||
async getOportunidades() {
|
||||
return datos.oportunidades ?? []
|
||||
},
|
||||
@@ -74,22 +93,29 @@ const sinMapeo: UsuarioMiDia = { nombre: 'Erick Peña', rol: 'vendedor', mondayU
|
||||
const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null }
|
||||
|
||||
describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
||||
it('filtra mis leads por mondayUserId (columna Asesor) y excluye etapas terminales', async () => {
|
||||
it('misLeads: merge de Leads (Vendedor=mío) y Oportunidades (Asesor=mío), sin terminales, con fuente, por updatedAt desc', async () => {
|
||||
const monday = mondayMock({
|
||||
leads: [
|
||||
lead({ id: 'l1', estado: 'Nuevo', updatedAt: '2026-08-26T10:00:00Z' }),
|
||||
lead({ id: 'l2', estado: 'Contactado', telefono: '4771234567', updatedAt: '2026-08-28T08:00:00Z' }),
|
||||
lead({ id: 'l3', estado: 'Descartado' }), // terminal Leads: fuera
|
||||
lead({ id: 'l4', estado: 'Calificado' }), // terminal Leads: fuera
|
||||
lead({ id: 'l5', estado: 'Nuevo', vendedorIds: [OTRO_ID] }), // de otro: fuera
|
||||
],
|
||||
oportunidades: [
|
||||
oportunidad({ id: '1', etapa: 'Afluencia' }),
|
||||
oportunidad({ id: '2', etapa: 'Contactado', telefono: '4771234567' }),
|
||||
oportunidad({ id: '3', etapa: 'Perdido' }),
|
||||
oportunidad({ id: '4', etapa: 'Calificado' }), // NO es terminal en el nuevo modelo
|
||||
oportunidad({ id: '5', etapa: 'Abandono' }),
|
||||
oportunidad({ id: '6', etapa: 'Ganado' }),
|
||||
oportunidad({ id: '7', etapa: 'Afluencia', asesorIds: [OTRO_ID] }),
|
||||
oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z' }),
|
||||
oportunidad({ id: 'o2', etapa: 'Calificado' }), // NO es terminal en Oportunidades
|
||||
oportunidad({ id: 'o3', etapa: 'Perdido' }),
|
||||
oportunidad({ id: 'o4', etapa: 'Abandono' }),
|
||||
oportunidad({ id: 'o5', etapa: 'Ganado' }),
|
||||
oportunidad({ id: 'o6', etapa: 'Afluencia', asesorIds: [OTRO_ID] }),
|
||||
],
|
||||
})
|
||||
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
||||
expect(r.sinMapeoMonday).toBe(false)
|
||||
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2', '4'])
|
||||
expect(r.misLeads[1]?.telefono).toBe('4771234567')
|
||||
expect(r.misLeads.map((l) => l.id)).toEqual(['l2', 'o1', 'o2', 'l1']) // updatedAt desc mezclando tableros
|
||||
expect(r.misLeads.map((l) => l.fuente)).toEqual(['lead', 'oportunidad', 'oportunidad', 'lead'])
|
||||
expect(r.misLeads[0]?.telefono).toBe('4771234567')
|
||||
})
|
||||
|
||||
it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => {
|
||||
@@ -156,8 +182,12 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
||||
expect(r.apartadosPorVencer).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => {
|
||||
it('conteos: leads nuevos de los últimos 7 días suman AMBOS tableros (incluye terminales recientes)', async () => {
|
||||
const monday = mondayMock({
|
||||
leads: [
|
||||
lead({ id: 'l1', createdAt: '2026-08-28T08:00:00' }), // hoy
|
||||
lead({ id: 'l2', createdAt: '2026-08-10T08:00:00' }), // viejo
|
||||
],
|
||||
oportunidades: [
|
||||
oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
|
||||
oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
|
||||
@@ -166,11 +196,16 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
||||
],
|
||||
})
|
||||
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
||||
expect(r.conteos.leadsNuevosSemana).toBe(2)
|
||||
expect(r.conteos.leadsNuevosSemana).toBe(3) // 1 lead + 2 oportunidades
|
||||
})
|
||||
|
||||
it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', async () => {
|
||||
it('vendedor no ve sección equipo; coordinador sí (conteos por persona de AMBOS tableros y sin asignar)', async () => {
|
||||
const monday = mondayMock({
|
||||
leads: [
|
||||
lead({ id: 'l1', vendedorIds: [] }), // sin asignar, Nuevo
|
||||
lead({ id: 'l2', vendedorIds: [], estado: 'Contactado' }), // sin asignar, no Nuevo
|
||||
lead({ id: 'l3', vendedorIds: [OTRO_ID] }),
|
||||
],
|
||||
oportunidades: [
|
||||
oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia
|
||||
oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia
|
||||
@@ -186,9 +221,9 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
||||
expect(rVendedor.equipo).toBeUndefined()
|
||||
|
||||
const rCoord = await resumenMiDia(monday, coordinador, nombres, [], [], AHORA)
|
||||
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1)
|
||||
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(2) // 1 lead Nuevo + 1 afluencia sin asignar
|
||||
expect(rCoord.equipo?.porVendedor).toEqual([
|
||||
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 },
|
||||
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 3, leadsNuevosSemana: 2 }, // 1 lead + 2 ops (1 vieja)
|
||||
{ mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 },
|
||||
])
|
||||
})
|
||||
@@ -222,14 +257,34 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('mis capturas: capturas viejas (del tablero Leads) ya no cruzan; estado y origen van vacíos', async () => {
|
||||
const monday = mondayMock({})
|
||||
it('mis capturas: capturas rápidas cruzan contra el tablero Leads por id', async () => {
|
||||
const monday = mondayMock({
|
||||
leads: [lead({ id: 'lead-9', nombre: 'Pepe Rápido', estado: 'Contactado', origen: 'Web', vendedorIds: [OTRO_ID] })],
|
||||
})
|
||||
const capturas = [
|
||||
{ leadId: 'lead-viejo-1', nombreLead: 'Lead Histórico', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
|
||||
{ leadId: 'lead-9', nombreLead: 'Pepe Rápido', telefono: '4771112233', createdAt: new Date('2026-08-27T09:00:00Z') },
|
||||
]
|
||||
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
|
||||
expect(r.misCapturas).toEqual([
|
||||
{ leadId: 'lead-viejo-1', nombre: 'Lead Histórico', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
|
||||
{
|
||||
leadId: 'lead-9',
|
||||
nombre: 'Pepe Rápido',
|
||||
telefono: '4771112233',
|
||||
estado: 'Contactado',
|
||||
origen: 'Web',
|
||||
capturadoEn: '2026-08-27T09:00:00.000Z',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('mis capturas: si el item ya no existe en ningún tablero, estado y origen van vacíos', async () => {
|
||||
const monday = mondayMock({})
|
||||
const capturas = [
|
||||
{ leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
|
||||
]
|
||||
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
|
||||
expect(r.misCapturas).toEqual([
|
||||
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -310,6 +365,14 @@ describe('clasificadores de estado', () => {
|
||||
expect(esEtapaTerminal('Intento de contacto')).toBe(false)
|
||||
})
|
||||
|
||||
it('estados terminales de lead (tablero Leads, case-insensitive)', () => {
|
||||
expect(esEstadoTerminalLead('DESCARTADO')).toBe(true)
|
||||
expect(esEstadoTerminalLead('calificado')).toBe(true)
|
||||
expect(esEstadoTerminalLead('Abandono')).toBe(true)
|
||||
expect(esEstadoTerminalLead('Nuevo')).toBe(false)
|
||||
expect(esEstadoTerminalLead('Intento de contacto')).toBe(false)
|
||||
})
|
||||
|
||||
it('estados finales de apartado', () => {
|
||||
expect(esEstadoFinalApartado('Escriturado')).toBe(true)
|
||||
expect(esEstadoFinalApartado('CANCELADO')).toBe(true)
|
||||
|
||||
@@ -5,13 +5,16 @@ import type { MondayService } from './monday.service.js'
|
||||
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
|
||||
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
|
||||
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
|
||||
* La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia
|
||||
* ES la oportunidad en etapa 'Afluencia'; el tablero Leads es archivo
|
||||
* histórico). "Mis capturas" viene del registro local (tabla LeadCaptura), no
|
||||
* de Monday: es la lista de afluencias que el usuario dio de alta, con su
|
||||
* estado actual.
|
||||
* "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna
|
||||
* Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES
|
||||
* la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro
|
||||
* local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario
|
||||
* dio de alta, con su estado actual (cruza contra ambos tableros por id).
|
||||
*/
|
||||
|
||||
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
||||
|
||||
// Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor
|
||||
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
|
||||
|
||||
@@ -34,6 +37,8 @@ export interface LeadMiDia {
|
||||
estado: string
|
||||
origen: string
|
||||
updatedAt: string
|
||||
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
}
|
||||
|
||||
export interface ActividadMiDia {
|
||||
@@ -98,7 +103,7 @@ export interface ResumenMiDia {
|
||||
}
|
||||
}
|
||||
|
||||
type MondayMiDia = Pick<MondayService, 'getOportunidades' | 'getActividades' | 'getApartados'>
|
||||
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados'>
|
||||
|
||||
const MS_DIA = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -111,6 +116,10 @@ function fechaLocal(base: Date, dias: number): string {
|
||||
return `${y}-${m}-${dd}`
|
||||
}
|
||||
|
||||
export function esEstadoTerminalLead(estado: string): boolean {
|
||||
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
|
||||
}
|
||||
|
||||
export function esEtapaTerminal(etapa: string): boolean {
|
||||
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
|
||||
}
|
||||
@@ -133,7 +142,8 @@ export async function resumenMiDia(
|
||||
const miId = usuario.mondayUserId
|
||||
const sinMapeo = miId === null
|
||||
|
||||
const [oportunidades, actividades, apartados] = await Promise.all([
|
||||
const [leads, oportunidades, actividades, apartados] = await Promise.all([
|
||||
monday.getLeads(),
|
||||
monday.getOportunidades(),
|
||||
monday.getActividades(),
|
||||
monday.getApartados(),
|
||||
@@ -145,8 +155,25 @@ export async function resumenMiDia(
|
||||
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
|
||||
|
||||
// --- Listas personales (vacías si no hay mapeo Monday) ---
|
||||
// "Mis leads" mezcla ambos tableros: Leads (Vendedor = yo, estado no
|
||||
// terminal) + Oportunidades (Asesor = yo, etapa no terminal), más reciente
|
||||
// primero y con su fuente para las acciones del CRM.
|
||||
const misLeadsLeads: LeadMiDia[] = sinMapeo
|
||||
? []
|
||||
: leads
|
||||
.filter((l) => l.vendedorIds.includes(miId) && !esEstadoTerminalLead(l.estado))
|
||||
.map((l) => ({
|
||||
id: l.id,
|
||||
nombre: l.nombre,
|
||||
telefono: l.telefono,
|
||||
email: l.email,
|
||||
estado: l.estado,
|
||||
origen: l.origen,
|
||||
updatedAt: l.updatedAt,
|
||||
fuente: 'lead',
|
||||
}))
|
||||
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
|
||||
const misLeads: LeadMiDia[] = mios
|
||||
const misLeadsOportunidades: LeadMiDia[] = mios
|
||||
.filter((o) => !esEtapaTerminal(o.etapa))
|
||||
.map((o) => ({
|
||||
id: o.id,
|
||||
@@ -156,7 +183,11 @@ export async function resumenMiDia(
|
||||
estado: o.etapa,
|
||||
origen: o.origen,
|
||||
updatedAt: o.updatedAt,
|
||||
fuente: 'oportunidad',
|
||||
}))
|
||||
const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) =>
|
||||
b.updatedAt.localeCompare(a.updatedAt),
|
||||
)
|
||||
|
||||
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
|
||||
// del pipeline personal, no la lista de trabajo activo)
|
||||
@@ -166,19 +197,21 @@ export async function resumenMiDia(
|
||||
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
|
||||
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday,
|
||||
// cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a
|
||||
// Leads; las de Alta Afluencia, a Oportunidades), más reciente primero
|
||||
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
|
||||
const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o]))
|
||||
const misCapturas: CapturaMiDia[] = capturas
|
||||
.map((c) => {
|
||||
const lead = leadsPorId.get(c.leadId)
|
||||
const op = oportunidadesPorId.get(c.leadId)
|
||||
return {
|
||||
leadId: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
estado: op?.etapa ?? '',
|
||||
origen: op?.origen ?? '',
|
||||
estado: lead?.estado ?? op?.etapa ?? '',
|
||||
origen: lead?.origen ?? op?.origen ?? '',
|
||||
capturadoEn: c.createdAt.toISOString(),
|
||||
}
|
||||
})
|
||||
@@ -222,8 +255,12 @@ export async function resumenMiDia(
|
||||
diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA),
|
||||
}))
|
||||
|
||||
// Conteo de la semana: míos en AMBOS tableros (leads + oportunidades)
|
||||
const miosLeadRows = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
|
||||
const conteos = {
|
||||
leadsNuevosSemana: mios.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length,
|
||||
leadsNuevosSemana:
|
||||
miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length +
|
||||
mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length,
|
||||
actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length,
|
||||
}
|
||||
|
||||
@@ -240,9 +277,23 @@ export async function resumenMiDia(
|
||||
}
|
||||
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||
const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa))
|
||||
// Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor
|
||||
// (etapa Afluencia) — lo que el round-robin aún no reparte
|
||||
const sinAsignar =
|
||||
leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO')
|
||||
.length +
|
||||
oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length
|
||||
const conteoPorId = new Map<number, number>()
|
||||
const nuevosPorId = new Map<number, number>()
|
||||
// Leads y oportunidades comparten el conteo por vendedor (misma persona Monday)
|
||||
for (const l of leads) {
|
||||
if (esEstadoTerminalLead(l.estado)) continue
|
||||
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
|
||||
for (const id of l.vendedorIds) {
|
||||
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
|
||||
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
for (const o of oportunidades) {
|
||||
if (esEtapaTerminal(o.etapa)) continue
|
||||
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
|
||||
@@ -252,8 +303,7 @@ export async function resumenMiDia(
|
||||
}
|
||||
}
|
||||
resumen.equipo = {
|
||||
// Afluencias recién llegadas que el round-robin aún no reparte
|
||||
leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length,
|
||||
leadsNuevosSinAsignar: sinAsignar,
|
||||
porVendedor: [...conteoPorId.entries()]
|
||||
.map(([mondayUserId, leadsAsignados]) => ({
|
||||
mondayUserId,
|
||||
|
||||
@@ -557,6 +557,97 @@ describe('MondayService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('asignarVendedorALead (columna people Vendedor del tablero Leads)', () => {
|
||||
it('escribe personsAndTeams con el user id en el board Leads (444 en tests)', async () => {
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) {
|
||||
return respuestaOk({ boards: [{ columns: [{ id: 'people_v', title: 'Vendedor', type: 'people' }] }] })
|
||||
}
|
||||
return respuestaOk({ change_column_value: { id: 'lead-1' } })
|
||||
})
|
||||
|
||||
await crearServicio().asignarVendedorALead('lead-1', 12345)
|
||||
|
||||
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
|
||||
expect(mutacion?.body.variables['boardId']).toBe('444')
|
||||
expect(mutacion?.body.variables['columnId']).toBe('people_v')
|
||||
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({
|
||||
personsAndTeams: [{ id: 12345, kind: 'person' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearLead (captura ligera en el tablero Leads)', () => {
|
||||
const COLUMNAS_LEADS_LIGERO = [
|
||||
{ id: 'color_estado', title: 'Estado', type: 'status' },
|
||||
{ id: 'date_fecha', title: 'Fecha', type: 'date' },
|
||||
{ id: 'phone_tel', title: 'Teléfono', type: 'phone' },
|
||||
{ id: 'email_em', title: 'Email', type: 'email' },
|
||||
{ id: 'color_or', title: 'Origen', type: 'status' },
|
||||
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
|
||||
{ id: 'text_notas', title: 'Notas', type: 'text' },
|
||||
]
|
||||
|
||||
it('crea el item ligero con Estado NUEVO y Fecha de hoy (sin perfil ni oportunidad)', async () => {
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('settings_str')) {
|
||||
return respuestaOk({
|
||||
boards: [
|
||||
{
|
||||
columns: COLUMNAS_LEADS_LIGERO.filter((c) => c.type === 'status').map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
settings_str: '{"labels":{"1":"Nuevo"}}',
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] })
|
||||
return respuestaOk({ create_item: { id: 'lead-nuevo-1' } })
|
||||
})
|
||||
|
||||
const id = await crearServicio().crearLead({
|
||||
nombre: 'Lead Prueba',
|
||||
telefono: '4771234567',
|
||||
email: 'lead@correo.com',
|
||||
origenLabel: 'Facebook',
|
||||
desarrolloLabel: 'SENDA',
|
||||
notas: 'Vio el volante',
|
||||
})
|
||||
expect(id).toBe('lead-nuevo-1')
|
||||
|
||||
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
||||
expect(mutacion?.body.variables['boardId']).toBe('444') // tablero Leads
|
||||
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
||||
expect(parsed['color_estado']).toEqual({ label: 'Nuevo' }) // 'NUEVO' resuelto al label real
|
||||
expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) })
|
||||
expect(parsed['phone_tel']).toEqual({ phone: '4771234567', countryShortName: 'MX' })
|
||||
expect(parsed['email_em']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' })
|
||||
expect(parsed['color_or']).toEqual({ label: 'Facebook' })
|
||||
expect(parsed['color_des']).toEqual({ label: 'SENDA' })
|
||||
expect(parsed['text_notas']).toBe('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":"Nuevo"}}' }] }],
|
||||
})
|
||||
}
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] })
|
||||
return respuestaOk({ create_item: { id: 'lead-nuevo-2' } })
|
||||
})
|
||||
|
||||
await crearServicio().crearLead({ 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'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('asignarAsesorAOportunidad (columna people Asesor)', () => {
|
||||
it('escribe personsAndTeams con el user id en el board Oportunidades (555 en tests)', async () => {
|
||||
const llamadas = mockearFetch((body) => {
|
||||
|
||||
@@ -1033,6 +1033,27 @@ export class MondayService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor desde
|
||||
* /hoy o captura manual). La columna date 'Fecha' lleva la fecha de captura
|
||||
* (hoy, hora local). Los leads NO crean oportunidad (eso es /api/afluencias).
|
||||
*/
|
||||
async crearLead(datos: DatosLead): Promise<string> {
|
||||
const boardId = config.monday.boards.leads
|
||||
const hoy = new Date()
|
||||
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Estado: 'NUEVO',
|
||||
Fecha: fechaHoy,
|
||||
Teléfono: datos.telefono,
|
||||
Email: datos.email,
|
||||
Origen: datos.origenLabel,
|
||||
Desarrollo: datos.desarrolloLabel,
|
||||
Notas: datos.notas,
|
||||
})
|
||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos
|
||||
* y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
|
||||
@@ -1111,10 +1132,9 @@ export class MondayService {
|
||||
return abiertas
|
||||
}
|
||||
|
||||
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
|
||||
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const col = await this.columnaId(boardId, 'Asesor')
|
||||
/** Asigna una persona (user ID de Monday) a una columna people de un item */
|
||||
private async asignarPersona(boardId: string, itemId: string, tituloColumna: string, userId: number): Promise<void> {
|
||||
const col = await this.columnaId(boardId, tituloColumna)
|
||||
await this.graphql(
|
||||
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
||||
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
||||
@@ -1128,6 +1148,16 @@ export class MondayService {
|
||||
)
|
||||
}
|
||||
|
||||
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
|
||||
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
|
||||
return this.asignarPersona(config.monday.boards.oportunidades, itemId, 'Asesor', userId)
|
||||
}
|
||||
|
||||
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
||||
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
||||
return this.asignarPersona(config.monday.boards.leads, itemId, 'Vendedor', userId)
|
||||
}
|
||||
|
||||
/** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */
|
||||
async crearUpdateEnItem(itemId: string, body: string): Promise<string> {
|
||||
const data = await this.graphql<{ create_update: { id: string } }>(
|
||||
@@ -1255,6 +1285,16 @@ export interface DatosActividad {
|
||||
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
|
||||
}
|
||||
|
||||
/** Captura ligera de lead (tablero Leads): la forma corta de /hoy y Monday UI */
|
||||
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
|
||||
}
|
||||
|
||||
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
||||
export const mondayService = new MondayService()
|
||||
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
|
||||
import type { OportunidadMonday, Unidad } from './monday.service.js'
|
||||
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
|
||||
import type { PlanPago } from './cobranza.service.js'
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
/** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */
|
||||
/** Lead del tablero Leads (captura ligera; fuente de la sección leads) */
|
||||
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
||||
return {
|
||||
id,
|
||||
nombre: `Lead ${id}`,
|
||||
createdAt: '2026-08-20T10:00:00Z',
|
||||
updatedAt: '2026-08-20T10:00:00Z',
|
||||
estado: 'Nuevo',
|
||||
origen: 'Facebook',
|
||||
vendedorIds: [],
|
||||
telefono: '',
|
||||
email: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Afluencia del board Oportunidades (fuente del perfilamiento de afluencia) */
|
||||
function afluencia(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
|
||||
return {
|
||||
id,
|
||||
@@ -68,37 +84,37 @@ function pago(id: string, fechaVencimiento: string, monto: number, montoPagado =
|
||||
|
||||
const AHORA = new Date('2026-08-22T12:00:00') // mediodía local
|
||||
|
||||
const DATOS_BASE: DatosResumen = { oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
|
||||
const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
|
||||
|
||||
// ---------- construirResumen (puro) ----------
|
||||
|
||||
describe('construirResumen', () => {
|
||||
it('cuenta la afluencia por estado (Etapa) y por origen (Origen del Lead)', () => {
|
||||
it('cuenta leads por estado y por origen (tablero Leads, captura ligera)', () => {
|
||||
const r = construirResumen(
|
||||
{
|
||||
...DATOS_BASE,
|
||||
oportunidades: [
|
||||
afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }),
|
||||
afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }),
|
||||
afluencia('3', { etapa: 'Contactado', origen: 'Referido' }),
|
||||
afluencia('4', { etapa: '', origen: '' }),
|
||||
leads: [
|
||||
lead('1', { estado: 'Nuevo', origen: 'Facebook' }),
|
||||
lead('2', { estado: 'Nuevo', origen: 'Facebook' }),
|
||||
lead('3', { estado: 'Contactado', origen: 'Referido' }),
|
||||
lead('4', { estado: '', origen: '' }),
|
||||
],
|
||||
},
|
||||
AHORA,
|
||||
)
|
||||
expect(r.leads.total).toBe(4)
|
||||
expect(r.leads.porEstado).toEqual({ Afluencia: 2, Contactado: 1, '(sin dato)': 1 })
|
||||
expect(r.leads.porEstado).toEqual({ Nuevo: 2, Contactado: 1, '(sin dato)': 1 })
|
||||
expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 })
|
||||
})
|
||||
|
||||
it('cuenta afluencias nuevas de hoy y de los últimos 7 días (fecha local)', () => {
|
||||
it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => {
|
||||
const r = construirResumen(
|
||||
{
|
||||
...DATOS_BASE,
|
||||
oportunidades: [
|
||||
afluencia('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
|
||||
afluencia('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
|
||||
afluencia('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
|
||||
leads: [
|
||||
lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
|
||||
lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
|
||||
lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
|
||||
],
|
||||
},
|
||||
AHORA,
|
||||
@@ -300,7 +316,11 @@ describe('construirResumen', () => {
|
||||
describe('ReportesService', () => {
|
||||
function mondayMock() {
|
||||
return {
|
||||
llamadas: { oportunidades: 0, unidades: 0, actividades: 0 },
|
||||
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
|
||||
async getLeads(): Promise<LeadMonday[]> {
|
||||
this.llamadas.leads++
|
||||
return [lead('1')]
|
||||
},
|
||||
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||
this.llamadas.oportunidades++
|
||||
return [afluencia('1')]
|
||||
@@ -316,16 +336,17 @@ describe('ReportesService', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('agrega todas las fuentes en una sola respuesta (una sola llamada a Oportunidades)', async () => {
|
||||
it('agrega todas las fuentes en una sola respuesta', async () => {
|
||||
const monday = mondayMock()
|
||||
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
|
||||
const r = await svc.resumen()
|
||||
expect(r.leads.total).toBe(1) // la sección leads se alimenta de Oportunidades
|
||||
expect(r.leads.total).toBe(1) // la sección leads se alimenta del tablero Leads
|
||||
expect(r.oportunidades.total).toBe(1)
|
||||
expect(r.unidades.total).toBe(1)
|
||||
expect(r.cobranza.planes.activos).toBe(1)
|
||||
expect(r.actividadesAbiertas).toBe(7)
|
||||
expect(monday.llamadas.oportunidades).toBe(1) // sin llamada duplicada
|
||||
expect(monday.llamadas.leads).toBe(1)
|
||||
expect(monday.llamadas.oportunidades).toBe(1)
|
||||
})
|
||||
|
||||
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
|
||||
import type { MondayService, OportunidadMonday, Unidad } from './monday.service.js'
|
||||
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
|
||||
import { ETAPAS_PIPELINE } from './etapas.js'
|
||||
|
||||
/**
|
||||
* Agregador de reportes para la app de tableros (/dash/).
|
||||
* En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la
|
||||
* afluencia ES la oportunidad en etapa 'Afluencia'), oportunidades (Monday),
|
||||
* unidades (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
|
||||
* En UNA respuesta junta: leads (Monday, tablero Leads — captura ligera),
|
||||
* afluencia/perfilamiento (Monday, tablero Oportunidades — el Alta Afluencia
|
||||
* vive ahí), oportunidades (Monday), unidades (Monday), cobranza (Prisma
|
||||
* local) y actividades abiertas (Monday).
|
||||
* Cache en memoria de 60 s para no martillar la API de Monday.
|
||||
*/
|
||||
|
||||
@@ -76,8 +77,10 @@ export interface ResumenReportes {
|
||||
|
||||
/** Datos crudos que necesita el agregador (inyectables en tests) */
|
||||
export interface DatosResumen {
|
||||
// La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades`
|
||||
// se alimentan de la MISMA fuente: el tablero Oportunidades
|
||||
// La sección `leads` (total/nuevos/porEstado/porOrigen) lee el tablero Leads
|
||||
// (captura ligera); el `perfil` de la afluencia lee Oportunidades (Alta
|
||||
// Afluencia), que es también la fuente de la sección `oportunidades`
|
||||
leads: LeadMonday[]
|
||||
oportunidades: OportunidadMonday[]
|
||||
unidades: Unidad[]
|
||||
planes: PlanPago[] // dominio de cobranza (con pagos)
|
||||
@@ -164,8 +167,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
||||
const hoy = inicioDelDia(ahora)
|
||||
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
const creadosEn = (desde: Date): number =>
|
||||
datos.oportunidades.filter((o) => {
|
||||
const t = Date.parse(o.createdAt)
|
||||
datos.leads.filter((l) => {
|
||||
const t = Date.parse(l.createdAt)
|
||||
return !Number.isNaN(t) && t >= desde.getTime()
|
||||
}).length
|
||||
|
||||
@@ -221,14 +224,14 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
||||
|
||||
return {
|
||||
generadoEn: ahora.toISOString(),
|
||||
// Sección "leads" = afluencia sobre el tablero Oportunidades (modelo
|
||||
// nuevo: la afluencia ES la oportunidad en etapa 'Afluencia')
|
||||
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
|
||||
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
|
||||
leads: {
|
||||
total: datos.oportunidades.length,
|
||||
total: datos.leads.length,
|
||||
nuevosHoy: creadosEn(hoy),
|
||||
nuevosSemana: creadosEn(hace7dias),
|
||||
porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
|
||||
porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
|
||||
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
||||
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
||||
perfil: {
|
||||
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
|
||||
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
|
||||
@@ -277,7 +280,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
||||
|
||||
// ---------- Servicio con cache ----------
|
||||
|
||||
type MondayReportes = Pick<MondayService, 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
|
||||
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
|
||||
|
||||
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
|
||||
export type CargadorPlanes = () => Promise<PlanPago[]>
|
||||
@@ -313,13 +316,14 @@ export class ReportesService {
|
||||
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
|
||||
|
||||
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
||||
const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
|
||||
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
|
||||
this.monday.getLeads(),
|
||||
this.monday.getOportunidades(),
|
||||
this.monday.getUnidades(),
|
||||
this.monday.getActividadesAbiertas(),
|
||||
this.cargarPlanes(),
|
||||
])
|
||||
const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
|
||||
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
|
||||
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
|
||||
return resumen
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('AltaAfluenciaForm', () => {
|
||||
'Registrado en afluencia — se asignará por round-robin',
|
||||
)
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/leads')
|
||||
expect(url).toContain('/afluencias')
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
nombre: 'Ana Demo',
|
||||
telefono: '6641234567',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { crearLead, type LeadResumen } from '../services/api'
|
||||
import { crearAfluencia, type LeadResumen } from '../services/api'
|
||||
import {
|
||||
DESARROLLOS_LEAD,
|
||||
ESTADOS_CIVILES,
|
||||
@@ -94,7 +94,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
setAviso('')
|
||||
if (!validar()) return
|
||||
setEnviando(true)
|
||||
const res = await crearLead({
|
||||
const res = await crearAfluencia({
|
||||
nombre: form.nombre.trim(),
|
||||
telefono: form.telefono.trim(),
|
||||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||
|
||||
@@ -253,12 +253,12 @@ export type ResultadoCrearLead =
|
||||
| { ok: false; motivo: 'conexion' }
|
||||
|
||||
/**
|
||||
* POST /leads — captura ligera de afluencia: crea UN item en el tablero
|
||||
* Oportunidades (etapa Afluencia, con todo el perfil; la asigna el capturista
|
||||
* o el round-robin). Si el teléfono ya existe el API responde 409 con la
|
||||
* oportunidad existente (motivo 'duplicado').
|
||||
* POST /afluencias — Alta Afluencia (forma completa): crea UN item en el
|
||||
* tablero Oportunidades (etapa Afluencia, con todo el perfil; la asigna el
|
||||
* capturista o el round-robin). Si el teléfono ya existe el API responde 409
|
||||
* con la oportunidad existente (motivo 'duplicado').
|
||||
*/
|
||||
export async function crearLead(payload: {
|
||||
export async function crearAfluencia(payload: {
|
||||
nombre: string
|
||||
telefono: string
|
||||
email?: string
|
||||
@@ -280,7 +280,7 @@ export async function crearLead(payload: {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(
|
||||
`${API_URL}/leads`,
|
||||
`${API_URL}/afluencias`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -288,7 +288,7 @@ export async function crearLead(payload: {
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[api] crearLead: sin conexión al servidor', err)
|
||||
console.warn('[api] crearAfluencia: sin conexión al servidor', err)
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
manejar401(res)
|
||||
@@ -299,7 +299,7 @@ export async function crearLead(payload: {
|
||||
const cuerpo = (await res.json()) as { leadId: string; nombre: string }
|
||||
return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre }
|
||||
}
|
||||
console.warn(`[api] crearLead: HTTP ${res.status}`)
|
||||
console.warn(`[api] crearAfluencia: HTTP ${res.status}`)
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
|
||||
|
||||
@@ -115,19 +115,19 @@ describe('unidadesEnEtapa', () => {
|
||||
})
|
||||
|
||||
describe('datosFunnel', () => {
|
||||
it('ordena Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (todo desde porEtapa)', () => {
|
||||
it('ordena Leads → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado', () => {
|
||||
const funnel = datosFunnel(resumen())
|
||||
expect(funnel.map((e) => e.etapa)).toEqual(['Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
|
||||
expect(funnel.map((e) => e.valor)).toEqual([8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||||
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
|
||||
expect(funnel.map((e) => e.valor)).toEqual([40, 8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||||
})
|
||||
|
||||
it('tolera etiquetas con otra capitalización', () => {
|
||||
const r = resumen()
|
||||
r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
|
||||
const funnel = datosFunnel(r)
|
||||
expect(funnel[0]?.valor).toBe(3)
|
||||
expect(funnel[3]?.valor).toBe(4)
|
||||
expect(funnel[4]?.valor).toBe(3)
|
||||
expect(funnel[1]?.valor).toBe(3)
|
||||
expect(funnel[4]?.valor).toBe(4)
|
||||
expect(funnel[5]?.valor).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos del funnel comercial, todo desde el tablero Oportunidades (la
|
||||
* afluencia ES la oportunidad en etapa 'Afluencia'): Afluencia → Agendo Cita
|
||||
* → Cotizador → Aparto o más → Ganado. "Aparto o más" es acumulado
|
||||
* Datos del funnel comercial: Leads (total del tablero Leads, captura ligera)
|
||||
* → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (las etapas,
|
||||
* del tablero Oportunidades vía porEtapa). "Aparto o más" es acumulado
|
||||
* (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado.
|
||||
*/
|
||||
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
||||
@@ -117,6 +117,7 @@ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
||||
.reduce((acc, [, v]) => acc + v, 0)
|
||||
const apartoOMas = conteo('APARTO') + conteo('GANADO')
|
||||
return [
|
||||
{ etapa: 'Leads', valor: resumen.leads.total },
|
||||
{ etapa: 'Afluencia', valor: conteo('AFLUENCIA') },
|
||||
{ etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') },
|
||||
{ etapa: 'Cotizador', valor: conteo('COTIZADOR') },
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface LeadMiDia {
|
||||
estado: string
|
||||
origen: string
|
||||
updatedAt: string
|
||||
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
}
|
||||
|
||||
export interface ActividadMiDia {
|
||||
|
||||
@@ -133,7 +133,19 @@ function FilaLead({
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{lead.nombre}</p>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{lead.nombre}
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
<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>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
{lead.telefono || 'Sin teléfono'}
|
||||
{lead.origen ? ` · ${lead.origen}` : ''}
|
||||
@@ -366,10 +378,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<ul className="space-y-2">
|
||||
{resumen.misLeads.map((lead) => (
|
||||
<FilaLead
|
||||
key={lead.id}
|
||||
key={`${lead.fuente}-${lead.id}`}
|
||||
lead={lead}
|
||||
enviando={enviando}
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha), 'No se pudo actualizar el lead')}
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||
onDescartar={() => setDescartando(lead)}
|
||||
/>
|
||||
))}
|
||||
@@ -656,7 +668,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
const lead = descartando
|
||||
setDescartando(null)
|
||||
void ejecutar(
|
||||
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario),
|
||||
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente),
|
||||
'No se pudo descartar el lead',
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -41,18 +41,26 @@ export async function crearLead(datos: {
|
||||
})
|
||||
}
|
||||
|
||||
export function cambiarEstadoLead(itemId: string, estado: string, fecha?: string): Promise<unknown> {
|
||||
export function cambiarEstadoLead(itemId: string, estado: string, fecha?: string, fuente?: 'lead' | 'oportunidad'): Promise<unknown> {
|
||||
return fetchJson(`/leads/${itemId}/estado`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fecha !== undefined && fecha !== '' ? { estado, fecha } : { estado }),
|
||||
body: JSON.stringify({
|
||||
estado,
|
||||
...(fecha !== undefined && fecha !== '' ? { fecha } : {}),
|
||||
...(fuente !== undefined ? { fuente } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function descartarLead(itemId: string, motivo: string, comentario?: string): Promise<unknown> {
|
||||
export function descartarLead(itemId: string, motivo: string, comentario?: string, fuente?: 'lead' | 'oportunidad'): Promise<unknown> {
|
||||
return fetchJson(`/leads/${itemId}/descartar`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(comentario !== undefined && comentario !== '' ? { motivo, comentario } : { motivo }),
|
||||
body: JSON.stringify({
|
||||
motivo,
|
||||
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
|
||||
...(fuente !== undefined ? { fuente } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user