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

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

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

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

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

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

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

View File

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

View File

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