feat(modo-local): CRM 100% local sin dependencia de Monday.com
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.
This commit is contained in:
449
api-gateway/src/services/automatizaciones-local.test.ts
Normal file
449
api-gateway/src/services/automatizaciones-local.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user