Nuevas tablas: - ActividadLocal (historial de llamadas, reuniones, tareas) - AsignacionLocal (round-robin local) - fechaLimite en LeadCaptura Servicios nuevos: - actividad-local.service.ts (CRUD actividades) - local-canal.ts (canal de envío para recordatorios) - seed-modo-local.js (activar modo local) Mi día local mejorado: - Mis actividades hoy (filtros usuario/fecha/estado) - Apartados por vencer (con fechaLimite real) - Equipo (contadores por vendedor, solo coordinador/admin) - unidadCodigo en oportunidades desde datosJson Automatizaciones locales: - Round-robin: asigna leads sin vendedor + crea actividad - Inactividad: detecta oportunidades >10 días sin update - Escalado: alerta oportunidades >48h sin contacto Reportes: actividadesAbiertas + parcial:false en modo local Cron wiring: crons locales cuando Monday caído 661 tests verdes.
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
// Mock Prisma before importing the service
|
|
vi.mock('./prisma.js', () => ({
|
|
prisma: {
|
|
leadCaptura: { findMany: vi.fn(), update: vi.fn() },
|
|
asignacionLocal: { findMany: vi.fn(), create: vi.fn() },
|
|
actividadLocal: { findFirst: vi.fn(), create: vi.fn() },
|
|
usuario: { findMany: vi.fn(), findFirst: vi.fn() },
|
|
},
|
|
}))
|
|
|
|
vi.mock('./actividad-local.service.js', () => ({
|
|
actividadLocalService: {
|
|
crearActividad: vi.fn(),
|
|
listarActividades: vi.fn(),
|
|
cerrarActividad: vi.fn(),
|
|
eliminarActividad: vi.fn(),
|
|
contarActividadesAbiertas: vi.fn(),
|
|
contarActividadesHoy: vi.fn(),
|
|
actividadesVencidas: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('./salud-monday.service.js', () => ({
|
|
saludMonday: {
|
|
estaDisponible: vi.fn(() => false),
|
|
registrarPing: vi.fn(),
|
|
registrarExito: vi.fn(),
|
|
registrarFallo: vi.fn(),
|
|
ok: vi.fn(),
|
|
sondear: vi.fn(),
|
|
restablecer: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
import { prisma } from './prisma.js'
|
|
import { actividadLocalService } from './actividad-local.service.js'
|
|
import { saludMonday } from './salud-monday.service.js'
|
|
import { AutomatizacionesService, type StateStore } from './automatizaciones.service.js'
|
|
|
|
// ── Fixtures ──
|
|
|
|
const prismaMock = vi.mocked(prisma)
|
|
const actividadMock = vi.mocked(actividadLocalService)
|
|
const saludMock = vi.mocked(saludMonday)
|
|
|
|
function leadLocal(id: string, overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id,
|
|
leadId: `loc_${id}`,
|
|
nombreLead: `Lead ${id}`,
|
|
board: 'LEADS',
|
|
etapa: 'NUEVO',
|
|
usuarioId: 'user-1',
|
|
telefono: '6641112233',
|
|
email: '',
|
|
origen: '',
|
|
desarrollo: null,
|
|
modelo: null,
|
|
motivo: null,
|
|
submotivo: null,
|
|
datosJson: null,
|
|
mondayItemId: null,
|
|
fechaLimite: null,
|
|
pendienteSync: false,
|
|
createdAt: new Date('2026-08-01T09:00:00Z'),
|
|
updatedAt: new Date('2026-08-01T09:00:00Z'),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function usuarioLocal(id: string, nombre: string, overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id,
|
|
username: nombre.toLowerCase(),
|
|
nombre,
|
|
passwordHash: '',
|
|
rol: 'vendedor',
|
|
asignacion: null,
|
|
empleadoId: null,
|
|
mondayUserId: null,
|
|
activo: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
|
const mapa = new Map<string, string>()
|
|
return {
|
|
mapa,
|
|
async get(clave) { return mapa.get(clave) ?? null },
|
|
async set(clave, valor) { mapa.set(clave, valor) },
|
|
}
|
|
}
|
|
|
|
function crearServicio(state: StateStore) {
|
|
// Mock Monday service stub (unused in local path but needed by constructor)
|
|
const mondayStub = {
|
|
getLeads: async () => [],
|
|
getOportunidades: async () => [],
|
|
asignarVendedorALead: async () => {},
|
|
asignarAsesorAOportunidad: async () => {},
|
|
crearUpdateEnItem: async () => '',
|
|
crearActividad: async () => '',
|
|
crearApartado: async () => '',
|
|
crearVenta: async () => '',
|
|
getUsuariosEquipo: async () => [],
|
|
}
|
|
return new AutomatizacionesService(mondayStub, state)
|
|
}
|
|
|
|
// ── Tests ──
|
|
|
|
beforeEach(() => {
|
|
saludMock.estaDisponible.mockReturnValue(false)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('AutomatizacionesService.runRoundRobinLocal', () => {
|
|
it('asigna leads locales sin asignar en rotación y crea ActividadLocal', async () => {
|
|
const leads = [
|
|
leadLocal('a1', { nombreLead: 'María López' }),
|
|
leadLocal('a2', { nombreLead: 'Carlos Ruiz' }),
|
|
]
|
|
const usuarios = [
|
|
usuarioLocal('u1', 'Ana Vendedor'),
|
|
usuarioLocal('u2', 'Beto Vendedor'),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never)
|
|
prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never)
|
|
prismaMock.usuario.findMany.mockResolvedValueOnce(usuarios as never)
|
|
prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarios[0] as never)
|
|
prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarios[1] as never)
|
|
prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never)
|
|
actividadMock.crearActividad.mockResolvedValueOnce({} as never)
|
|
prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never)
|
|
prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never)
|
|
actividadMock.crearActividad.mockResolvedValueOnce({} as never)
|
|
prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.runRoundRobinLocal()
|
|
|
|
expect(r.asignados).toBe(2)
|
|
expect(r.detalle[0]?.lead).toBe('María López')
|
|
expect(r.detalle[1]?.lead).toBe('Carlos Ruiz')
|
|
expect(prismaMock.asignacionLocal.create).toHaveBeenCalledTimes(2)
|
|
expect(actividadMock.crearActividad).toHaveBeenCalledTimes(2)
|
|
expect(actividadMock.crearActividad).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
titulo: 'Contactar a María López',
|
|
tipo: 'SEGUIMIENTO',
|
|
}),
|
|
)
|
|
expect(prismaMock.leadCaptura.update).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('no asigna leads que ya tienen AsignacionLocal', async () => {
|
|
const leads = [
|
|
leadLocal('a1'),
|
|
leadLocal('a2'),
|
|
]
|
|
const asignaciones = [{ leadCapturaId: 'a1' }]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never)
|
|
prismaMock.asignacionLocal.findMany.mockResolvedValueOnce(asignaciones as never)
|
|
prismaMock.usuario.findMany.mockResolvedValueOnce([usuarioLocal('u1', 'Ana')] as never)
|
|
prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarioLocal('u1', 'Ana') as never)
|
|
prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never)
|
|
actividadMock.crearActividad.mockResolvedValueOnce({} as never)
|
|
prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.runRoundRobinLocal()
|
|
|
|
expect(r.asignados).toBe(1)
|
|
expect(r.detalle[0]?.lead).toBe('Lead a2')
|
|
})
|
|
|
|
it('devuelve 0 si no hay leads sin asignar', async () => {
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce([] as never)
|
|
prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.runRoundRobinLocal()
|
|
|
|
expect(r.asignados).toBe(0)
|
|
expect(prismaMock.asignacionLocal.create).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rota el índice entre pasadas (dedup)', async () => {
|
|
const leads = [leadLocal('a1')]
|
|
const usuarios = [usuarioLocal('u1', 'Ana'), usuarioLocal('u2', 'Beto')]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never)
|
|
prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never)
|
|
prismaMock.usuario.findMany.mockResolvedValueOnce(usuarios as never)
|
|
prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarios[1] as never)
|
|
prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never)
|
|
actividadMock.crearActividad.mockResolvedValueOnce({} as never)
|
|
prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never)
|
|
|
|
const state = stateEnMemoria()
|
|
state.mapa.set('rr:indice', '1')
|
|
const svc = crearServicio(state)
|
|
const r = await svc.runRoundRobinLocal()
|
|
|
|
expect(r.asignados).toBe(1)
|
|
expect(r.detalle[0]?.vendedor).toBe('Beto')
|
|
expect(state.mapa.get('rr:indice')).toBe('0') // 2 % 2 vendedores = 0
|
|
})
|
|
})
|
|
|
|
describe('AutomatizacionesService.escaneoInactividadLocal', () => {
|
|
const ahora = new Date('2026-08-22T09:00:00Z')
|
|
|
|
it('detecta oportunidades inactivas y crea ActividadLocal RECORDATORIO', async () => {
|
|
const oportunidades = [
|
|
leadLocal('o1', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
updatedAt: new Date('2026-08-01T00:00:00Z'),
|
|
}),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never)
|
|
prismaMock.actividadLocal.findFirst.mockResolvedValueOnce(null)
|
|
actividadMock.crearActividad.mockResolvedValueOnce({} as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoInactividadLocal({ preview: false, ahora })
|
|
|
|
expect(r.preview).toBe(false)
|
|
expect(r.candidatos).toHaveLength(1)
|
|
expect(r.candidatos[0]?.diasInactivo).toBe(21)
|
|
expect(r.recordatoriosEnviados).toBe(1)
|
|
expect(actividadMock.crearActividad).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
titulo: 'Seguimiento inactivo: Lead o1',
|
|
tipo: 'RECORDATORIO',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('no duplica si ya tiene actividad abierta (yaRecordadoHoy=true)', async () => {
|
|
const oportunidades = [
|
|
leadLocal('o1', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
updatedAt: new Date('2026-08-01T00:00:00Z'),
|
|
}),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never)
|
|
prismaMock.actividadLocal.findFirst.mockResolvedValueOnce({ id: 'act-1' } as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoInactividadLocal({ preview: false, ahora })
|
|
|
|
expect(r.candidatos[0]?.yaRecordadoHoy).toBe(true)
|
|
expect(r.recordatoriosEnviados).toBe(0)
|
|
expect(actividadMock.crearActividad).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preview no crea actividades', async () => {
|
|
const oportunidades = [
|
|
leadLocal('o1', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
updatedAt: new Date('2026-08-01T00:00:00Z'),
|
|
}),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never)
|
|
prismaMock.actividadLocal.findFirst.mockResolvedValueOnce(null)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoInactividadLocal({ preview: true, ahora })
|
|
|
|
expect(r.preview).toBe(true)
|
|
expect(r.recordatoriosEnviados).toBe(0)
|
|
expect(actividadMock.crearActividad).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('ignora etapas terminales (Prisma las filtra con notIn)', async () => {
|
|
// Prisma's where clause filters terminal stages; mock returns empty (simulating that)
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce([] as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoInactividadLocal({ preview: false, ahora })
|
|
|
|
expect(r.candidatos).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('AutomatizacionesService.escaneoEscaladoLocal', () => {
|
|
const ahora = new Date('2026-08-22T12:00:00Z')
|
|
|
|
it('detecta oportunidades sin contacto >48h y crea ActividadLocal URGENTE', async () => {
|
|
const oportunidades = [
|
|
leadLocal('o1', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
updatedAt: new Date('2026-08-19T12:00:00Z'), // 72h
|
|
}),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never)
|
|
prismaMock.actividadLocal.findFirst.mockResolvedValueOnce(null) // no tiene URGENTE
|
|
actividadMock.crearActividad.mockResolvedValueOnce({} as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoEscaladoLocal({ ahora })
|
|
|
|
expect(r.escalados).toBe(1)
|
|
expect(r.candidatos[0]?.horasSinContactar).toBe(72)
|
|
expect(r.candidatos[0]?.yaEscalado).toBe(false)
|
|
expect(actividadMock.crearActividad).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
titulo: '🔴 URGENTE: Lead o1 sin contacto >48h',
|
|
tipo: 'SEGUIMIENTO',
|
|
}),
|
|
)
|
|
expect(state.mapa.get('escalado:loc_o1')).toBeDefined()
|
|
})
|
|
|
|
it('no duplica si ya tiene actividad URGENTE', async () => {
|
|
const oportunidades = [
|
|
leadLocal('o1', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
updatedAt: new Date('2026-08-19T12:00:00Z'),
|
|
}),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never)
|
|
prismaMock.actividadLocal.findFirst.mockResolvedValueOnce({ id: 'act-urg-1' } as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoEscaladoLocal({ ahora })
|
|
|
|
expect(r.escalados).toBe(0)
|
|
expect(r.candidatos[0]?.yaEscalado).toBe(true)
|
|
expect(actividadMock.crearActividad).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('ignora oportunidades con <48h sin contacto', async () => {
|
|
const oportunidades = [
|
|
leadLocal('o1', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
updatedAt: new Date('2026-08-20T12:00:00Z'), // 48h justo → no supera
|
|
}),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never)
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoEscaladoLocal({ ahora })
|
|
|
|
expect(r.candidatos).toHaveLength(0)
|
|
expect(r.escalados).toBe(0)
|
|
})
|
|
|
|
it('un fallo al crear actividad no rompe el escaneo de las demás', async () => {
|
|
const oportunidades = [
|
|
leadLocal('o1', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
updatedAt: new Date('2026-08-19T12:00:00Z'),
|
|
}),
|
|
leadLocal('o2', {
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'INTENTO DE CONTACTO',
|
|
updatedAt: new Date('2026-08-18T12:00:00Z'),
|
|
}),
|
|
]
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(oportunidades as never)
|
|
prismaMock.actividadLocal.findFirst
|
|
.mockResolvedValueOnce(null) // o1 no tiene URGENTE
|
|
.mockResolvedValueOnce(null) // o2 no tiene URGENTE
|
|
actividadMock.crearActividad
|
|
.mockRejectedValueOnce(new Error('DB error'))
|
|
.mockResolvedValueOnce({} as never)
|
|
|
|
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(state)
|
|
const r = await svc.escaneoEscaladoLocal({ ahora })
|
|
|
|
expect(r.escalados).toBe(1) // o2 se escaló
|
|
expect(r.candidatos).toHaveLength(2)
|
|
spyError.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('runRoundRobin delega a runRoundRobinLocal cuando Monday no está disponible', () => {
|
|
it('si saludMonday.estaDisponible() es false, llama a runRoundRobinLocal', async () => {
|
|
saludMock.estaDisponible.mockReturnValue(false)
|
|
|
|
const leads = [leadLocal('a1')]
|
|
prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never)
|
|
prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never)
|
|
prismaMock.usuario.findMany.mockResolvedValueOnce([usuarioLocal('u1', 'Ana')] as never)
|
|
prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarioLocal('u1', 'Ana') as never)
|
|
prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never)
|
|
actividadMock.crearActividad.mockResolvedValueOnce({} as never)
|
|
prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never)
|
|
|
|
const mondayStub = {
|
|
getLeads: vi.fn(),
|
|
getOportunidades: vi.fn(),
|
|
getUsuariosEquipo: vi.fn(),
|
|
asignarVendedorALead: vi.fn(),
|
|
asignarAsesorAOportunidad: vi.fn(),
|
|
crearUpdateEnItem: vi.fn(),
|
|
crearActividad: vi.fn(),
|
|
crearApartado: vi.fn(),
|
|
crearVenta: vi.fn(),
|
|
}
|
|
|
|
const state = stateEnMemoria()
|
|
const svc = new AutomatizacionesService(mondayStub, state)
|
|
const r = await svc.runRoundRobin()
|
|
|
|
// Monday methods should NOT be called (local path used)
|
|
expect(mondayStub.getLeads).not.toHaveBeenCalled()
|
|
expect(r.asignados).toBe(1)
|
|
})
|
|
})
|