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,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()
})
})