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:
233
api-gateway/src/services/actividad-local.service.test.ts
Normal file
233
api-gateway/src/services/actividad-local.service.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { ActividadLocalService } from './actividad-local.service.js'
|
||||
|
||||
// Mock de Prisma: solo operaciones de CRUD sobre ActividadLocal
|
||||
const mockActividades: Record<string, {
|
||||
id: string; titulo: string; descripcion: string | null; usuarioId: string
|
||||
leadCapturaId: string | null; fechaVencimiento: Date | null; fechaCierre: Date | null
|
||||
estado: string; tipo: string; createdAt: Date; updatedAt: Date
|
||||
}> = {}
|
||||
let consecutivo = 0
|
||||
|
||||
vi.mock('./prisma.js', () => ({
|
||||
prisma: {
|
||||
actividadLocal: {
|
||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
consecutivo++
|
||||
const id = `act_${consecutivo}`
|
||||
const fila = {
|
||||
id,
|
||||
titulo: data.titulo as string,
|
||||
descripcion: (data.descripcion as string) ?? null,
|
||||
usuarioId: data.usuarioId as string,
|
||||
leadCapturaId: (data.leadCapturaId as string) ?? null,
|
||||
fechaVencimiento: (data.fechaVencimiento as Date) ?? null,
|
||||
fechaCierre: null,
|
||||
estado: (data.estado as string) ?? 'ABIERTA',
|
||||
tipo: (data.tipo as string) ?? 'SEGUIMIENTO',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
mockActividades[id] = fila
|
||||
return fila
|
||||
}),
|
||||
findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
||||
let filas = Object.values(mockActividades)
|
||||
if (where.usuarioId !== undefined) {
|
||||
filas = filas.filter((f) => f.usuarioId === where.usuarioId)
|
||||
}
|
||||
if (where.estado !== undefined) {
|
||||
filas = filas.filter((f) => f.estado === where.estado)
|
||||
}
|
||||
if (where.fechaVencimiento !== undefined && typeof where.fechaVencimiento === 'object') {
|
||||
const fv = where.fechaVencimiento as Record<string, Date>
|
||||
if (fv.gte !== undefined) filas = filas.filter((f) => f.fechaVencimiento !== null && f.fechaVencimiento >= fv.gte)
|
||||
if (fv.lt !== undefined) filas = filas.filter((f) => f.fechaVencimiento !== null && f.fechaVencimiento < fv.lt)
|
||||
}
|
||||
return filas
|
||||
}),
|
||||
count: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
||||
let filas = Object.values(mockActividades)
|
||||
if (where.usuarioId !== undefined) {
|
||||
filas = filas.filter((f) => f.usuarioId === where.usuarioId)
|
||||
}
|
||||
if (where.estado !== undefined) {
|
||||
filas = filas.filter((f) => f.estado === where.estado)
|
||||
}
|
||||
if (where.fechaVencimiento !== undefined && typeof where.fechaVencimiento === 'object') {
|
||||
const fv = where.fechaVencimiento as Record<string, Date>
|
||||
if (fv.gte !== undefined && fv.lt !== undefined) {
|
||||
filas = filas.filter((f) => f.fechaVencimiento !== null && f.fechaVencimiento >= fv.gte && f.fechaVencimiento < fv.lt)
|
||||
}
|
||||
}
|
||||
return filas.length
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => mockActividades[where.id] ?? null),
|
||||
update: vi.fn(async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||
const fila = mockActividades[where.id]
|
||||
if (!fila) throw new Error('Not found')
|
||||
Object.assign(fila, data, { updatedAt: new Date() })
|
||||
return fila
|
||||
}),
|
||||
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||
const fila = mockActividades[where.id]
|
||||
if (!fila) throw new Error('Not found')
|
||||
delete mockActividades[where.id]
|
||||
return fila
|
||||
}),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(mockActividades).forEach((k) => delete mockActividades[k])
|
||||
consecutivo = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ActividadLocalService', () => {
|
||||
const svc = new ActividadLocalService()
|
||||
|
||||
it('crearActividad crea una actividad con valores por defecto', async () => {
|
||||
const act = await svc.crearActividad({ titulo: 'Llamar a Juan', usuarioId: 'u1' })
|
||||
expect(act.id).toBe('act_1')
|
||||
expect(act.titulo).toBe('Llamar a Juan')
|
||||
expect(act.estado).toBe('ABIERTA')
|
||||
expect(act.tipo).toBe('SEGUIMIENTO')
|
||||
expect(act.descripcion).toBeNull()
|
||||
expect(act.leadCapturaId).toBeNull()
|
||||
})
|
||||
|
||||
it('crearActividad con campos opcionales', async () => {
|
||||
const fv = new Date('2026-09-10T10:00:00')
|
||||
const act = await svc.crearActividad({
|
||||
titulo: 'Reunión',
|
||||
descripcion: 'Seguimiento del lead',
|
||||
usuarioId: 'u2',
|
||||
leadCapturaId: 'loc_123',
|
||||
fechaVencimiento: fv,
|
||||
tipo: 'REUNION',
|
||||
})
|
||||
expect(act.descripcion).toBe('Seguimiento del lead')
|
||||
expect(act.leadCapturaId).toBe('loc_123')
|
||||
expect(act.fechaVencimiento).toBe(fv)
|
||||
expect(act.tipo).toBe('REUNION')
|
||||
})
|
||||
|
||||
it('listarActividades filtra por usuario', async () => {
|
||||
await svc.crearActividad({ titulo: 'A1', usuarioId: 'u1' })
|
||||
await svc.crearActividad({ titulo: 'A2', usuarioId: 'u2' })
|
||||
await svc.crearActividad({ titulo: 'A3', usuarioId: 'u1' })
|
||||
|
||||
const lista = await svc.listarActividades('u1')
|
||||
expect(lista).toHaveLength(2)
|
||||
expect(lista.every((a) => a.usuarioId === 'u1')).toBe(true)
|
||||
})
|
||||
|
||||
it('listarActividades con soloAbiertas', async () => {
|
||||
await svc.crearActividad({ titulo: 'Abierta', usuarioId: 'u1' })
|
||||
const cerrada = await svc.crearActividad({ titulo: 'Cerrada', usuarioId: 'u1' })
|
||||
await svc.cerrarActividad(cerrada.id)
|
||||
|
||||
const abiertas = await svc.listarActividades('u1', { soloAbiertas: true })
|
||||
expect(abiertas).toHaveLength(1)
|
||||
expect(abiertas[0].titulo).toBe('Abierta')
|
||||
})
|
||||
|
||||
it('cerrarActividad marca estado CERRADA y fechaCierre', async () => {
|
||||
const act = await svc.crearActividad({ titulo: 'Test', usuarioId: 'u1' })
|
||||
const cerrada = await svc.cerrarActividad(act.id)
|
||||
expect(cerrada.estado).toBe('CERRADA')
|
||||
expect(cerrada.fechaCierre).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('cerrarActividad lanza error si no existe', async () => {
|
||||
await expect(svc.cerrarActividad('no_existe')).rejects.toThrow('no encontrada')
|
||||
})
|
||||
|
||||
it('eliminarActividad borra la actividad', async () => {
|
||||
const act = await svc.crearActividad({ titulo: 'Borrar', usuarioId: 'u1' })
|
||||
await svc.eliminarActividad(act.id)
|
||||
const todas = await svc.listarActividades('u1')
|
||||
expect(todas).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('eliminarActividad lanza error si no existe', async () => {
|
||||
await expect(svc.eliminarActividad('no_existe')).rejects.toThrow('no encontrada')
|
||||
})
|
||||
|
||||
it('contarActividadesAbiertas sin filtro de usuario', async () => {
|
||||
await svc.crearActividad({ titulo: 'A1', usuarioId: 'u1' })
|
||||
await svc.crearActividad({ titulo: 'A2', usuarioId: 'u2' })
|
||||
expect(await svc.contarActividadesAbiertas()).toBe(2)
|
||||
})
|
||||
|
||||
it('contarActividadesAbiertas con filtro de usuario', async () => {
|
||||
await svc.crearActividad({ titulo: 'A1', usuarioId: 'u1' })
|
||||
await svc.crearActividad({ titulo: 'A2', usuarioId: 'u2' })
|
||||
expect(await svc.contarActividadesAbiertas('u1')).toBe(1)
|
||||
})
|
||||
|
||||
it('contarActividadesHoy cuenta solo las de hoy', async () => {
|
||||
const hoy = new Date('2026-09-07T10:00:00')
|
||||
const ayer = new Date('2026-09-06T10:00:00')
|
||||
await svc.crearActividad({ titulo: 'Hoy', usuarioId: 'u1', fechaVencimiento: hoy })
|
||||
await svc.crearActividad({ titulo: 'Ayer', usuarioId: 'u1', fechaVencimiento: ayer })
|
||||
|
||||
// contarActividadesHoy usa new Date() internamente, mockeamos el Date global
|
||||
const DateOrig = Date
|
||||
vi.stubGlobal('Date', class extends DateOrig {
|
||||
constructor(...args: unknown[]) {
|
||||
if (args.length === 0) super('2026-09-07T12:00:00')
|
||||
else super(...args)
|
||||
}
|
||||
})
|
||||
|
||||
const count = await svc.contarActividadesHoy('u1')
|
||||
expect(count).toBe(1)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('actividadesVencidas retorna solo las que vencieron y están abiertas', async () => {
|
||||
const pasada = new Date('2026-09-06T10:00:00')
|
||||
const futura = new Date('2026-09-10T10:00:00')
|
||||
await svc.crearActividad({ titulo: 'Pasada', usuarioId: 'u1', fechaVencimiento: pasada })
|
||||
await svc.crearActividad({ titulo: 'Futura', usuarioId: 'u1', fechaVencimiento: futura })
|
||||
|
||||
// actividadesVencidas compara con new Date() → 2026-09-07T12:00:00
|
||||
const DateOrig = Date
|
||||
vi.stubGlobal('Date', class extends DateOrig {
|
||||
constructor(...args: unknown[]) {
|
||||
if (args.length === 0) super('2026-09-07T12:00:00')
|
||||
else super(...args)
|
||||
}
|
||||
})
|
||||
|
||||
const vencidas = await svc.actividadesVencidas('u1')
|
||||
expect(vencidas).toHaveLength(1)
|
||||
expect(vencidas[0].titulo).toBe('Pasada')
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('actividadesVencidas con filtro de usuario', async () => {
|
||||
const pasada = new Date('2026-09-06T10:00:00')
|
||||
await svc.crearActividad({ titulo: 'U1', usuarioId: 'u1', fechaVencimiento: pasada })
|
||||
await svc.crearActividad({ titulo: 'U2', usuarioId: 'u2', fechaVencimiento: pasada })
|
||||
|
||||
const DateOrig = Date
|
||||
vi.stubGlobal('Date', class extends DateOrig {
|
||||
constructor(...args: unknown[]) {
|
||||
if (args.length === 0) super('2026-09-07T12:00:00')
|
||||
else super(...args)
|
||||
}
|
||||
})
|
||||
|
||||
const vencidas = await svc.actividadesVencidas('u1')
|
||||
expect(vencidas).toHaveLength(1)
|
||||
expect(vencidas[0].titulo).toBe('U1')
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user