feat(modo-local): CRM 100% local sin dependencia de Monday.com

Nuevas tablas:
- ActividadLocal (historial de llamadas, reuniones, tareas)
- AsignacionLocal (round-robin local)
- fechaLimite en LeadCaptura

Servicios nuevos:
- actividad-local.service.ts (CRUD actividades)
- local-canal.ts (canal de envío para recordatorios)
- seed-modo-local.js (activar modo local)

Mi día local mejorado:
- Mis actividades hoy (filtros usuario/fecha/estado)
- Apartados por vencer (con fechaLimite real)
- Equipo (contadores por vendedor, solo coordinador/admin)
- unidadCodigo en oportunidades desde datosJson

Automatizaciones locales:
- Round-robin: asigna leads sin vendedor + crea actividad
- Inactividad: detecta oportunidades >10 días sin update
- Escalado: alerta oportunidades >48h sin contacto

Reportes: actividadesAbiertas + parcial:false en modo local
Cron wiring: crons locales cuando Monday caído

661 tests verdes.
This commit is contained in:
2026-09-07 04:00:15 +00:00
parent aadbc6f83f
commit 388e1da338
16 changed files with 1604 additions and 37 deletions

View File

@@ -0,0 +1,42 @@
-- Tablas de actividades locales y asignación round-robin local.
-- Patrón manual: db execute + migrate resolve. NUNCA migrate dev.
-- CreateTable ActividadLocal
CREATE TABLE IF NOT EXISTS "ActividadLocal" (
"id" TEXT NOT NULL PRIMARY KEY,
"titulo" TEXT NOT NULL,
"descripcion" TEXT,
"usuarioId" TEXT NOT NULL,
"leadCapturaId" TEXT,
"fechaVencimiento" DATETIME,
"fechaCierre" DATETIME,
"estado" TEXT NOT NULL DEFAULT 'ABIERTA',
"tipo" TEXT NOT NULL DEFAULT 'SEGUIMIENTO',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "ActividadLocal_usuarioId_idx" ON "ActividadLocal"("usuarioId");
CREATE INDEX IF NOT EXISTS "ActividadLocal_estado_idx" ON "ActividadLocal"("estado");
CREATE INDEX IF NOT EXISTS "ActividadLocal_fechaVencimiento_idx" ON "ActividadLocal"("fechaVencimiento");
-- CreateTable AsignacionLocal
CREATE TABLE IF NOT EXISTS "AsignacionLocal" (
"id" TEXT NOT NULL PRIMARY KEY,
"leadCapturaId" TEXT NOT NULL,
"usuarioId" TEXT NOT NULL,
"asignadoPor" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS "AsignacionLocal_leadCapturaId_key" ON "AsignacionLocal"("leadCapturaId");
CREATE INDEX IF NOT EXISTS "AsignacionLocal_usuarioId_idx" ON "AsignacionLocal"("usuarioId");
-- AlterTable LeadCaptura: add fechaLimite column
-- SQLite: CHECK if column exists first to avoid duplicate
-- The ALTER TABLE ADD COLUMN in SQLite is safe with IF NOT EXISTS semantics via PRAGMA
CREATE TABLE IF NOT EXISTS "__pragma_check" (name TEXT);
DROP TABLE IF EXISTS "__pragma_check";
-- ALTER TABLE is safe: SQLite ignores ADD COLUMN if column exists (error + ignore pattern)
-- We use a safer approach: add only if not present
-- SQLite doesn't support IF NOT EXISTS for ADD COLUMN, so we wrap in a safe pattern
ALTER TABLE "LeadCaptura" ADD COLUMN "fechaLimite" DATETIME;

View File

@@ -183,6 +183,8 @@ model LeadCaptura {
// Item de Monday una vez sincronizado (rows viejas: = leadId)
mondayItemId String?
// true = falta replicar a Monday (lo drena el sync worker)
// Fecha límite del apartado (para "Apartados por vencer" en Mi día)
fechaLimite DateTime?
pendienteSync Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
@@ -353,3 +355,35 @@ model AIMensaje {
@@index([conversacionId])
}
// ---------- Actividades locales (modo sin Monday) ----------
model ActividadLocal {
id String @id @default(uuid())
titulo String
descripcion String?
usuarioId String
leadCapturaId String?
fechaVencimiento DateTime?
fechaCierre DateTime?
estado String @default("ABIERTA") // ABIERTA | CERRADA
tipo String @default("SEGUIMIENTO") // SEGUIMIENTO | RECORDATORIO | REUNION
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([usuarioId])
@@index([estado])
@@index([fechaVencimiento])
}
// Asignaciones locales de leads (reemplaza round-robin de Monday)
model AsignacionLocal {
id String @id @default(uuid())
leadCapturaId String
usuarioId String
asignadoPor String? // round-robin | capturista | manual
createdAt DateTime @default(now())
@@unique([leadCapturaId])
@@index([usuarioId])
}

View File

@@ -0,0 +1,67 @@
/**
* Activa el modo local forzado en ConfiguracionSistema.
*
* 1. Pon `modo_local_forzado = true` en ConfiguracionSistema
* 2. Verifica que unidades locales existen (count > 0)
* 3. Verifica que roles existen
* 4. Log: "Modo local activado. Monday deshabilitado."
*
* Idempotente: puede ejecutarse múltiples veces sin efectos adversos.
*
* Uso: npx tsx scripts/seed-modo-local.js
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
const prisma = new PrismaClient()
async function main() {
// 1. Activar modo local forzado
console.log('⚙️ Activando modo local forzado...')
await prisma.configuracionSistema.upsert({
where: { clave: 'modo_local_forzado' },
create: { clave: 'modo_local_forzado', valor: 'true' },
update: { valor: 'true' },
})
console.log(' ✓ modo_local_forzado = true')
// 2. Verificar que unidades locales existen
const unidadesCount = await prisma.unidad.count()
if (unidadesCount === 0) {
console.warn(' ⚠️ No hay unidades locales. Ejecuta seed-unidades-locales.js primero.')
} else {
console.log(` ✓ ${unidadesCount} unidades locales verificadas`)
}
// 3. Verificar que roles existen
const rolesCount = await prisma.rolConfiguracion.count()
if (rolesCount === 0) {
console.warn(' ⚠️ No hay roles configurados. Ejecuta seed-config.js primero.')
} else {
console.log(` ✓ ${rolesCount} roles configurados verificados`)
}
// 4. Verificar que usuarios existen
const usuariosCount = await prisma.usuario.count()
if (usuariosCount === 0) {
console.warn(' ⚠️ No hay usuarios. Ejecuta seed-usuarios.ts primero.')
} else {
console.log(` ✓ ${usuariosCount} usuarios verificados`)
}
console.log('\n✅ Modo local activado. Monday deshabilitado.')
console.log(' La API operará con tablas locales (LeadCaptura, ActividadLocal, etc.)')
console.log(' Los crons de Monday se saltarán; los crons locales correrán en su lugar.')
}
main()
.catch((err) => {
console.error('❌ Error en seed:', err)
process.exit(1)
})
.finally(() => prisma.$disconnect())

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