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

@@ -18,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
cliente: {
findMany: vi.fn(),
},
actividadLocal: {
findMany: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
@@ -57,6 +60,7 @@ function capturaFake(parcial: Record<string, unknown> & { leadId: string }) {
motivo: null,
submotivo: null,
datosJson: null,
fechaLimite: null,
mondayItemId: null,
pendienteSync: false,
usuarioId: 'u-1',
@@ -83,6 +87,7 @@ beforeEach(() => {
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.actividadLocal.findMany.mockResolvedValue([])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
mondayMock.getLeads.mockResolvedValue([])
mondayMock.getOportunidades.mockResolvedValue([])

View File

@@ -1,12 +1,14 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { mondayService } from '../services/monday.service.js'
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales } from '../services/mi-dia.service.js'
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js'
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
/**
* GET /api/mi-dia/resumen — agenda del vendedor logueado.
* El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday);
@@ -68,7 +70,34 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local
if (!saludMonday.estaDisponible()) {
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes))
// Actividades locales del usuario
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id,
titulo: a.titulo,
descripcion: a.descripcion,
usuarioId: a.usuarioId,
leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento,
fechaCierre: a.fechaCierre,
estado: a.estado,
tipo: a.tipo,
}))
// Usuarios activos (para la sección equipo, solo coordinador/admin)
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
return
}
@@ -88,6 +117,24 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
} catch (err) {
// Monday se cayó a media lectura: mismo resumen, pero todo local
if (!esFalloMonday(err)) throw err
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes))
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id, titulo: a.titulo, descripcion: a.descripcion,
usuarioId: a.usuarioId, leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento, fechaCierre: a.fechaCierre,
estado: a.estado, tipo: a.tipo,
}))
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
}
}

View File

@@ -53,10 +53,26 @@ if (config.automatizaciones.enabled) {
// escaneos en paralelo provoca timeouts P1008 en AutomatizacionState.
cron.schedule(config.automatizaciones.pollCron, () => {
void (async () => {
// Modo local: Monday caído → saltar las reglas sin ruido (fallan seguro de todos modos)
// Modo local: Monday caído → ejecutar versiones locales de las automatizaciones
if (!saludMonday.estaDisponible()) {
// eslint-disable-next-line no-console
console.debug('[cron poll] Monday no disponible (modo local); pasada omitida')
try {
const r = await automatizacionesService.runRoundRobinLocal()
if (r.asignados > 0) {
// eslint-disable-next-line no-console
console.log(`[cron poll-local] round-robin local: ${r.asignados} lead(s) asignado(s)`)
}
} catch (err: unknown) {
console.error('[cron poll-local] error en round-robin local:', err)
}
try {
const r = await automatizacionesService.escaneoEscaladoLocal()
if (r.escalados > 0) {
// eslint-disable-next-line no-console
console.log(`[cron poll-local] escalado local: ${r.escalados} lead(s) escalado(s)`)
}
} catch (err: unknown) {
console.error('[cron poll-local] error en escalado local:', err)
}
return
}
try {
@@ -97,8 +113,14 @@ if (config.automatizaciones.enabled) {
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
cron.schedule(config.automatizaciones.recordatorioCron, () => {
if (!saludMonday.estaDisponible()) {
// eslint-disable-next-line no-console
console.debug('[cron inactividad] Monday no disponible (modo local); escaneo omitido')
// Modo local: escaneo de inactividad sobre tablas locales
automatizacionesService
.escaneoInactividadLocal({ preview: false })
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[cron inactividad-local] ${r.recordatoriosEnviados} recordatorio(s) local(es)`)
})
.catch((err: unknown) => console.error('[cron inactividad-local] error en escaneo local:', err))
return
}
automatizacionesService

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

View File

@@ -0,0 +1,133 @@
import { NotFoundError } from '../utils/errors.js'
import { prisma } from './prisma.js'
/**
* CRUD de actividades locales (tabla ActividadLocal).
* Reemplaza las actividades de Monday en modo local: historial de llamadas,
* reuniones, tareas pendientes. Mi día las muestra en "Mis actividades hoy".
*/
export interface DatosCrearActividad {
titulo: string
descripcion?: string | null
usuarioId: string
leadCapturaId?: string | null
fechaVencimiento?: Date | null
tipo?: string
}
export interface OpcionesListarActividades {
soloAbiertas?: boolean
fechaDesde?: Date | null
fechaHasta?: Date | null
}
export class ActividadLocalService {
/**
* Crea una actividad local (llamada, reunión, tarea de seguimiento, etc.)
*/
async crearActividad(datos: DatosCrearActividad) {
return prisma.actividadLocal.create({
data: {
titulo: datos.titulo,
descripcion: datos.descripcion ?? null,
usuarioId: datos.usuarioId,
leadCapturaId: datos.leadCapturaId ?? null,
fechaVencimiento: datos.fechaVencimiento ?? null,
tipo: datos.tipo ?? 'SEGUIMIENTO',
},
})
}
/**
* Lista actividades de un usuario con filtros opcionales
*/
async listarActividades(usuarioId: string, opciones: OpcionesListarActividades = {}) {
const where: Record<string, unknown> = { usuarioId }
if (opciones.soloAbiertas === true) {
where['estado'] = 'ABIERTA'
}
if (opciones.fechaDesde !== null && opciones.fechaDesde !== undefined) {
where['fechaVencimiento'] = where['fechaVencimiento'] ?? {}
;(where['fechaVencimiento'] as Record<string, Date>)['gte'] = opciones.fechaDesde
}
if (opciones.fechaHasta !== null && opciones.fechaHasta !== undefined) {
where['fechaVencimiento'] = where['fechaVencimiento'] ?? {}
;(where['fechaVencimiento'] as Record<string, Date>)['lte'] = opciones.fechaHasta
}
return prisma.actividadLocal.findMany({
where,
orderBy: { createdAt: 'desc' },
})
}
/**
* Marca una actividad como cerrada (estado = 'CERRADA', fechaCierre = ahora)
*/
async cerrarActividad(id: string) {
const existente = await prisma.actividadLocal.findUnique({ where: { id } })
if (!existente) throw new NotFoundError(`Actividad ${id} no encontrada`)
return prisma.actividadLocal.update({
where: { id },
data: { estado: 'CERRADA', fechaCierre: new Date() },
})
}
/**
* Elimina una actividad local
*/
async eliminarActividad(id: string) {
const existente = await prisma.actividadLocal.findUnique({ where: { id } })
if (!existente) throw new NotFoundError(`Actividad ${id} no encontrada`)
return prisma.actividadLocal.delete({ where: { id } })
}
/**
* Cuenta actividades abiertas, opcionalmente filtrado por usuario
*/
async contarActividadesAbiertas(usuarioId?: string): Promise<number> {
const where: Record<string, unknown> = { estado: 'ABIERTA' }
if (usuarioId !== undefined) where['usuarioId'] = usuarioId
return prisma.actividadLocal.count({ where })
}
/**
* Cuenta actividades del día (fechaVencimiento = hoy) para un usuario
*/
async contarActividadesHoy(usuarioId: string): Promise<number> {
const hoy = new Date()
const inicio = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
const fin = new Date(inicio.getTime() + 24 * 60 * 60 * 1000)
return prisma.actividadLocal.count({
where: {
usuarioId,
estado: 'ABIERTA',
fechaVencimiento: { gte: inicio, lt: fin },
},
})
}
/**
* Lista actividades vencidas (fechaVencimiento < ahora y estado ABIERTA)
*/
async actividadesVencidas(usuarioId?: string) {
const where: Record<string, unknown> = {
estado: 'ABIERTA',
fechaVencimiento: { lt: new Date() },
}
if (usuarioId !== undefined) where['usuarioId'] = usuarioId
return prisma.actividadLocal.findMany({
where,
orderBy: { fechaVencimiento: 'asc' },
})
}
}
/** Singleton usado por controllers (los tests instancian el suyo o mockean prisma) */
export const actividadLocalService = new ActividadLocalService()

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

View File

@@ -1,5 +1,6 @@
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js'
import { actividadLocalService } from './actividad-local.service.js'
import {
mondayService,
type LeadMonday,
@@ -8,6 +9,7 @@ import {
type UsuarioMonday,
} from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
/**
* Automatizaciones sobre Monday:
@@ -322,6 +324,11 @@ export class AutomatizacionesService {
* pasada a un tablero (rutas de los webhooks create_item).
*/
async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise<ResultadoRoundRobin> {
// Modo local: Monday caído → round-robin sobre tablas locales
if (!saludMonday.estaDisponible() && soloItemId === undefined) {
return this.runRoundRobinLocal()
}
interface Candidato {
id: string
nombre: string
@@ -413,6 +420,11 @@ export class AutomatizacionesService {
* el estado (sirve para el endpoint de preview).
*/
async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
// Modo local: Monday caído → escaneo sobre tablas locales
if (!saludMonday.estaDisponible()) {
return this.escaneoInactividadLocal(opciones)
}
const ahora = opciones.ahora ?? new Date()
const diasUmbral = config.automatizaciones.inactividadDias
const oportunidades = await this.monday.getOportunidades()
@@ -477,6 +489,11 @@ export class AutomatizacionesService {
* en una oportunidad no detiene a las demás.
*/
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
// Modo local: Monday caído → escalado sobre tablas locales
if (!saludMonday.estaDisponible()) {
return this.escaneoEscaladoLocal(opciones)
}
const ahora = opciones.ahora ?? new Date()
const horasUmbral = config.automatizaciones.escaladoHoras
const oportunidades = await this.monday.getOportunidades()
@@ -603,6 +620,245 @@ export class AutomatizacionesService {
return null
}
// ---------- Round-robin / inactividad / escalado LOCAL (modo sin Monday) ----------
/**
* Round-robin local: asigna leads locales (board='LEADS', etapa='NUEVO') sin
* registro en AsignacionLocal al siguiente vendedor en rotación.
* Crea ActividadLocal de SEGUIMIENTO y actualiza LeadCaptura.usuarioId.
*/
async runRoundRobinLocal(): Promise<ResultadoRoundRobin> {
// Leads locales sin asignar
const leadsSinAsignar = await prisma.leadCaptura.findMany({
where: {
board: 'LEADS',
etapa: 'NUEVO',
},
})
const leadIdsAsignados = (
await prisma.asignacionLocal.findMany({ select: { leadCapturaId: true } })
).map((a) => a.leadCapturaId)
const candidatos = leadsSinAsignar.filter((l) => !leadIdsAsignados.includes(l.id))
if (candidatos.length === 0) {
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
return { asignados: 0, detalle: [] }
}
// Vendedores locales
let vendedores: UsuarioMonday[]
if (config.automatizaciones.rrUserIds.trim() !== '') {
vendedores = parseRrUserIds(config.automatizaciones.rrUserIds)
} else {
const usuarios = await prisma.usuario.findMany({
where: { rol: 'vendedor', activo: true },
select: { id: true, nombre: true, mondayUserId: true },
})
vendedores = usuarios.map((u) => ({
id: u.mondayUserId ?? 0,
nombre: u.nombre,
email: '',
// Guardamos el id local en el campo id para usarlo en AsignacionLocal
// El id local se infiere del usuarioId en LeadCaptura
}))
// Si los vendedores no tienen mondayUserId, usamos el id local directamente
if (vendedores.every((v) => v.id === 0)) {
vendedores = usuarios.map((u) => ({
id: 0,
nombre: u.nombre,
email: u.id, // Guardamos el id local aquí temporalmente
}))
}
}
if (vendedores.length === 0) return { asignados: 0, detalle: [] }
let indice = Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0')
if (!Number.isInteger(indice) || indice < 0) indice = 0
const detalle: AsignacionRR[] = []
for (const lead of candidatos) {
const vendedorTurno = vendedorEnTurno(vendedores, indice)
if (!vendedorTurno) break
// Determinar el usuarioId local: si vendedorTurno.email tiene un uuid, usarlo;
// si no, buscar por nombre en la tabla Usuario
let usuarioLocalId: string | null = null
if (vendedorTurno.email !== '' && vendedorTurno.id === 0) {
// email fue usado para guardar el id local temporalmente
const usuarioLocal = await prisma.usuario.findFirst({
where: { nombre: vendedorTurno.nombre, rol: 'vendedor', activo: true },
select: { id: true },
})
usuarioLocalId = usuarioLocal?.id ?? null
}
if (!usuarioLocalId && vendedorTurno.id > 0) {
// Tiene mondayUserId; buscar el usuario local por mondayUserId
const usuarioLocal = await prisma.usuario.findFirst({
where: { mondayUserId: vendedorTurno.id },
select: { id: true },
})
usuarioLocalId = usuarioLocal?.id ?? null
}
if (usuarioLocalId) {
await prisma.asignacionLocal.create({
data: {
leadCapturaId: lead.id,
usuarioId: usuarioLocalId,
asignadoPor: 'round-robin',
},
})
await actividadLocalService.crearActividad({
titulo: `Contactar a ${lead.nombreLead}`,
usuarioId: usuarioLocalId,
leadCapturaId: lead.id,
fechaVencimiento: new Date(Date.now() + 48 * 60 * 60 * 1000),
tipo: 'SEGUIMIENTO',
})
await prisma.leadCaptura.update({
where: { id: lead.id },
data: { usuarioId: usuarioLocalId },
})
}
detalle.push({
itemId: lead.leadId,
lead: lead.nombreLead,
vendedorId: vendedorTurno.id,
vendedor: vendedorTurno.nombre,
tablero: 'leads',
})
indice++
}
await this.state.set(CLAVE_RR_INDICE, String(indice % vendedores.length))
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
return { asignados: detalle.length, detalle }
}
/**
* Escaneo de inactividad local: detecta oportunidades (board='OPORTUNIDADES')
* en etapa no terminal que llevan más de `diasUmbral` días sin actualizarse
* y sin actividad ABIERTA asociada. Crea ActividadLocal RECORDATORIO.
*/
async escaneoInactividadLocal(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
const ahora = opciones.ahora ?? new Date()
const hoy = new Date(ahora.getFullYear(), ahora.getMonth(), ahora.getDate())
const diasUmbral = config.automatizaciones.inactividadDias
const limiteMs = diasUmbral * DIA_MS
const oportunidades = await prisma.leadCaptura.findMany({
where: {
board: 'OPORTUNIDADES',
etapa: { notIn: [...ETAPAS_TERMINALES_OPORTUNIDAD] },
},
})
const candidatos: CandidatoInactividad[] = []
let recordatoriosEnviados = 0
for (const op of oportunidades) {
const t = op.updatedAt.getTime()
if (Number.isNaN(t) || ahora.getTime() - t <= limiteMs) continue
// ¿Ya tiene actividad abierta asociada?
const tieneActividadAbierta = await prisma.actividadLocal.findFirst({
where: { leadCapturaId: op.id, estado: 'ABIERTA' },
})
const yaRecordadoHoy = tieneActividadAbierta !== null
const dias = Math.floor((ahora.getTime() - t) / DIA_MS)
candidatos.push({
itemId: op.leadId,
lead: op.nombreLead,
estado: op.etapa,
updatedAt: op.updatedAt.toISOString(),
diasInactivo: dias,
yaRecordadoHoy,
})
if (opciones.preview || yaRecordadoHoy) continue
await actividadLocalService.crearActividad({
titulo: `Seguimiento inactivo: ${op.nombreLead}`,
descripcion: `⚠️ Recordatorio automático: esta oportunidad lleva ${dias} días sin actualizarse (última actividad: ${op.updatedAt.toISOString().slice(0, 10)}). Favor de darle seguimiento o marcarla como Perdido.`,
usuarioId: op.usuarioId,
leadCapturaId: op.id,
fechaVencimiento: hoy,
tipo: 'RECORDATORIO',
})
recordatoriosEnviados++
}
if (!opciones.preview) await this.state.set(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO, ahora.toISOString())
return { preview: opciones.preview, diasUmbral, candidatos, recordatoriosEnviados }
}
/**
* Escalado local: detecta oportunidades asignadas sin contacto >48h.
* Crea ActividadLocal tipo SEGUIMIENTO con titulo URGENTE.
*/
async escaneoEscaladoLocal(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
const ahora = opciones.ahora ?? new Date()
const horasUmbral = config.automatizaciones.escaladoHoras
const limiteMs = horasUmbral * HORA_MS
const oportunidades = await prisma.leadCaptura.findMany({
where: {
board: 'OPORTUNIDADES',
etapa: { notIn: [...ETAPAS_TERMINALES_OPORTUNIDAD] },
},
})
const candidatos: CandidatoEscalado[] = []
let escalados = 0
for (const op of oportunidades) {
const t = op.updatedAt.getTime()
if (Number.isNaN(t) || ahora.getTime() - t <= limiteMs) continue
// Verificar que no tenga actividad URGENTE ya creada
const tieneActividadUrgente = await prisma.actividadLocal.findFirst({
where: {
leadCapturaId: op.id,
titulo: { contains: 'URGENTE' },
estado: 'ABIERTA',
},
})
const horas = Math.floor((ahora.getTime() - t) / HORA_MS)
candidatos.push({
itemId: op.leadId,
lead: op.nombreLead,
estado: op.etapa,
horasSinContactar: horas,
responsableId: null, // se resuelve abajo
yaEscalado: tieneActividadUrgente !== null,
})
if (tieneActividadUrgente) continue
try {
await actividadLocalService.crearActividad({
titulo: `🔴 URGENTE: ${op.nombreLead} sin contacto >${horasUmbral}h`,
descripcion: `🚨 Escalado automático: la oportunidad lleva ${horas}h en etapa "${op.etapa}" sin contacto.`,
usuarioId: op.usuarioId,
leadCapturaId: op.id,
fechaVencimiento: ahora,
tipo: 'SEGUIMIENTO',
})
await this.state.set(claveEscalado(op.leadId), ahora.toISOString())
escalados++
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[escalado-local] falló el escalado de ${op.leadId} (${op.nombreLead}):`, err)
}
}
await this.incrementarContador(CLAVE_CONTADOR_ESCALADOS, escalados)
await this.state.set(CLAVE_ESCALADO_ULTIMO_ESCANEO, ahora.toISOString())
return { horasUmbral, candidatos, escalados }
}
/** Estado actual para el endpoint admin */
async estado(): Promise<Record<string, unknown>> {
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)

View File

@@ -0,0 +1,33 @@
import { actividadLocalService } from './actividad-local.service.js'
import { saludMonday } from './salud-monday.service.js'
import type { CanalRecordatorio, DestinatarioRecordatorio } from './recordatorios-cobranza.service.js'
/**
* Canal local para recordatorios de cobranza.
* Crea ActividadLocal en vez de Actividad en Monday.
* Se usa cuando Monday no está disponible (modo local).
*/
export class LocalCanal implements CanalRecordatorio {
readonly nombre = 'local-actividad'
disponible(_destinatario: DestinatarioRecordatorio): boolean {
// El canal local está disponible cuando Monday NO está disponible
return !saludMonday.estaDisponible()
}
async enviar(
destinatario: DestinatarioRecordatorio,
mensaje: string,
_fecha: string,
): Promise<string> {
const actividad = await actividadLocalService.crearActividad({
titulo: mensaje.length > 250 ? `${mensaje.slice(0, 247)}...` : mensaje,
descripcion: mensaje,
usuarioId: 'sistema',
leadCapturaId: null,
fechaVencimiento: new Date(),
tipo: 'RECORDATORIO',
})
return actividad.id
}
}

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

View File

@@ -537,6 +537,12 @@ export interface FilaLocalMiDia {
modelo: string | null
board: string // LEADS | OPORTUNIDADES | APARTADO
etapa: string
/** Fecha límite del apartado (para "Apartados por vencer") */
fechaLimite: Date | null
/** Perfilamiento completo de afluencia (JSON serializado) */
datosJson: string | null
/** Usuario del ecosistema que capturó el lead (LeadCaptura.usuarioId) */
usuarioId: string
/** Item de Monday una vez sincronizado (null en capturas puramente locales) */
mondayItemId: string | null
pendienteSync: boolean
@@ -566,20 +572,46 @@ function ultimoCambioLocal(fila: FilaLocalMiDia, nombreUsuario: string) {
const porUpdatedAtDesc = (a: { updatedAt: string }, b: { updatedAt: string }): number =>
b.updatedAt.localeCompare(a.updatedAt)
/**
* Actividad local tal como la necesita Mi día (tabla ActividadLocal)
*/
export interface ActividadLocalMiDia {
id: string
titulo: string
descripcion: string | null
usuarioId: string
leadCapturaId: string | null
fechaVencimiento: Date | null
fechaCierre: Date | null
estado: string
tipo: string
}
/** Usuario local tal como lo necesita la sección equipo (tabla Usuario) */
export interface UsuarioLocal {
id: string
nombre: string
rol: string
activo: boolean
}
/**
* ResumenMiDia COMPLETO construido solo con datos locales (Monday caído).
* Misma forma que resumenMiDia: Mis leads (board LEADS, etapa no terminal),
* Mis oportunidades (board OPORTUNIDADES), Mis apartados (board APARTADO +
* unidad ligada vía Cliente), Mis capturas, Mis clientes. Lo que no existe en
* local va vacío/cero sin romper el front: actividades del día (no hay tabla
* local), apartadosPorVencer (los apartados locales aún no guardan fecha
* límite) y la sección equipo (se omite).
* unidad ligada vía Cliente), Mis capturas, Mis clientes.
*
* Parámetros opcionales para el modo local 100%:
* - `actividades`: actividades locales del usuario (tabla ActividadLocal)
* - `usuarios`: todos los usuarios activos (para la sección equipo)
*/
export function resumenMiDiaLocal(
usuario: UsuarioMiDia,
capturas: FilaLocalMiDia[],
clientes: ClienteLocalMiDia[],
ahora: Date = new Date(),
actividades: ActividadLocalMiDia[] = [],
usuarios: UsuarioLocal[] = [],
): ResumenMiDia {
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
@@ -614,19 +646,29 @@ export function resumenMiDiaLocal(
const itemsOportunidades: OportunidadMiDia[] = oportunidadesLocales
.filter((c) => !esEtapaTerminal(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
etapa: c.etapa,
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
unidadCodigo: '',
updatedAt: c.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
}))
.map((c) => {
let unidadCodigo = ''
if (c.datosJson) {
try {
const datos = JSON.parse(c.datosJson) as Record<string, unknown>
if (typeof datos.unidadCodigo === 'string' && datos.unidadCodigo !== '') unidadCodigo = datos.unidadCodigo
else if (typeof datos.desarrollo === 'string' && datos.desarrollo !== '') unidadCodigo = datos.desarrollo
} catch { /* datosJson corrupto: ignorar */ }
}
return {
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
etapa: c.etapa,
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
unidadCodigo,
updatedAt: c.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
}
})
.sort(porUpdatedAtDesc)
// La unidad del apartado local se conoce vía el Cliente ligado (alta iniciada)
@@ -642,7 +684,7 @@ export function resumenMiDiaLocal(
nombre: c.nombreLead,
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: '',
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
}))
@@ -680,22 +722,119 @@ export function resumenMiDiaLocal(
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// --- Mis actividades hoy (el controller ya filtra por usuarioId) ---
const hoy = fechaLocal(ahora, 0)
const manana = fechaLocal(ahora, 1)
const en7Dias = fechaLocal(ahora, 7)
const misActividadesHoy: ActividadMiDia[] = actividades
.filter((a) => a.estado.toUpperCase() === 'ABIERTA')
.filter((a) => {
if (!a.fechaVencimiento) return true
const fv = a.fechaVencimiento.toISOString().slice(0, 10)
return fv <= manana
})
.sort((a, b) => {
const fa = a.fechaVencimiento?.toISOString() ?? '9999'
const fb = b.fechaVencimiento?.toISOString() ?? '9999'
return fa.localeCompare(fb)
})
.map((a) => ({
id: a.id,
nombre: a.titulo,
tipo: a.tipo,
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
estado: a.estado,
}))
// --- Apartados por vencer ---
const apartadosPorVencer: ApartadoPorVencer[] = apartadosLocales
.filter((c) => {
if (esEstadoFinalApartado(c.etapa)) return false
if (!c.fechaLimite) return false
const fv = c.fechaLimite.toISOString().slice(0, 10)
return fv >= hoy && fv <= en7Dias
})
.sort((a, b) => {
const fa = a.fechaLimite?.toISOString().slice(0, 10) ?? '9999-12-31'
const fb = b.fechaLimite?.toISOString().slice(0, 10) ?? '9999-12-31'
return fa.localeCompare(fb)
})
.map((c) => {
const fv = c.fechaLimite!.toISOString().slice(0, 10)
return {
id: c.leadId,
nombre: c.nombreLead,
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
fechaLimite: fv,
estado: c.etapa,
diasRestantes: Math.round(
(Date.parse(`${fv}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA,
),
}
})
// --- Conteo de actividades abiertas ---
const actividadesAbiertas = actividades.filter((a) => a.estado.toUpperCase() === 'ABIERTA').length
// --- Equipo (solo coordinador/admin/corporativo) ---
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
let equipo: NonNullable<ResumenMiDia['equipo']> | undefined
if (ROLES_CON_EQUIPO.includes(usuario.rol) && usuarios.length > 0) {
const Leads = capturas.filter((c) => c.board === 'LEADS')
const Oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES')
const idsVendedores = new Set(usuarios.filter((u) => u.rol === 'vendedor' && u.activo).map((u) => u.id))
const sinAsignarLeads = Leads.filter(
(c) =>
!esEstadoTerminalLead(c.etapa) &&
c.etapa.toUpperCase() === 'NUEVO' &&
!idsVendedores.has(c.usuarioId),
).length
const sinAsignarOportunidades = Oportunidades.filter(
(c) => c.etapa.toUpperCase() === 'AFLUENCIA' && !idsVendedores.has(c.usuarioId),
).length
const porVendedor: NonNullable<ResumenMiDia['equipo']>['porVendedor'] = usuarios
.filter((u) => u.rol === 'vendedor' && u.activo)
.map((u) => {
const leadsAsignados = Leads.filter(
(c) => c.usuarioId === u.id && !esEstadoTerminalLead(c.etapa),
).length
const leadsNuevosSemana = Leads.filter(
(c) => c.usuarioId === u.id && c.createdAt.getTime() >= hace7DiasMs,
).length
return {
mondayUserId: 0,
nombre: u.nombre,
leadsAsignados,
leadsNuevosSemana,
}
})
.sort((a, b) => b.leadsAsignados - a.leadsAsignados)
equipo = {
leadsNuevosSinAsignar: sinAsignarLeads + sinAsignarOportunidades,
porVendedor,
}
}
return {
generadoEn: ahora.toISOString(),
sinMapeoMonday: usuario.mondayUserId === null,
misLeads,
misCapturas: misCapturasSinDuplicadosLocal,
misClientes,
misActividadesHoy: [], // no hay tabla local de actividades
apartadosPorVencer: [], // los apartados locales aún no guardan fecha límite
misActividadesHoy,
apartadosPorVencer,
misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados,
conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs,
).length,
actividadesAbiertas: 0,
actividadesAbiertas,
},
...(equipo !== undefined ? { equipo } : {}),
}
}

View File

@@ -1,5 +1,6 @@
import { config } from '../config/index.js'
import { parseFechaLocal, round2, statusPago, type Pago, type PlanPago } from './cobranza.service.js'
import { LocalCanal } from './local-canal.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
@@ -341,5 +342,6 @@ export const prismaRecordatoriosStore: RecordatoriosStore = {
/** Singleton usado por el cron y los endpoints (los tests instancian el suyo) */
export const recordatoriosCobranzaService = new RecordatoriosCobranzaService(prismaRecordatoriosStore, [
new MondayActividadCanal(mondayService),
new LocalCanal(),
new WhatsAppCanal(),
])

View File

@@ -12,6 +12,9 @@ const prismaMock = vi.hoisted(() => ({
unidad: {
findMany: vi.fn(),
},
actividadLocal: {
count: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -21,7 +24,8 @@ import { cargarDatosLocalesPrisma, ReportesService, type DatosLocalesReportes }
/**
* Fallback de reportes (modo local): Monday caído o 502 → el resumen se
* calcula desde tablas locales (LeadCaptura por board, Unidad por status,
* PlanPago como siempre) con el flag aditivo `parcial: true`.
* PlanPago como siempre). `parcial: false` porque en modo local los datos
* son completos (no parciales).
*/
function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] {
@@ -114,6 +118,7 @@ beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.actividadLocal.count.mockResolvedValue(0)
})
describe('ReportesService (fallback local)', () => {
@@ -126,7 +131,7 @@ describe('ReportesService (fallback local)', () => {
const r = await svc.resumen()
expect(r.parcial).toBe(true)
expect(r.parcial).toBe(false) // modo local completo: datos no son parciales
expect(r.leads.total).toBe(2)
expect(r.leads.porEstado).toEqual({ Nuevo: 1, Descartado: 1 })
expect(r.oportunidades.total).toBe(1)
@@ -134,7 +139,7 @@ describe('ReportesService (fallback local)', () => {
expect(r.unidades.total).toBe(2)
expect(r.unidades.disponibles).toBe(1)
expect(r.cobranza.planes.activos).toBe(1) // planes locales como siempre
expect(r.actividadesAbiertas).toBe(0) // no hay tabla local de actividades
expect(r.actividadesAbiertas).toBe(0) // no hay actividades en el fixture
expect(monday.getLeads).not.toHaveBeenCalled()
})
@@ -168,7 +173,7 @@ describe('ReportesService (fallback local)', () => {
})
const r = await svc.resumen()
expect(r.parcial).toBe(true)
expect(r.parcial).toBe(false) // modo local completo: datos no son parciales
expect(r.leads.total).toBe(2)
})

View File

@@ -296,6 +296,7 @@ export interface DatosLocalesReportes {
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
unidades: Unidad[]
actividadesAbiertas?: number
}
/** Cargador de los datos locales para el fallback (Prisma en prod, fixture en tests) */
@@ -379,7 +380,10 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
loQuierePara: campoTexto(datos, 'loQuierePara'),
}
})
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto) }
// Contar actividades abiertas
const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } })
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto), actividadesAbiertas }
}
export class ReportesService {
@@ -437,7 +441,13 @@ export class ReportesService {
}
}
const [locales, planes] = await Promise.all([this.cargarDatosLocales(), this.cargarPlanes()])
const resumen = construirResumen({ ...locales, planes, actividadesAbiertas: 0 }, new Date(ahoraMs))
return { ...resumen, parcial: true }
const resumen = construirResumen(
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
new Date(ahoraMs),
)
// En modo local, los datos son completos (no parciales): todas las métricas
// vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó
// a media lectura (ya no aplica aquí).
return { ...resumen, parcial: false }
}
}