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:
@@ -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([])
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
133
api-gateway/src/services/actividad-local.service.ts
Normal file
133
api-gateway/src/services/actividad-local.service.ts
Normal 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()
|
||||
449
api-gateway/src/services/automatizaciones-local.test.ts
Normal file
449
api-gateway/src/services/automatizaciones-local.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
33
api-gateway/src/services/local-canal.ts
Normal file
33
api-gateway/src/services/local-canal.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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) ----------
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user