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:
2026-09-07 04:00:15 +00:00
parent aadbc6f83f
commit 388e1da338
16 changed files with 1604 additions and 37 deletions

View 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)
})
})