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

@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
import {
resumenMiDiaLocal,
unirPendientesLocales,
type ActividadLocalMiDia,
type ClienteLocalMiDia,
type FilaLocalMiDia,
type ResumenMiDia,
type UsuarioLocal,
type UsuarioMiDia,
} from './mi-dia.service.js'
@@ -13,9 +15,10 @@ import {
* (Monday vivo + capturas locales pendientes de sincronizar).
*/
const AHORA = new Date('2026-09-05T12:00:00')
const AHORA = new Date('2026-09-07T12:00:00')
const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor', mondayUserId: 123 }
const coordinador: UsuarioMiDia = { nombre: 'Coord General', rol: 'coordinador', mondayUserId: 456 }
const sinNombre: UsuarioMiDia = { nombre: ' ', rol: 'vendedor', mondayUserId: null }
let consecutivo = 0
@@ -30,6 +33,9 @@ function fila(parcial: Partial<FilaLocalMiDia> & { leadId: string }): FilaLocalM
modelo: null,
board: 'LEADS',
etapa: 'NUEVO',
fechaLimite: null,
datosJson: null,
usuarioId: 'u1',
mondayItemId: null,
pendienteSync: true,
createdAt: new Date(AHORA.getTime() - consecutivo * 3_600_000),
@@ -115,6 +121,90 @@ describe('resumenMiDiaLocal (Monday caído)', () => {
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2)
})
it('misActividadesHoy muestra actividades abiertas con fecha <= mañana', () => {
const actividades: ActividadLocalMiDia[] = [
{ id: 'a1', titulo: 'Llamar a Juan', descripcion: null, usuarioId: 'u1', leadCapturaId: null, fechaVencimiento: new Date('2026-09-07T10:00:00'), fechaCierre: null, estado: 'ABIERTA', tipo: 'SEGUIMIENTO' },
{ id: 'a2', titulo: 'Reunión passada', descripcion: null, usuarioId: 'u1', leadCapturaId: null, fechaVencimiento: new Date('2026-09-08T14:00:00'), fechaCierre: null, estado: 'ABIERTA', tipo: 'REUNION' },
{ id: 'a3', titulo: 'Cerrada', descripcion: null, usuarioId: 'u1', leadCapturaId: null, fechaVencimiento: new Date('2026-09-07T09:00:00'), fechaCierre: new Date(), estado: 'CERRADA', tipo: 'SEGUIMIENTO' },
{ id: 'a4', titulo: 'Sin fecha', descripcion: null, usuarioId: 'u1', leadCapturaId: null, fechaVencimiento: null, fechaCierre: null, estado: 'ABIERTA', tipo: 'SEGUIMIENTO' },
{ id: 'a5', titulo: 'Lejana', descripcion: null, usuarioId: 'u1', leadCapturaId: null, fechaVencimiento: new Date('2026-09-15T10:00:00'), fechaCierre: null, estado: 'ABIERTA', tipo: 'SEGUIMIENTO' },
]
const r = resumenMiDiaLocal(vendedor, [], [], AHORA, actividades)
// Solo a1 (hoy), a2 (mañana), a4 (sin fecha) — no a3 (cerrada), no a5 (lejana)
expect(r.misActividadesHoy).toHaveLength(3)
// Sort: fechaAsc, sin fecha al final
expect(r.misActividadesHoy.map((a) => a.id)).toEqual(['a1', 'a2', 'a4'])
expect(r.misActividadesHoy[0]).toMatchObject({ nombre: 'Llamar a Juan', tipo: 'SEGUIMIENTO' })
expect(r.conteos.actividadesAbiertas).toBe(4) // todas las ABIERTA (a1,a2,a4,a5)
})
it('apartadosPorVencer usa fechaLimite de la captura', () => {
const capturas = [
fila({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado', fechaLimite: new Date('2026-09-08') }), // +1 día
fila({ leadId: 'loc_a2', board: 'APARTADO', etapa: 'Documentos', fechaLimite: new Date('2026-09-14') }), // +7 días
fila({ leadId: 'loc_a3', board: 'APARTADO', etapa: 'Escrituracion', fechaLimite: new Date('2026-09-20') }), // +13 días: fuera
fila({ leadId: 'loc_a4', board: 'APARTADO', etapa: 'Cancelado', fechaLimite: new Date('2026-09-08') }), // final: fuera
fila({ leadId: 'loc_a5', board: 'APARTADO', etapa: 'Pago Apartado' }), // sin fecha: fuera
]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.apartadosPorVencer).toHaveLength(2)
expect(r.apartadosPorVencer.map((a) => a.id)).toEqual(['loc_a1', 'loc_a2'])
expect(r.apartadosPorVencer[0]).toMatchObject({ diasRestantes: 1 })
expect(r.apartadosPorVencer[1]).toMatchObject({ diasRestantes: 7 })
})
it('unidadCodigo se extrae de datosJson de la oportunidad', () => {
const capturas = [
fila({
leadId: 'loc_o1',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify({ nombre: 'Test', unidadCodigo: 'SENDA-101' }),
}),
fila({
leadId: 'loc_o2',
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
datosJson: JSON.stringify({ nombre: 'Test2', desarrollo: 'DIAGONAL' }),
}),
]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.misOportunidades.items.find((o) => o.id === 'loc_o1')?.unidadCodigo).toBe('SENDA-101')
expect(r.misOportunidades.items.find((o) => o.id === 'loc_o2')?.unidadCodigo).toBe('DIAGONAL')
})
it('equipo se genera para coordinador con vendedores', () => {
const usuarios: UsuarioLocal[] = [
{ id: 'u1', nombre: 'Martin Zavala', rol: 'vendedor', activo: true },
{ id: 'u2', nombre: 'Ana Perez', rol: 'vendedor', activo: true },
{ id: 'u3', nombre: 'Inactivo', rol: 'vendedor', activo: false },
{ id: 'u4', nombre: 'Coord', rol: 'coordinador', activo: true },
]
const capturas = [
fila({ leadId: 'loc_l1', board: 'LEADS', usuarioId: 'u1' }),
fila({ leadId: 'loc_l2', board: 'LEADS', usuarioId: 'u1', etapa: 'Descartado' }), // terminal
fila({ leadId: 'loc_l3', board: 'LEADS', usuarioId: 'u2', etapa: 'CONTACTADO', createdAt: new Date('2026-09-06T00:00:00') }),
fila({ leadId: 'loc_o1', board: 'OPORTUNIDADES', usuarioId: 'u2' }),
]
const r = resumenMiDiaLocal(coordinador, capturas, [], AHORA, [], usuarios)
expect(r.equipo).toBeDefined()
expect(r.equipo!.leadsNuevosSinAsignar).toBe(0) // todos asignados
const porVendedor = r.equipo!.porVendedor
expect(porVendedor).toHaveLength(2)
// Martin: 1 lead activo (loc_l1), 2 leads nuevos semana (loc_l1 y loc_l2 ambos en semana)
expect(porVendedor.find((v) => v.nombre === 'Martin Zavala')).toMatchObject({ mondayUserId: 0, leadsAsignados: 1, leadsNuevosSemana: 2 })
// Ana: 1 lead activo (loc_l3, 1 día atrás = semana), 1 oportunidad no cuenta en leads
expect(porVendedor.find((v) => v.nombre === 'Ana Perez')).toMatchObject({ mondayUserId: 0, leadsAsignados: 1, leadsNuevosSemana: 1 })
})
it('equipo no se genera para vendedor', () => {
const usuarios: UsuarioLocal[] = [
{ id: 'u1', nombre: 'Martin', rol: 'vendedor', activo: true },
]
const r = resumenMiDiaLocal(vendedor, [], [], AHORA, [], usuarios)
expect(r.equipo).toBeUndefined()
})
})
// ---------- Unión (Monday vivo + pendientes locales) ----------