From 388e1da338bd4845d3504baee994087f4437c078 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Mon, 7 Sep 2026 04:00:15 +0000 Subject: [PATCH] feat(modo-local): CRM 100% local sin dependencia de Monday.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../migration.sql | 42 ++ api-gateway/prisma/schema.prisma | 34 ++ api-gateway/scripts/seed-modo-local.js | 67 +++ .../src/controllers/mi-dia.controller.test.ts | 5 + .../src/controllers/mi-dia.controller.ts | 53 ++- api-gateway/src/index.ts | 32 +- .../services/actividad-local.service.test.ts | 233 +++++++++ .../src/services/actividad-local.service.ts | 133 ++++++ .../services/automatizaciones-local.test.ts | 449 ++++++++++++++++++ .../src/services/automatizaciones.service.ts | 256 ++++++++++ api-gateway/src/services/local-canal.ts | 33 ++ api-gateway/src/services/mi-dia-local.test.ts | 92 +++- api-gateway/src/services/mi-dia.service.ts | 181 ++++++- .../recordatorios-cobranza.service.ts | 2 + .../src/services/reportes-local.test.ts | 13 +- api-gateway/src/services/reportes.service.ts | 16 +- 16 files changed, 1604 insertions(+), 37 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260907120000_actividad_local/migration.sql create mode 100644 api-gateway/scripts/seed-modo-local.js create mode 100644 api-gateway/src/services/actividad-local.service.test.ts create mode 100644 api-gateway/src/services/actividad-local.service.ts create mode 100644 api-gateway/src/services/automatizaciones-local.test.ts create mode 100644 api-gateway/src/services/local-canal.ts diff --git a/api-gateway/prisma/migrations/20260907120000_actividad_local/migration.sql b/api-gateway/prisma/migrations/20260907120000_actividad_local/migration.sql new file mode 100644 index 0000000..9a4db7f --- /dev/null +++ b/api-gateway/prisma/migrations/20260907120000_actividad_local/migration.sql @@ -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; diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 7ad60ef..40eb333 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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]) +} diff --git a/api-gateway/scripts/seed-modo-local.js b/api-gateway/scripts/seed-modo-local.js new file mode 100644 index 0000000..3c38dd3 --- /dev/null +++ b/api-gateway/scripts/seed-modo-local.js @@ -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()) diff --git a/api-gateway/src/controllers/mi-dia.controller.test.ts b/api-gateway/src/controllers/mi-dia.controller.test.ts index 84ccade..59d20a6 100644 --- a/api-gateway/src/controllers/mi-dia.controller.test.ts +++ b/api-gateway/src/controllers/mi-dia.controller.test.ts @@ -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 & { 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([]) diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index ccd0048..7ec92e4 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -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)) } } diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts index fbf7a23..6b16323 100644 --- a/api-gateway/src/index.ts +++ b/api-gateway/src/index.ts @@ -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 diff --git a/api-gateway/src/services/actividad-local.service.test.ts b/api-gateway/src/services/actividad-local.service.test.ts new file mode 100644 index 0000000..013d564 --- /dev/null +++ b/api-gateway/src/services/actividad-local.service.test.ts @@ -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 = {} +let consecutivo = 0 + +vi.mock('./prisma.js', () => ({ + prisma: { + actividadLocal: { + create: vi.fn(async ({ data }: { data: Record }) => { + 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 }) => { + 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 + 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 }) => { + 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 + 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 }) => { + 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() + }) +}) diff --git a/api-gateway/src/services/actividad-local.service.ts b/api-gateway/src/services/actividad-local.service.ts new file mode 100644 index 0000000..6ffa2ef --- /dev/null +++ b/api-gateway/src/services/actividad-local.service.ts @@ -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 = { usuarioId } + + if (opciones.soloAbiertas === true) { + where['estado'] = 'ABIERTA' + } + if (opciones.fechaDesde !== null && opciones.fechaDesde !== undefined) { + where['fechaVencimiento'] = where['fechaVencimiento'] ?? {} + ;(where['fechaVencimiento'] as Record)['gte'] = opciones.fechaDesde + } + if (opciones.fechaHasta !== null && opciones.fechaHasta !== undefined) { + where['fechaVencimiento'] = where['fechaVencimiento'] ?? {} + ;(where['fechaVencimiento'] as Record)['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 { + const where: Record = { 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 { + 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 = { + 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() diff --git a/api-gateway/src/services/automatizaciones-local.test.ts b/api-gateway/src/services/automatizaciones-local.test.ts new file mode 100644 index 0000000..df2f713 --- /dev/null +++ b/api-gateway/src/services/automatizaciones-local.test.ts @@ -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 = {}) { + 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 = {}) { + 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 } { + const mapa = new Map() + 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) + }) +}) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index e770058..bc4b7b1 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -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 { + // 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 { + // 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 { + // 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 { + // 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 { + 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 { + 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> { const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES) diff --git a/api-gateway/src/services/local-canal.ts b/api-gateway/src/services/local-canal.ts new file mode 100644 index 0000000..8149bae --- /dev/null +++ b/api-gateway/src/services/local-canal.ts @@ -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 { + 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 + } +} diff --git a/api-gateway/src/services/mi-dia-local.test.ts b/api-gateway/src/services/mi-dia-local.test.ts index 5498848..bc375f1 100644 --- a/api-gateway/src/services/mi-dia-local.test.ts +++ b/api-gateway/src/services/mi-dia-local.test.ts @@ -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 & { 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) ---------- diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 564c0b0..718c8a7 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -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 + 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 | 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['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 } : {}), } } diff --git a/api-gateway/src/services/recordatorios-cobranza.service.ts b/api-gateway/src/services/recordatorios-cobranza.service.ts index 5ae339d..706976c 100644 --- a/api-gateway/src/services/recordatorios-cobranza.service.ts +++ b/api-gateway/src/services/recordatorios-cobranza.service.ts @@ -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(), ]) diff --git a/api-gateway/src/services/reportes-local.test.ts b/api-gateway/src/services/reportes-local.test.ts index c46ed51..e3ec578 100644 --- a/api-gateway/src/services/reportes-local.test.ts +++ b/api-gateway/src/services/reportes-local.test.ts @@ -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) }) diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index a50bd0b..06149d6 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -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 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 } } }