feat(purga): Monday eliminado completamente del sistema

14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
This commit is contained in:
2026-09-11 11:47:17 +00:00
parent 64b01246d6
commit 89d95eb53b
130 changed files with 2018 additions and 14747 deletions

View File

@@ -3,7 +3,7 @@ import { prisma } from './prisma.js'
/**
* CRUD de actividades locales (tabla ActividadLocal).
* Reemplaza las actividades de Monday en modo local: historial de llamadas,
* Actividades locales: historial de llamadas,
* reuniones, tareas pendientes. Mi día las muestra en "Mis actividades hoy".
*/

View File

@@ -1,94 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
updateMany: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { MondayService } from './monday.service.js'
import { saludMonday } from './salud-monday.service.js'
/**
* Fallback de actualizarDisponibilidadUnidad (modo local): con Monday caído o
* lanzando 502, el status se actualiza en el espejo local (tabla Unidad) y la
* réplica queda encolada (DISPONIBILIDAD_UNIDAD) para el sync worker.
*/
const crearServicio = (): MondayService =>
new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 })
const respuesta500 = (): Response =>
new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' })
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
vi.unstubAllGlobals()
saludMonday.restablecer()
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
prismaMock.syncPendiente.create.mockResolvedValue({})
})
describe('actualizarDisponibilidadUnidad (fallback local)', () => {
it('Monday caído (circuit breaker): actualiza el espejo local y encola DISPONIBILIDAD_UNIDAD sin fetch', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA')
expect(fetchSpy).not.toHaveBeenCalled()
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101' },
data: { status: 'APARTADA' },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: {
tipo: 'DISPONIBILIDAD_UNIDAD',
payloadJson: JSON.stringify({ codigo: 'U-101', status: 'APARTADA' }),
referenciaLocal: 'U-101',
},
})
})
it('Monday lanza 502 a media operación: mismo fallback local', async () => {
vi.stubGlobal('fetch', async () => respuesta500())
await crearServicio().actualizarDisponibilidadUnidad('U-102', 'VENDIDA')
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-102' },
data: { status: 'VENDIDA' },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledOnce()
})
it('unidad inexistente en el espejo local → 404 y no encola', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.updateMany.mockResolvedValue({ count: 0 })
await expect(crearServicio().actualizarDisponibilidadUnidad('U-X', 'APARTADA')).rejects.toThrow(
'Unidad U-X no encontrada',
)
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
it('un fallo al encolar no rompe la escritura local (best-effort)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.syncPendiente.create.mockRejectedValue(new Error('DB llena'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA')
expect(prismaMock.unidad.updateMany).toHaveBeenCalledOnce()
consoleSpy.mockRestore()
})
})

View File

@@ -400,7 +400,7 @@ describe('ejecutarToolCall', () => {
return { ok: true, args }
},
async buscar_unidad(_args: ArgsBuscarUnidad) {
throw new Error('Monday caído')
throw new Error('proveedor caído')
},
async buscar_leads(_args: ArgsBuscarLeads) {
return []
@@ -430,7 +430,7 @@ describe('ejecutarToolCall', () => {
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'borrar_todo', argumentosJson: '{}' }, ejecutores)))
.toEqual({ error: 'Herramienta desconocida: borrar_todo' })
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'buscar_unidad', argumentosJson: '{}' }, ejecutores)))
.toEqual({ error: 'Monday caído' })
.toEqual({ error: 'proveedor caído' })
})
})

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { mondayService } from './monday.service.js'
import { getUnidadesLocal } from './unidades-local.service.js'
import {
APARTADO_DEFAULT,
calcularPropuesta,
@@ -489,11 +489,12 @@ export interface EjecutoresHerramientas {
const r2 = (x: number | null): number | null => (x === null ? null : Math.round(x * 100) / 100)
/**
* Ejecutores reales: inventario desde Monday (o el buscador inyectado en tests)
* y cálculo con el motor de cotización portado de los Excel.
* Ejecutores reales: inventario desde el espejo local de unidades (o el
* buscador inyectado en tests) y cálculo con el motor de cotización portado de
* los Excel.
*/
export function crearEjecutoresHerramientas(
buscarUnidades: () => Promise<UnidadCotizable[]> = () => mondayService.getUnidades(),
buscarUnidades: () => Promise<UnidadCotizable[]> = () => getUnidadesLocal(),
): EjecutoresHerramientas {
return {
async cotizar_unidad(args) {
@@ -719,7 +720,7 @@ export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] {
export interface OpcionesChat {
/** sub del JWT: la conversación nueva queda ligada al usuario (memoria por usuario) */
usuarioId?: string | null
/** Ejecutores de herramientas (default: los reales contra Monday + motor de cotización) */
/** Ejecutores de herramientas (default: inventario local + motor de cotización) */
ejecutores?: EjecutoresHerramientas
}

View File

@@ -8,17 +8,16 @@ import {
interface Opciones {
cotizaciones?: CotizacionParaAlerta[]
mondayUserId?: number | null
yaAlertada?: boolean
fallaMonday?: boolean
fallaActividad?: boolean
}
/** Dependencias mockeadas: registra actividades y claves dedup marcadas */
/** Dependencias mockeadas: registra actividades locales y claves dedup marcadas */
function depsMock(opts: Opciones = {}) {
const dedup = new Set<string>()
if (opts.yaAlertada) dedup.add('*') // comodín: todo ya alertado
const deps: AlertasInventarioDeps & {
actividades: { nombre: string; responsableId: number }[]
actividades: { nombre: string; vendedorId: string }[]
consultas: { unidadCodigo: string; desde: Date }[]
} = {
actividades: [],
@@ -27,12 +26,9 @@ function depsMock(opts: Opciones = {}) {
this.consultas.push({ unidadCodigo, desde })
return opts.cotizaciones ?? []
},
async mondayUserIdDeVendedor() {
return opts.mondayUserId === undefined ? 12345 : opts.mondayUserId
},
async crearActividad(datos) {
if (opts.fallaMonday) throw new Error('Monday caído')
this.actividades.push({ nombre: datos.nombre, responsableId: datos.responsableId })
if (opts.fallaActividad) throw new Error('SQLite bloqueado')
this.actividades.push({ nombre: datos.nombre, vendedorId: datos.vendedorId })
return 'act-1'
},
async yaAlertada(clave) {
@@ -48,16 +44,16 @@ function depsMock(opts: Opciones = {}) {
const AHORA = new Date('2026-08-28T12:00:00')
describe('procesarAlertasInventario', () => {
it('crea la actividad al vendedor con mondayUserId y texto de estatus', async () => {
it('crea la actividad local al vendedor con texto de estatus', async () => {
const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] })
const r = await procesarAlertasInventario(
deps,
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }],
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }],
AHORA,
)
expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 })
expect(deps.actividades).toEqual([
{ nombre: 'La unidad U-101 que cotizaste pasó a APARTADO', responsableId: 12345 },
{ nombre: 'La unidad U-101 que cotizaste pasó a APARTADA', vendedorId: 'ven-1' },
])
})
@@ -70,7 +66,7 @@ describe('procesarAlertasInventario', () => {
it('dedup por unidad+cotización+estatus: no repite la alerta', async () => {
const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] })
const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }
const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }
await procesarAlertasInventario(deps, [cambio], AHORA)
const r = await procesarAlertasInventario(deps, [cambio], AHORA)
expect(r.alertasCreadas).toBe(0)
@@ -78,35 +74,23 @@ describe('procesarAlertasInventario', () => {
expect(deps.actividades).toHaveLength(1)
})
it('omite cotizaciones sin vendedor o sin mapeo Monday', async () => {
it('omite cotizaciones sin vendedor', async () => {
const sinVendedor = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: null }] })
const r1 = await procesarAlertasInventario(
sinVendedor,
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }],
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }],
AHORA,
)
expect(r1.omitidas).toBe(1)
expect(sinVendedor.actividades).toHaveLength(0)
const sinMapeo = depsMock({
cotizaciones: [{ id: 'cot-2', vendedorId: 'ven-2' }],
mondayUserId: null,
})
const r2 = await procesarAlertasInventario(
sinMapeo,
[{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }],
AHORA,
)
expect(r2.omitidas).toBe(1)
expect(sinMapeo.actividades).toHaveLength(0)
})
it('si Monday falla no marca dedup (reintentable) y cuenta fallidas', async () => {
it('si la creación falla no marca dedup (reintentable) y cuenta fallidas', async () => {
const deps = depsMock({
cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }],
fallaMonday: true,
fallaActividad: true,
})
const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }
const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }
const r1 = await procesarAlertasInventario(deps, [cambio], AHORA)
expect(r1.fallidas).toBe(1)
// Segundo intento vuelve a intentarlo (no quedó deduplicada)
@@ -124,7 +108,7 @@ describe('procesarAlertasInventario', () => {
const r = await procesarAlertasInventario(
deps,
[
{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' },
{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' },
{ unidadCodigo: 'U-102', statusNuevo: 'VENDIDA' },
],
AHORA,
@@ -137,7 +121,7 @@ describe('procesarAlertasInventario', () => {
const deps = depsMock({ cotizaciones: [] })
const r = await procesarAlertasInventario(
deps,
[{ unidadCodigo: 'U-999', statusNuevo: 'APARTADO' }],
[{ unidadCodigo: 'U-999', statusNuevo: 'APARTADA' }],
AHORA,
)
expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 0, fallidas: 0 })

View File

@@ -1,15 +1,15 @@
/**
* Alerta de inventario: cuando una unidad cambia de estatus, se busca si hay
* cotizaciones recientes (≤30 días) de esa unidad y se crea una Actividad en
* Monday al vendedor de cada cotización (solo si tiene mondayUserId mapeado).
* cotizaciones recientes (≤30 días) de esa unidad y se crea una Actividad
* local (tabla ActividadLocal) al vendedor de cada cotización.
*
* Lógica pura con dependencias inyectadas para poder probarla con mocks; el
* controller la cablea con Prisma y MondayService.
* controller la cablea con Prisma y ActividadLocalService.
*/
export interface CambioEstatusUnidad {
unidadCodigo: string
statusNuevo: string // p. ej. APARTADO | VENDIDA | BLOQUEADO
statusNuevo: string // p. ej. APARTADA | VENDIDA | BLOQUEADO
}
export interface CotizacionParaAlerta {
@@ -20,15 +20,10 @@ export interface CotizacionParaAlerta {
export interface AlertasInventarioDeps {
/** Cotizaciones de la unidad creadas desde `desde` (ventana de 30 días) */
buscarCotizacionesRecientes(unidadCodigo: string, desde: Date): Promise<CotizacionParaAlerta[]>
/** mondayUserId del vendedor; null si no existe o no está mapeado */
mondayUserIdDeVendedor(vendedorId: string): Promise<number | null>
/** Crea la Actividad en Monday y devuelve su item id */
/** Crea la Actividad local al vendedor y devuelve su id */
crearActividad(datos: {
nombre: string
tipoLabel: string
responsableId: number
fecha: string // 'YYYY-MM-DD'
estadoLabel: string
vendedorId: string
}): Promise<string>
/** Dedup persistente por clave (unidad+cotización+estatus) */
yaAlertada(clave: string): Promise<boolean>
@@ -38,8 +33,8 @@ export interface AlertasInventarioDeps {
export interface ResultadoAlertas {
cambiosProcesados: number
alertasCreadas: number
omitidas: number // sin vendedor, sin mapeo Monday o ya alertadas
fallidas: number // Monday falló; NO quedan deduplicadas (reintentables)
omitidas: number // sin vendedor o ya alertadas
fallidas: number // la creación falló; NO quedan deduplicadas (reintentables)
}
export const VENTANA_ALERTA_DIAS = 30
@@ -47,9 +42,6 @@ export const VENTANA_ALERTA_DIAS = 30
const claveDedup = (cambio: CambioEstatusUnidad, cotizacionId: string): string =>
`alerta-inv:${cambio.unidadCodigo}:${cotizacionId}:${cambio.statusNuevo}`
const hoyISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
/** Procesa los cambios de estatus y crea las actividades correspondientes */
export async function procesarAlertasInventario(
deps: AlertasInventarioDeps,
@@ -78,23 +70,15 @@ export async function procesarAlertasInventario(
resultado.omitidas += 1
continue
}
const mondayUserId = await deps.mondayUserIdDeVendedor(cotizacion.vendedorId)
if (mondayUserId === null) {
resultado.omitidas += 1
continue
}
try {
await deps.crearActividad({
nombre: `La unidad ${cambio.unidadCodigo} que cotizaste pasó a ${cambio.statusNuevo}`,
tipoLabel: 'Tarea',
responsableId: mondayUserId,
fecha: hoyISO(ahora),
estadoLabel: 'Abierto',
vendedorId: cotizacion.vendedorId,
})
await deps.marcarAlertada(clave)
resultado.alertasCreadas += 1
} catch {
// Monday falló: no se marca dedup para que el siguiente sync reintente
// La creación falló: no se marca dedup para que el siguiente sync reintente
resultado.fallidas += 1
}
}

View File

@@ -11,7 +11,7 @@ export interface UsuarioPublico {
nombre: string
rol: string
asignacion: string | null
// User ID de Monday vinculado (null = sin vincular; ver PATCH /usuarios/me/monday)
// User ID del CRM anterior vinculado (null = sin vincular; se mantiene por compatibilidad de datos)
mondayUserId: number | null
// Módulos del ecosistema visibles para el rol (matriz de config/roles.ts)
modulos: Modulo[]

View File

@@ -10,6 +10,11 @@ vi.mock('./prisma.js', () => ({
},
}))
vi.mock('./config.service.js', () => ({
obtenerConfigSync: vi.fn(() => 'true'), // round-robin habilitado en tests
refrescarCache: vi.fn(),
}))
vi.mock('./actividad-local.service.js', () => ({
actividadLocalService: {
crearActividad: vi.fn(),
@@ -22,28 +27,14 @@ vi.mock('./actividad-local.service.js', () => ({
},
}))
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 {
@@ -97,27 +88,11 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
}
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)
return new AutomatizacionesService(state)
}
// ── Tests ──
beforeEach(() => {
saludMock.estaDisponible.mockReturnValue(false)
})
afterEach(() => {
vi.clearAllMocks()
})
@@ -412,38 +387,3 @@ describe('AutomatizacionesService.escaneoEscaladoLocal', () => {
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,759 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { config } from '../config/index.js'
import {
AutomatizacionesService,
claveApartadoCreado,
claveEscalado,
claveRecordatorio,
claveVentaCreada,
esEstadoTerminal,
esEtapa,
ESTADOS_TERMINALES_LEAD,
oportunidadesInactivas,
oportunidadesSinContactar,
parseRrUserIds,
vendedorEnTurno,
type StateStore,
} from './automatizaciones.service.js'
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
// ---------- Fixtures ----------
const VENDEDORES: UsuarioMonday[] = [
{ id: 101, nombre: 'Ana', email: 'ana@x.com' },
{ id: 102, nombre: 'Beto', email: 'beto@x.com' },
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
]
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-01T09:00:00Z',
updatedAt: '2026-08-01T10:00:00Z',
estado: 'Nuevo',
origen: '',
vendedorIds: [],
telefono: '',
email: '',
...overrides,
}
}
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
nombre: `Cliente ${id}`,
createdAt: '2026-08-01T09:00:00Z',
updatedAt: '2026-08-01T10:00:00Z',
etapa: 'Afluencia',
unidadCodigo: 'U-101',
precioFinal: 2500000,
asesorIds: [],
telefono: '',
email: '',
origen: '',
...overrides,
}
}
/** StateStore en memoria para tests (sin Prisma) */
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)
},
}
}
/** Monday mockeado: leads y oportunidades fijos, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[] = [], oportunidades: OportunidadMonday[] = []) {
return {
asignaciones: [] as { itemId: string; userId: number; tablero: string }[],
updates: [] as { itemId: string; body: string }[],
actividades: [] as {
nombre: string
tipoLabel: string
responsableId: number
fecha: string
estadoLabel: string
leadItemId: string
}[],
apartados: [] as Record<string, unknown>[],
ventas: [] as Record<string, unknown>[],
async getLeads() {
return leads
},
async getOportunidades() {
return oportunidades
},
async asignarVendedorALead(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId, tablero: 'leads' })
},
async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId, tablero: 'oportunidades' })
},
async crearUpdateEnItem(itemId: string, body: string) {
this.updates.push({ itemId, body })
return 'upd-1'
},
async crearActividad(datos: {
nombre: string
tipoLabel: string
responsableId: number
fecha: string
estadoLabel: string
leadItemId: string
}) {
this.actividades.push(datos)
return 'act-1'
},
async crearApartado(datos: Record<string, unknown>) {
this.apartados.push(datos)
return `apt-${this.apartados.length}`
},
async crearVenta(datos: Record<string, unknown>) {
this.ventas.push(datos)
return `vta-${this.ventas.length}`
},
async getUsuariosEquipo(_nombre: string) {
return VENDEDORES
},
}
}
const crearServicio = (monday: ReturnType<typeof mondayMock>, state: StateStore) =>
new AutomatizacionesService(monday, state)
// ---------- Lógica pura ----------
describe('parseRrUserIds', () => {
it('parsea IDs separados por coma ignorando espacios y vacíos', () => {
expect(parseRrUserIds(' 106735975, 106735976 ,, ')).toEqual([
{ id: 106735975, nombre: 'Usuario 106735975', email: '' },
{ id: 106735976, nombre: 'Usuario 106735976', email: '' },
])
expect(parseRrUserIds('')).toEqual([])
})
it('lanza error con IDs inválidos', () => {
expect(() => parseRrUserIds('123,abc')).toThrow(/inválido/)
expect(() => parseRrUserIds('-5')).toThrow(/inválido/)
})
})
describe('esEstadoTerminal (terminales por tablero)', () => {
it('Ganado / Perdido / Abandono son terminales en Oportunidades (default)', () => {
expect(esEstadoTerminal('GANADO')).toBe(true)
expect(esEstadoTerminal(' perdido ')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Afluencia')).toBe(false)
expect(esEstadoTerminal('Contactado')).toBe(false)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
expect(esEstadoTerminal('')).toBe(false)
})
it('Descartado / Calificado / Abandono son terminales en Leads (title case, case-insensitive)', () => {
expect(esEstadoTerminal('Descartado', ESTADOS_TERMINALES_LEAD)).toBe(true)
expect(esEstadoTerminal('calificado', ESTADOS_TERMINALES_LEAD)).toBe(true)
expect(esEstadoTerminal('ABANDONO', ESTADOS_TERMINALES_LEAD)).toBe(true)
expect(esEstadoTerminal('Nuevo', ESTADOS_TERMINALES_LEAD)).toBe(false)
expect(esEstadoTerminal('Contactado', ESTADOS_TERMINALES_LEAD)).toBe(false)
})
})
describe('vendedorEnTurno (rotación round-robin)', () => {
it('rota circularmente por la lista', () => {
expect(vendedorEnTurno(VENDEDORES, 0)?.id).toBe(101)
expect(vendedorEnTurno(VENDEDORES, 1)?.id).toBe(102)
expect(vendedorEnTurno(VENDEDORES, 2)?.id).toBe(103)
expect(vendedorEnTurno(VENDEDORES, 3)?.id).toBe(101) // wraparound
expect(vendedorEnTurno(VENDEDORES, 7)?.id).toBe(102)
})
it('devuelve undefined con lista vacía', () => {
expect(vendedorEnTurno([], 0)).toBeUndefined()
})
})
describe('oportunidadesInactivas', () => {
const ahora = new Date('2026-08-22T00:00:00Z')
it('incluye oportunidades en etapa temprana CON asesor que superan el umbral de días', () => {
const ops = [
oportunidad('1', { updatedAt: '2026-08-10T00:00:00Z', asesorIds: [101] }), // 12 días → inactiva (umbral 10)
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // 2 días → activa
oportunidad('3', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Perdido' }), // terminal → excluida
oportunidad('4', { updatedAt: '2026-07-01T00:00:00Z' }), // sin asesor → excluida
oportunidad('5', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Cotizador' }), // etapa avanzada → excluida
oportunidad('6', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101], etapa: 'Contactado' }), // 21 días → inactiva
]
const inactivas = oportunidadesInactivas(ops, ahora, 10)
expect(inactivas.map((o) => o.id)).toEqual(['1', '6'])
})
it('una oportunidad actualizada hace exactamente N días aún no supera el umbral', () => {
const ops = [oportunidad('1', { updatedAt: '2026-08-12T00:00:00Z', asesorIds: [101] })] // justo 10 días
expect(oportunidadesInactivas(ops, ahora, 10)).toEqual([])
})
it('ignora fechas inválidas', () => {
expect(oportunidadesInactivas([oportunidad('1', { updatedAt: 'no-es-fecha', asesorIds: [101] })], ahora, 10)).toEqual([])
})
})
describe('claveRecordatorio (dedup por día)', () => {
it('una clave por oportunidad por día', () => {
expect(claveRecordatorio('123', new Date('2026-08-22T09:00:00Z'))).toBe('recordatorio:123:2026-08-22')
expect(claveRecordatorio('123', new Date('2026-08-22T23:59:00Z'))).toBe('recordatorio:123:2026-08-22')
expect(claveRecordatorio('123', new Date('2026-08-23T00:00:00Z'))).toBe('recordatorio:123:2026-08-23')
})
})
// ---------- Servicio (monday mockeado, sin red) ----------
describe('AutomatizacionesService.runRoundRobin', () => {
it('asigna en rotación sobre AMBOS tableros y persiste el índice (Leads → Vendedor, Oportunidades → Asesor)', async () => {
const monday = mondayMock([lead('l1'), lead('l2')], [oportunidad('o1'), oportunidad('o2')])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r1 = await svc.runRoundRobin()
expect(r1.asignados).toBe(4)
expect(monday.asignaciones).toEqual([
{ itemId: 'l1', userId: 101, tablero: 'leads' },
{ itemId: 'l2', userId: 102, tablero: 'leads' },
{ itemId: 'o1', userId: 103, tablero: 'oportunidades' },
{ itemId: 'o2', userId: 101, tablero: 'oportunidades' },
])
expect(r1.detalle.map((d) => d.tablero)).toEqual(['leads', 'leads', 'oportunidades', 'oportunidades'])
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
// Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
const monday2 = mondayMock([], [oportunidad('o5')])
const svc2 = new AutomatizacionesService(monday2, state)
const r2 = await svc2.runRoundRobin()
expect(r2.asignados).toBe(1)
expect(monday2.asignaciones[0]?.userId).toBe(102)
expect(state.mapa.get('rr:indice')).toBe('2')
})
it('Leads: reparte los sin Vendedor y no terminales (labels title case, case-insensitive)', async () => {
const monday = mondayMock([
lead('1'),
lead('2', { vendedorIds: [101] }), // ya asignado
lead('3', { estado: 'Descartado' }), // terminal
lead('4', { estado: 'Calificado' }), // terminal
lead('5', { estado: 'Contactado' }), // en seguimiento: sí entra
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin(undefined, 'leads')
expect(r.asignados).toBe(2)
expect(monday.asignaciones).toEqual([
{ itemId: '1', userId: 101, tablero: 'leads' },
{ itemId: '5', userId: 102, tablero: 'leads' },
])
})
it('Oportunidades: solo reparte etapa Afluencia (Cotizador/Contactado NO entran)', async () => {
const monday = mondayMock([], [
oportunidad('1'),
oportunidad('2', { asesorIds: [101] }), // ya asignada
oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra
oportunidad('4', { etapa: 'Contactado' }), // ya en seguimiento: no entra
oportunidad('5', { etapa: 'Perdido' }), // terminal: no entra
oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin(undefined, 'oportunidades')
expect(r.asignados).toBe(2)
expect(monday.asignaciones).toEqual([
{ itemId: '1', userId: 101, tablero: 'oportunidades' },
{ itemId: '6', userId: 102, tablero: 'oportunidades' },
])
})
it('omite items con bandera rr:skip=capturista (los asigna su capturista)', async () => {
const monday = mondayMock([lead('1')], [oportunidad('2')])
const state = stateEnMemoria()
state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna
state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
const svc = crearServicio(monday, state)
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1)
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'oportunidades' }])
})
it('con soloItemId + board solo evalúa ese item (ruta de los webhooks create_item)', async () => {
const monday = mondayMock([lead('1'), lead('2')], [oportunidad('9')])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin('2', 'leads')
expect(r.asignados).toBe(1)
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'leads' }])
})
it('con soloItemId de otro tablero no asigna nada', async () => {
const monday = mondayMock([lead('1')], [oportunidad('9')])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin('9', 'leads') // '9' no está en Leads
expect(r.asignados).toBe(0)
expect(monday.asignaciones).toEqual([])
})
it('cachea los vendedores del equipo en el estado (una sola llamada dentro del TTL)', async () => {
const monday = mondayMock()
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const v1 = await svc.obtenerVendedores()
const v2 = await svc.obtenerVendedores()
expect(v1.map((v) => v.id)).toEqual([101, 102, 103])
expect(v2).toEqual(v1)
expect(spy).toHaveBeenCalledTimes(1)
expect(spy.mock.calls[0]?.[0]).toBe('vendedores') // equipo por default
})
it('cache con formato viejo (arreglo sin ts) se descarta y re-consulta', async () => {
const monday = mondayMock()
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
const state = stateEnMemoria()
state.mapa.set('rr:vendedores', JSON.stringify([{ id: 999, nombre: 'Viejo', email: '' }]))
const svc = crearServicio(monday, state)
const v = await svc.obtenerVendedores()
expect(v.map((x) => x.id)).toEqual([101, 102, 103])
expect(spy).toHaveBeenCalledTimes(1)
})
})
describe('AutomatizacionesService.escaneoInactividad', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
const oportunidadesViejas = () => [
oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] }), // 21 días inactiva
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // activa
]
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
const monday = mondayMock([], oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoInactividad({ preview: true, ahora })
expect(r.preview).toBe(true)
expect(r.candidatos.map((c) => c.itemId)).toEqual(['1'])
expect(r.candidatos[0]?.diasInactivo).toBe(21)
expect(r.recordatoriosEnviados).toBe(0)
expect(monday.updates).toEqual([])
expect(state.mapa.size).toBe(0) // preview no persiste nada
})
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
const monday = mondayMock([], oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r1 = await svc.escaneoInactividad({ preview: false, ahora })
expect(r1.recordatoriosEnviados).toBe(1)
expect(monday.updates).toHaveLength(1)
expect(monday.updates[0]?.body).toContain('21 días')
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined()
// Mismo día: no repite aunque la oportunidad siga inactiva
const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
expect(r2.recordatoriosEnviados).toBe(0)
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
expect(monday.updates).toHaveLength(1)
// Al día siguiente vuelve a recordar
const r3 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-23T09:00:00Z') })
expect(r3.recordatoriosEnviados).toBe(1)
expect(monday.updates).toHaveLength(2)
})
})
// ---------- Actividades automáticas (tablero Actividades del CRM) ----------
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
const monday = mondayMock([], [oportunidad('1'), oportunidad('2')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(2)
expect(monday.actividades).toHaveLength(2)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Cliente 1',
tipoLabel: 'Llamada',
responsableId: 101,
estadoLabel: 'Abierto',
leadItemId: '1',
})
// Fecha compromiso: hoy + 1 día (fecha local, como la construye el servicio)
const esperada = new Date()
esperada.setDate(esperada.getDate() + 1)
const m = String(esperada.getMonth() + 1).padStart(2, '0')
const d = String(esperada.getDate()).padStart(2, '0')
expect(monday.actividades[0]?.fecha).toBe(`${esperada.getFullYear()}-${m}-${d}`)
expect(monday.actividades[1]?.responsableId).toBe(102)
})
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
const monday = mondayMock([], [oportunidad('1')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.runRoundRobin()
expect(monday.actividades).toEqual([])
})
it('asignarACapturista: asigna al capturista (columna Asesor) y le crea su actividad de contacto', async () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.asignarACapturista('op-77', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555, tablero: 'oportunidades' }])
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead Propio',
responsableId: 555,
estadoLabel: 'Abierto',
leadItemId: 'op-77',
})
})
it('asignarACapturista con board "leads" escribe la columna Vendedor del tablero Leads', async () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.asignarACapturista('lead-77', 'Lead Propio', 555, 'leads')
expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555, tablero: 'leads' }])
expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', leadItemId: 'lead-77' })
})
it('asignarACapturista con actividadesEnabled=false solo asigna', async () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.asignarACapturista('op-78', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555, tablero: 'oportunidades' }])
expect(monday.actividades).toEqual([])
})
it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => {
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoInactividad({ preview: false, ahora })
expect(r.recordatoriosEnviados).toBe(1)
expect(monday.actividades).toHaveLength(1)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Retomar contacto con Cliente 1',
tipoLabel: 'Llamada',
responsableId: 103,
estadoLabel: 'Abierto',
leadItemId: '1',
})
})
it('preview de inactividad no crea actividades', async () => {
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.escaneoInactividad({ preview: true, ahora })
expect(monday.actividades).toEqual([])
})
it('un fallo al crear la actividad no rompe la asignación', async () => {
const monday = mondayMock([], [oportunidad('1')])
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1) // la asignación se hizo igual
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101, tablero: 'oportunidades' }])
spyError.mockRestore()
})
})
// ---------- Escalado de afluencias sin contactar ----------
describe('oportunidadesSinContactar (regla de escalado por horas)', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
it('incluye oportunidades asignadas en Afluencia / Intento de contacto que superan el umbral', () => {
const ops = [
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
oportunidad('2', { asesorIds: [102], etapa: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
oportunidad('3', { asesorIds: [103], etapa: 'Contactado', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactada
oportunidad('4', { etapa: 'Afluencia', updatedAt: '2026-08-01T12:00:00Z' }), // sin asesor → no aplica
oportunidad('5', { asesorIds: [101], etapa: 'Perdido', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
]
expect(oportunidadesSinContactar(ops, ahora, 48).map((o) => o.id)).toEqual(['1'])
})
it('una oportunidad con exactamente N horas aún no supera el umbral', () => {
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
})
it('ignora fechas inválidas', () => {
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })]
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
})
})
describe('claves de dedup', () => {
it('una clave por oportunidad', () => {
expect(claveEscalado('123')).toBe('escalado:123')
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
expect(claveVentaCreada('456')).toBe('venta-creada:456')
})
})
describe('esEtapa (detección de transición de etapa)', () => {
it('compara tolerando mayúsculas y espacios', () => {
expect(esEtapa('Aparto', 'APARTO')).toBe(true)
expect(esEtapa(' ganado ', 'Ganado')).toBe(true)
expect(esEtapa('Cotizador', 'Aparto')).toBe(false)
expect(esEtapa('', 'Ganado')).toBe(false)
})
})
describe('AutomatizacionesService.escaneoEscalado', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
const configAuto = config.automatizaciones as Record<string, unknown>
it('escala al asesor asignado cuando no hay coordinador configurado', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1)
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
expect(monday.actividades[0]).toMatchObject({
nombre: 'URGENTE: oportunidad sin contactar — Cliente 1',
responsableId: 102,
estadoLabel: 'Abierto',
leadItemId: '1',
})
expect(monday.updates[0]?.body).toContain('72 h')
expect(state.mapa.get('escalado:1')).toBeDefined()
expect(state.mapa.get('contador:escalados')).toBe('1')
} finally {
configAuto['coordinadorUserId'] = anterior
}
})
it('escala al coordinador cuando MONDAY_COORDINADOR_USER_ID está configurado', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 999
try {
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1)
expect(monday.actividades[0]?.responsableId).toBe(999)
expect(r.candidatos[0]?.responsableId).toBe(999)
} finally {
configAuto['coordinadorUserId'] = anterior
}
})
it('dedup: no vuelve a escalar una oportunidad ya escalada', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([], [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
const r1 = await svc.escaneoEscalado({ ahora })
expect(r1.escalados).toBe(1)
const r2 = await svc.escaneoEscalado({ ahora: new Date('2026-08-23T12:00:00Z') })
expect(r2.escalados).toBe(0)
expect(r2.candidatos[0]?.yaEscalado).toBe(true)
expect(monday.actividades).toHaveLength(1)
expect(state.mapa.get('contador:escalados')).toBe('1')
} finally {
configAuto['coordinadorUserId'] = anterior
}
})
it('un fallo de Monday en una oportunidad no detiene el escalado de las demás', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const monday = mondayMock([], [
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
])
vi.spyOn(monday, 'crearUpdateEnItem')
.mockRejectedValueOnce(new Error('Monday caído'))
.mockResolvedValue('upd-ok')
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1) // la segunda oportunidad sí se escaló
expect(r.candidatos).toHaveLength(2)
} finally {
configAuto['coordinadorUserId'] = anterior
spyError.mockRestore()
}
})
})
// ---------- Oportunidad → Aparto / Ganado ----------
describe('AutomatizacionesService.escaneoOportunidades', () => {
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.apartadosCreados).toEqual([{ oportunidadId: '10', nombre: 'Cliente 10', apartadoItemId: 'apt-1' }])
expect(monday.apartados[0]).toMatchObject({
clienteNombre: 'Cliente 10',
unidadCodigo: 'U-101',
estadoLabel: 'Pago Apartado',
})
expect(state.mapa.get('apartado-creado:10')).toBe('apt-1')
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
})
it('el Apartado hereda de la oportunidad: contacto, Precio Final y Vendedor (primer asesor)', async () => {
const monday = mondayMock([], [
oportunidad('10', { etapa: 'Aparto', telefono: '6641112233', email: 'c@c.com', asesorIds: [101, 102] }),
])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.apartadosCreados).toHaveLength(1)
expect(monday.apartados[0]).toMatchObject({
clienteNombre: 'Cliente 10',
unidadCodigo: 'U-101',
telefono: '6641112233',
email: 'c@c.com',
precioFinal: 2500000,
vendedorId: 101,
})
})
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.ventasCreadas).toEqual([{ oportunidadId: '11', nombre: 'Cliente 11', ventaItemId: 'vta-1' }])
expect(monday.ventas[0]).toMatchObject({ clienteNombre: 'Cliente 11', unidadCodigo: 'U-101', valorVenta: 2500000 })
expect(state.mapa.get('venta-creada:11')).toBe('vta-1')
})
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
await svc.escaneoOportunidades()
const r2 = await svc.escaneoOportunidades()
expect(r2.apartadosCreados).toEqual([])
expect(r2.ventasCreadas).toEqual([])
expect(monday.apartados).toHaveLength(1)
expect(monday.ventas).toHaveLength(1)
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
expect(state.mapa.get('contador:ventasCreadas')).toBe('1')
})
it('ignora oportunidades en otras etapas', async () => {
const monday = mondayMock([], [
oportunidad('10', { etapa: 'Cotizador' }),
oportunidad('11', { etapa: 'Perdido' }),
oportunidad('12', { etapa: 'Afluencia' }),
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.escaneoOportunidades()
expect(r.apartadosCreados).toEqual([])
expect(r.ventasCreadas).toEqual([])
expect(monday.apartados).toEqual([])
expect(monday.ventas).toEqual([])
})
it('un fallo de Monday en una oportunidad no detiene a las demás', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.errores).toHaveLength(1)
expect(r.errores[0]?.oportunidadId).toBe('10')
expect(r.ventasCreadas).toHaveLength(1) // la segunda oportunidad sí se procesó
expect(state.mapa.get('apartado-creado:10')).toBeUndefined() // sin dedup: se reintenta en la próxima pasada
})
})
describe('AutomatizacionesService.asegurarApartado (botón Aparto de Mi día)', () => {
it('dedup: si ya existe el apartado, lo devuelve sin tocar Monday', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const state = stateEnMemoria()
state.mapa.set('apartado-creado:10', 'apt-viejo')
const svc = crearServicio(monday, state)
const r = await svc.asegurarApartado('10', 3, 0)
expect(r).toBe('apt-viejo')
expect(monday.apartados).toEqual([])
})
it('crea el apartado en la primera pasada si la etapa ya se ve', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.asegurarApartado('10', 3, 0)
expect(r).toBe('apt-1')
expect(monday.apartados).toHaveLength(1)
})
it('reintenta cuando Monday aún no propaga la etapa (read-after-write)', async () => {
// La oportunidad se lee en 'Afluencia' en la primera pasada y en 'Aparto'
// en la segunda (así se comporta Monday ~1s después del cambio)
const op = oportunidad('10', { etapa: 'Afluencia' })
const oportunidades = [op]
const monday = mondayMock([], oportunidades)
const svc = crearServicio(monday, stateEnMemoria())
const original = monday.getOportunidades.bind(monday)
let llamadas = 0
monday.getOportunidades = async () => {
llamadas++
if (llamadas >= 2) op.etapa = 'Aparto'
return original()
}
const r = await svc.asegurarApartado('10', 3, 0)
expect(r).toBe('apt-1')
expect(llamadas).toBeGreaterThanOrEqual(2)
expect(monday.apartados).toHaveLength(1)
})
it('agota los reintentos y devuelve null si nunca se ve en Aparto', async () => {
const monday = mondayMock([], [oportunidad('10', { etapa: 'Cotizador' })])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.asegurarApartado('10', 2, 0)
expect(r).toBeNull()
expect(monday.apartados).toEqual([])
})
})

View File

@@ -2,68 +2,34 @@ import { config } from '../config/index.js'
import * as configService from './config.service.js'
import { AppError } from '../utils/errors.js'
import { actividadLocalService } from './actividad-local.service.js'
import {
mondayService,
type LeadMonday,
type MondayService,
type OportunidadMonday,
type UsuarioMonday,
} from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
import { enviarPush, usuarioIdPorMondayUserId } from './push.service.js'
import { enviarPush } from './push.service.js'
/**
* Automatizaciones sobre Monday:
* (a) asignación round-robin en AMBOS tableros del embudo: Leads (captura
* ligera, columna Vendedor, cualquier estado no terminal sin asignar) y
* Oportunidades (Alta Afluencia, columna Asesor, solo etapa 'Afluencia'
* sin asignar — las del cotizador nacen en 'Cotizador' y no entran)
* Automatizaciones sobre tablas locales:
* (a) asignación round-robin de leads locales (board LEADS, etapa NUEVO, sin
* registro en AsignacionLocal) al siguiente vendedor en rotación
* (b) recordatorio de oportunidades inactivas (> INACTIVIDAD_DIAS sin actualizarse)
* (c) escalado de afluencias asignadas sin contactar
* (d) Oportunidad → Apartado / Ventas al entrar a 'Aparto' / 'Ganado'
* (c) escalado de afluencias asignadas sin contactar (> LEAD_ESCALADO_HORAS horas)
* (d) push de apartados por vencer
*
* El estado (índice RR, cache de vendedores, recordatorios enviados) vive en la
* tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir
* recordatorios el mismo día.
* El estado (índice RR, recordatorios enviados) vive en la tabla clave-valor
* AutomatizacionState para sobrevivir reinicios y no repetir recordatorios.
*/
// Estados del tablero Leads en los que el lead ya no entra al reparto ni es
// trabajo activo (labels title case en el board; se comparan case-insensitive)
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] as const
// Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo
// activo del asesor (fuera de seguimiento).
// Etapas del embudo en las que la oportunidad ya no es trabajo activo del
// asesor (fuera de seguimiento).
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const
// Etapas tempranas que vigilan los escaneos de inactividad/escalado
export const ETAPAS_SEGUIMIENTO = ['AFLUENCIA', 'CONTACTADO', 'INTENTO DE CONTACTO'] as const
// Etapas en las que una oportunidad asignada aún no ha sido contactada de
// verdad; si permanece así más de LEAD_ESCALADO_HORAS horas se escala al
// coordinador.
export const ETAPAS_SIN_CONTACTAR = ['AFLUENCIA', 'INTENTO DE CONTACTO'] as const
// Etapas del tablero Oportunidades que disparan las reglas de ronda 2
export const ETAPA_APARTO = 'APARTO'
export const ETAPA_GANADO = 'GANADO'
const CLAVE_RR_INDICE = 'rr:indice'
const CLAVE_RR_VENDEDORES = 'rr:vendedores'
const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada'
/** Prefijo de la bandera "este lead lo asigna su capturista, no el round-robin" */
export const CLAVE_RR_SKIP = 'rr:skip:'
const CLAVE_INACTIVIDAD_ULTIMO_ESCANEO = 'inactividad:ultimoEscaneo'
const CLAVE_ESCALADO_ULTIMO_ESCANEO = 'escalado:ultimoEscaneo'
const CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO = 'oportunidades:ultimoEscaneo'
const CLAVE_CONTADOR_ESCALADOS = 'contador:escalados'
const CLAVE_CONTADOR_APARTADOS = 'contador:apartadosCreados'
const CLAVE_CONTADOR_VENTAS = 'contador:ventasCreadas'
const CLAVE_APARTADO_CREADO = 'apartado-creado'
const DIA_MS = 24 * 60 * 60 * 1000
const HORA_MS = 60 * 60 * 1000
/** TTL del cache de la lista de vendedores del equipo (1 h) */
const CACHE_VENDEDORES_MS = HORA_MS
/** Store clave-valor inyectable (Prisma en prod, Map en tests) */
export interface StateStore {
@@ -71,136 +37,23 @@ export interface StateStore {
set(clave: string, valor: string): Promise<void>
}
// ---------- Lógica pura (testeable sin red ni DB) ----------
/** Parsea MONDAY_RR_USER_IDS ("106735975,106735976") a la lista de vendedores */
export function parseRrUserIds(raw: string): UsuarioMonday[] {
return raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '')
.map((s) => {
const id = Number(s)
if (!Number.isInteger(id) || id <= 0) {
throw new AppError(500, `MONDAY_RR_USER_IDS contiene un ID inválido: "${s}"`)
}
return { id, nombre: `Usuario ${id}`, email: '' }
})
}
export function esEstadoTerminal(estado: string, terminales: readonly string[] = ETAPAS_TERMINALES_OPORTUNIDAD): boolean {
const normalizado = estado.trim().toUpperCase()
return terminales.some((t) => t.toUpperCase() === normalizado)
}
/** Vendedor al que le toca el turno `indice` (rotación circular) */
export function vendedorEnTurno(vendedores: readonly UsuarioMonday[], indice: number): UsuarioMonday | undefined {
if (vendedores.length === 0) return undefined
return vendedores[((indice % vendedores.length) + vendedores.length) % vendedores.length]
}
/**
* Oportunidades en etapa temprana (Afluencia / Contactado / Intento de
* contacto) CON asesor asignado cuya última actualización supera
* `diasInactividad` días respecto a `ahora`.
*/
export function oportunidadesInactivas(
oportunidades: readonly OportunidadMonday[],
ahora: Date,
diasInactividad: number,
): OportunidadMonday[] {
const limiteMs = diasInactividad * DIA_MS
return oportunidades.filter((op) => {
if (op.asesorIds.length === 0) return false
if (!ETAPAS_SEGUIMIENTO.some((e) => e === op.etapa.trim().toUpperCase())) return false
const t = Date.parse(op.updatedAt)
if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs
})
}
/** Clave de dedup: un recordatorio por oportunidad por día */
export function claveRecordatorio(itemId: string, fecha: Date): string {
return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}`
}
export function diasSinActividad(op: Pick<OportunidadMonday, 'updatedAt'>, ahora: Date): number {
return Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / DIA_MS)
}
/**
* Oportunidades ASIGNADAS (con asesor) que siguen en Afluencia /
* Intento de contacto y llevan más de `horasUmbral` horas sin actualizarse:
* candidatas a escalar. (El round-robin actualiza el item al asignar, así que
* el reloj arranca ahí.)
*/
export function oportunidadesSinContactar(
oportunidades: readonly OportunidadMonday[],
ahora: Date,
horasUmbral: number,
): OportunidadMonday[] {
const limiteMs = horasUmbral * HORA_MS
return oportunidades.filter((op) => {
if (op.asesorIds.length === 0) return false
if (!ETAPAS_SIN_CONTACTAR.some((e) => e === op.etapa.trim().toUpperCase())) return false
const t = Date.parse(op.updatedAt)
if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs
})
}
/** Clave de dedup del escalado: un escalado por lead (una sola vez) */
export function claveEscalado(itemId: string): string {
return `escalado:${itemId}`
}
/** Dedup: un item en Apartado por oportunidad que entra a 'Aparto' */
/** Clave de dedup del apartado local creado a partir de una oportunidad */
export function claveApartadoCreado(oportunidadId: string): string {
return `apartado-creado:${oportunidadId}`
}
/** Dedup: un item en Ventas por oportunidad que entra a 'Ganado' */
export function claveVentaCreada(oportunidadId: string): string {
return `venta-creada:${oportunidadId}`
}
/** Comparación de etapa tolerante a mayúsculas y espacios ('Aparto' === 'APARTO') */
export function esEtapa(etapa: string, esperada: string): boolean {
return etapa.trim().toUpperCase() === esperada.trim().toUpperCase()
return `${CLAVE_APARTADO_CREADO}:${oportunidadId}`
}
// ---------- Servicio ----------
type MondayLeads = Pick<
MondayService,
| 'getLeads'
| 'getOportunidades'
| 'asignarVendedorALead'
| 'asignarAsesorAOportunidad'
| 'crearUpdateEnItem'
| 'getUsuariosEquipo'
| 'crearActividad'
| 'crearApartado'
| 'crearVenta'
>
export interface OpcionesAutomatizaciones {
/** Crear actividades en el tablero Actividades al asignar/recordar (default: config) */
actividadesEnabled?: boolean
}
export interface AsignacionRR {
itemId: string
lead: string
vendedorId: number
/** Usuario del ecosistema al que se asignó el lead */
usuarioId: string
vendedor: string
/** Tablero del item asignado: 'leads' (Vendedor) u 'oportunidades' (Asesor) */
tablero: TableroRoundRobin
/** Tablero del item asignado (siempre 'leads' en el modo local) */
tablero: 'leads'
}
/** Tablero del embudo que cubre el round-robin / la asignación al capturista */
export type TableroRoundRobin = 'leads' | 'oportunidades'
export interface ResultadoRoundRobin {
asignados: number
detalle: AsignacionRR[]
@@ -227,7 +80,6 @@ export interface CandidatoEscalado {
lead: string
estado: string
horasSinContactar: number
responsableId: number | null // coordinador o vendedor asignado; null si no hay a quién asignar
yaEscalado: boolean
}
@@ -237,246 +89,14 @@ export interface ResultadoEscalado {
escalados: number
}
export interface ResultadoOportunidades {
apartadosCreados: { oportunidadId: string; nombre: string; apartadoItemId: string }[]
ventasCreadas: { oportunidadId: string; nombre: string; ventaItemId: string }[]
errores: { oportunidadId: string; mensaje: string }[]
/** Vendedor local en la rotación del round-robin */
interface VendedorLocal {
usuarioId: string
nombre: string
}
export class AutomatizacionesService {
private readonly actividadesEnabled: boolean
constructor(
private readonly monday: MondayLeads,
private readonly state: StateStore,
opciones: OpcionesAutomatizaciones = {},
) {
this.actividadesEnabled = opciones.actividadesEnabled ?? config.automatizaciones.actividadesEnabled
}
/** 'YYYY-MM-DD' de hoy + N días (fecha compromiso de la actividad) */
private static fechaHoyMas(dias: number): string {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/**
* Crea una actividad en Monday sin interrumpir la automatización:
* un fallo aquí solo se loguea (la asignación/recordatorio ya se hizo).
*/
private async crearActividadSegura(datos: {
nombre: string
responsableId: number
leadItemId: string
}): Promise<void> {
try {
await this.monday.crearActividad({
nombre: datos.nombre,
tipoLabel: 'Llamada',
responsableId: datos.responsableId,
fecha: AutomatizacionesService.fechaHoyMas(1),
estadoLabel: 'Abierto',
leadItemId: datos.leadItemId,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear la actividad "${datos.nombre}":`, err)
}
}
/**
* Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida
* (override manual); si no, los miembros del equipo de Monday MONDAY_RR_TEAM
* (default "vendedores"). El cache dura 1 h: altas/bajas del equipo se
* reflejan solas sin redeploy.
*/
async obtenerVendedores(): Promise<UsuarioMonday[]> {
if (config.automatizaciones.rrUserIds.trim() !== '') {
return parseRrUserIds(config.automatizaciones.rrUserIds)
}
const cacheado = await this.state.get(CLAVE_RR_VENDEDORES)
if (cacheado) {
try {
const parsed = JSON.parse(cacheado) as { ts?: number; usuarios?: UsuarioMonday[] }
if (parsed.ts !== undefined && parsed.usuarios !== undefined && Date.now() - parsed.ts < CACHE_VENDEDORES_MS) {
return parsed.usuarios
}
} catch {
// formato viejo (arreglo sin ts) → re-consulta abajo
}
}
const usuarios = await this.monday.getUsuariosEquipo(config.automatizaciones.rrEquipo)
if (usuarios.length === 0) {
throw new AppError(502, `El equipo "${config.automatizaciones.rrEquipo}" no tiene usuarios asignables`)
}
await this.state.set(CLAVE_RR_VENDEDORES, JSON.stringify({ ts: Date.now(), usuarios }))
return usuarios
}
/**
* Una pasada de round-robin sobre AMBOS tableros del embudo:
* - Leads: todo lead sin Vendedor asignado y con estado no terminal
* (Descartado/Calificado/Abandono quedan fuera).
* - Oportunidades: solo etapa 'Afluencia' sin Asesor (las creadas por el
* cotizador nacen en 'Cotizador' y NO entran al reparto).
* Si se pasa `soloItemId`, solo se evalúa ese item, y `board` limita la
* pasada a un tablero (rutas de los webhooks create_item).
*/
async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise<ResultadoRoundRobin> {
// Round-robin deshabilitado vía config (default false en seed)
if (soloItemId === undefined && configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
return { asignados: 0, detalle: [] }
}
// Modo local: Monday caído → round-robin sobre tablas locales
if (!saludMonday.estaDisponible() && soloItemId === undefined) {
return this.runRoundRobinLocal()
}
interface Candidato {
id: string
nombre: string
tablero: TableroRoundRobin
}
const candidatos: Candidato[] = []
if (board === undefined || board === 'leads') {
const leads: LeadMonday[] = await this.monday.getLeads()
for (const l of leads) {
if (l.vendedorIds.length > 0) continue
if (esEstadoTerminal(l.estado, ESTADOS_TERMINALES_LEAD)) continue
if (soloItemId !== undefined && l.id !== soloItemId) continue
candidatos.push({ id: l.id, nombre: l.nombre, tablero: 'leads' })
}
}
if (board === undefined || board === 'oportunidades') {
const oportunidades = await this.monday.getOportunidades()
for (const o of oportunidades) {
if (o.asesorIds.length > 0) continue
if (o.etapa.trim().toUpperCase() !== 'AFLUENCIA') continue
if (soloItemId !== undefined && o.id !== soloItemId) continue
candidatos.push({ id: o.id, nombre: o.nombre, tablero: 'oportunidades' })
}
}
// Los items capturados desde una app con sesión se asignan a su capturista
// (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday
// hacía que el webhook los viera sin persona asignada y pisara la asignación.
const pendientes: Candidato[] = []
for (const c of candidatos) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${c.id}`)) !== 'capturista') pendientes.push(c)
}
if (pendientes.length === 0) {
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
return { asignados: 0, detalle: [] }
}
const vendedores = await this.obtenerVendedores()
let indice = Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0')
if (!Number.isInteger(indice) || indice < 0) indice = 0
const detalle: AsignacionRR[] = []
for (const c of pendientes) {
const vendedor = vendedorEnTurno(vendedores, indice)
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
if (c.tablero === 'leads') await this.monday.asignarVendedorALead(c.id, vendedor.id)
else await this.monday.asignarAsesorAOportunidad(c.id, vendedor.id)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${c.nombre}`,
responsableId: vendedor.id,
leadItemId: c.id,
})
}
// Push notification al vendedor asignado
const usuarioId = await usuarioIdPorMondayUserId(vendedor.id)
if (usuarioId) {
void enviarPush(usuarioId, 'Nuevo lead asignado', `${c.nombre} — ${c.tablero}`, `/hoy/`)
}
detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero })
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 }
}
/**
* Asigna el item a quien lo capturó desde una app del ecosistema (su usuario
* Monday vinculado) y le crea la actividad de contacto — el mismo paquete
* que haría el round-robin, pero para el capturista. `board` decide la
* columna: Vendedor en Leads, Asesor en Oportunidades. No hay carrera con el
* webhook create_item: runRoundRobin solo toma items sin persona asignada.
*/
async asignarACapturista(
itemId: string,
nombre: string,
vendedorId: number,
board: TableroRoundRobin = 'oportunidades',
): Promise<void> {
if (board === 'leads') await this.monday.asignarVendedorALead(itemId, vendedorId)
else await this.monday.asignarAsesorAOportunidad(itemId, vendedorId)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${nombre}`,
responsableId: vendedorId,
leadItemId: itemId,
})
}
}
/**
* Escaneo de inactividad. Con `preview: true` no escribe nada en Monday ni en
* 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()
const inactivas = oportunidadesInactivas(oportunidades, ahora, diasUmbral)
const candidatos: CandidatoInactividad[] = []
let recordatoriosEnviados = 0
for (const op of inactivas) {
const clave = claveRecordatorio(op.id, ahora)
const yaEnviado = (await this.state.get(clave)) !== null
const dias = diasSinActividad(op, ahora)
candidatos.push({
itemId: op.id,
lead: op.nombre,
estado: op.etapa,
updatedAt: op.updatedAt,
diasInactivo: dias,
yaRecordadoHoy: yaEnviado,
})
if (opciones.preview || yaEnviado) continue
const body =
`⚠️ Recordatorio automático: esta oportunidad lleva ${dias} días sin actualizarse ` +
`(última actividad: ${op.updatedAt.slice(0, 10)}). ` +
`Favor de darle seguimiento o marcarla como Perdido.`
await this.monday.crearUpdateEnItem(op.id, body)
await this.state.set(clave, ahora.toISOString())
recordatoriosEnviados++
// Además del update, deja una actividad al asesor asignado (si tiene)
const asesorId = op.asesorIds[0]
if (this.actividadesEnabled && asesorId !== undefined) {
await this.crearActividadSegura({
nombre: `Retomar contacto con ${op.nombre}`,
responsableId: asesorId,
leadItemId: op.id,
})
}
}
if (!opciones.preview) await this.state.set(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO, ahora.toISOString())
return { preview: opciones.preview, diasUmbral, candidatos, recordatoriosEnviados }
}
constructor(private readonly state: StateStore) {}
/** Suma `n` a un contador acumulado en el estado (para el endpoint admin) */
private async incrementarContador(clave: string, n: number): Promise<void> {
@@ -485,173 +105,6 @@ export class AutomatizacionesService {
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
}
/** Responsable del escalado: coordinador (env) o, si no hay, el asesor asignado */
private responsableEscalado(op: OportunidadMonday): number | null {
const coordinador = config.automatizaciones.coordinadorUserId
if (Number.isInteger(coordinador) && coordinador > 0) return coordinador
return op.asesorIds[0] ?? null
}
/**
* Escalado de afluencias sin contactar: una oportunidad asignada que sigue en
* Afluencia / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
* actividad URGENTE al coordinador (o al asesor) + update en el item.
* Un escalado por oportunidad (dedup `escalado:<itemId>`); un fallo de Monday
* 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()
const pendientes = oportunidadesSinContactar(oportunidades, ahora, horasUmbral)
const candidatos: CandidatoEscalado[] = []
let escalados = 0
for (const op of pendientes) {
const responsableId = this.responsableEscalado(op)
const horas = Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / HORA_MS)
const yaEscalado = (await this.state.get(claveEscalado(op.id))) !== null
candidatos.push({
itemId: op.id,
lead: op.nombre,
estado: op.etapa,
horasSinContactar: horas,
responsableId,
yaEscalado,
})
if (yaEscalado) continue
try {
if (this.actividadesEnabled && responsableId !== null) {
await this.monday.crearActividad({
nombre: `URGENTE: oportunidad sin contactar — ${op.nombre}`,
tipoLabel: 'Llamada',
responsableId,
fecha: AutomatizacionesService.fechaHoyMas(0),
estadoLabel: 'Abierto',
leadItemId: op.id,
})
}
await this.monday.crearUpdateEnItem(
op.id,
`🚨 Escalado automático: la oportunidad lleva ${horas} h asignada en etapa "${op.etapa}" sin contacto. ` +
`Se notificó a ${responsableId !== null && responsableId !== op.asesorIds[0] ? 'la coordinación' : 'el asesor asignado'}.`,
)
await this.state.set(claveEscalado(op.id), ahora.toISOString())
escalados++
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[escalado] falló el escalado de la oportunidad ${op.id} (${op.nombre}):`, err)
}
}
await this.incrementarContador(CLAVE_CONTADOR_ESCALADOS, escalados)
await this.state.set(CLAVE_ESCALADO_ULTIMO_ESCANEO, ahora.toISOString())
return { horasUmbral, candidatos, escalados }
}
/**
* Oportunidades → Apartado / Ventas (polling):
* - etapa 'Aparto' sin item en Apartado → crea el apartado (estado inicial
* 'Pago Apartado', vigencia de 7 días). Dedup `apartado-creado:<id>`.
* - etapa 'Ganado' sin item en Ventas → crea la venta con unidad y valor.
* Dedup `venta-creada:<id>`.
* Un fallo de Monday en una oportunidad no detiene a las demás.
*/
async escaneoOportunidades(): Promise<ResultadoOportunidades> {
const oportunidades = await this.monday.getOportunidades()
const resultado: ResultadoOportunidades = { apartadosCreados: [], ventasCreadas: [], errores: [] }
for (const op of oportunidades) {
if (esEtapa(op.etapa, ETAPA_APARTO) && (await this.state.get(claveApartadoCreado(op.id))) === null) {
try {
// El Apartado hereda lo que la oportunidad ya trae (contacto, precio,
// asesor como Vendedor); lo que no exista se omite
const apartadoItemId = await this.monday.crearApartado({
clienteNombre: op.nombre,
unidadCodigo: op.unidadCodigo,
fechaApartado: AutomatizacionesService.fechaHoyMas(0),
fechaLimite: AutomatizacionesService.fechaHoyMas(7),
estadoLabel: 'Pago Apartado',
telefono: op.telefono !== '' ? op.telefono : undefined,
email: op.email !== '' ? op.email : undefined,
precioFinal: op.precioFinal > 0 ? op.precioFinal : undefined,
vendedorId: op.asesorIds[0],
})
await this.state.set(claveApartadoCreado(op.id), apartadoItemId)
resultado.apartadosCreados.push({ oportunidadId: op.id, nombre: op.nombre, apartadoItemId })
// Crear LeadCaptura local para el apartado (Mi día los necesita)
try {
// Buscar el usuarioId de la oportunidad original para vincular el apartado
const opLocal = await prisma.leadCaptura.findFirst({ where: { leadId: op.id } })
await prisma.leadCaptura.create({
data: {
leadId: apartadoItemId,
board: 'APARTADO',
etapa: 'Pago Apartado',
nombreLead: op.nombre,
telefono: op.telefono || null,
email: op.email || null,
usuarioId: opLocal?.usuarioId ?? 'sistema',
oportunidadId: op.id,
mondayItemId: apartadoItemId,
pendienteSync: false,
},
})
} catch (e) { console.error('[APARTADO-ERR]', e instanceof Error ? e.message.slice(0, 150) : e) }
} catch (err) {
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
}
continue // una oportunidad no puede estar en dos etapas a la vez
}
if (esEtapa(op.etapa, ETAPA_GANADO) && (await this.state.get(claveVentaCreada(op.id))) === null) {
try {
const ventaItemId = await this.monday.crearVenta({
clienteNombre: op.nombre,
...(op.unidadCodigo !== '' ? { unidadCodigo: op.unidadCodigo } : {}),
...(op.precioFinal > 0 ? { valorVenta: op.precioFinal } : {}),
})
await this.state.set(claveVentaCreada(op.id), ventaItemId)
resultado.ventasCreadas.push({ oportunidadId: op.id, nombre: op.nombre, ventaItemId })
} catch (err) {
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
}
}
}
await this.incrementarContador(CLAVE_CONTADOR_APARTADOS, resultado.apartadosCreados.length)
await this.incrementarContador(CLAVE_CONTADOR_VENTAS, resultado.ventasCreadas.length)
await this.state.set(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO, new Date().toISOString())
return resultado
}
/**
* Garantiza el apartado de UNA oportunidad que acaba de pasar a 'Aparto'
* (botón de Mi día). Monday tarda ~1s en propagar el cambio de etapa
* (read-after-write), así que el escaneo se repite unas veces hasta que la
* oportunidad se lea en 'Aparto' o el dedup confirme la creación. Devuelve
* el apartadoItemId o null si no se pudo.
*/
async asegurarApartado(oportunidadId: string, reintentos = 3, esperaMs = 1200): Promise<string | null> {
const previo = await this.state.get(claveApartadoCreado(oportunidadId))
if (previo !== null) return previo
for (let intento = 0; intento < reintentos; intento++) {
const r = await this.escaneoOportunidades()
const creado = r.apartadosCreados.find((a) => a.oportunidadId === oportunidadId)
if (creado) return creado.apartadoItemId
const tras = await this.state.get(claveApartadoCreado(oportunidadId))
if (tras !== null) return tras
if (intento < reintentos - 1) await new Promise((res) => setTimeout(res, esperaMs))
}
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.
@@ -678,31 +131,12 @@ export class AutomatizacionesService {
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
}))
}
}
// Vendedores locales activos
const usuarios = await prisma.usuario.findMany({
where: { rol: 'vendedor', activo: true },
select: { id: true, nombre: true },
})
const vendedores: VendedorLocal[] = usuarios.map((u) => ({ usuarioId: u.id, nombre: u.nombre }))
if (vendedores.length === 0) return { asignados: 0, detalle: [] }
let indice = Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0')
@@ -710,56 +144,33 @@ export class AutomatizacionesService {
const detalle: AsignacionRR[] = []
for (const lead of candidatos) {
const vendedorTurno = vendedorEnTurno(vendedores, indice)
const vendedorTurno = vendedores[((indice % vendedores.length) + vendedores.length) % vendedores.length]
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) {
// Push notification al vendedor asignado
void enviarPush(usuarioLocalId, 'Nuevo lead asignado', lead.nombreLead, '/hoy/')
await prisma.asignacionLocal.create({
data: {
leadCapturaId: lead.id,
usuarioId: usuarioLocalId,
asignadoPor: 'round-robin',
},
})
await actividadLocalService.crearActividad({
titulo: `Contactar a ${lead.nombreLead}`,
usuarioId: usuarioLocalId,
void enviarPush(vendedorTurno.usuarioId, 'Nuevo lead asignado', lead.nombreLead, '/hoy/')
await prisma.asignacionLocal.create({
data: {
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 },
})
}
usuarioId: vendedorTurno.usuarioId,
asignadoPor: 'round-robin',
},
})
await actividadLocalService.crearActividad({
titulo: `Contactar a ${lead.nombreLead}`,
usuarioId: vendedorTurno.usuarioId,
leadCapturaId: lead.id,
fechaVencimiento: new Date(Date.now() + 48 * 60 * 60 * 1000),
tipo: 'SEGUIMIENTO',
})
await prisma.leadCaptura.update({
where: { id: lead.id },
data: { usuarioId: vendedorTurno.usuarioId },
})
detalle.push({
itemId: lead.leadId,
lead: lead.nombreLead,
vendedorId: vendedorTurno.id,
usuarioId: vendedorTurno.usuarioId,
vendedor: vendedorTurno.nombre,
tablero: 'leads',
})
@@ -867,7 +278,6 @@ export class AutomatizacionesService {
lead: op.nombreLead,
estado: op.etapa,
horasSinContactar: horas,
responsableId: null, // se resuelve abajo
yaEscalado: tieneActividadUrgente !== null,
})
@@ -882,7 +292,7 @@ export class AutomatizacionesService {
fechaVencimiento: ahora,
tipo: 'SEGUIMIENTO',
})
await this.state.set(claveEscalado(op.leadId), ahora.toISOString())
await this.state.set(`escalado:${op.leadId}`, ahora.toISOString())
escalados++
} catch (err) {
// eslint-disable-next-line no-console
@@ -932,16 +342,9 @@ export class AutomatizacionesService {
/** Estado actual para el endpoint admin */
async estado(): Promise<Record<string, unknown>> {
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
return {
roundRobin: {
indiceActual: Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0'),
vendedoresDeEnv: config.automatizaciones.rrUserIds.trim() !== '',
vendedores: config.automatizaciones.rrUserIds.trim() !== ''
? parseRrUserIds(config.automatizaciones.rrUserIds)
: vendedoresCache
? (JSON.parse(vendedoresCache) as UsuarioMonday[])
: [],
ultimaPasada: await this.state.get(CLAVE_RR_ULTIMA_PASADA),
},
inactividad: {
@@ -950,15 +353,9 @@ export class AutomatizacionesService {
},
escalado: {
horasUmbral: config.automatizaciones.escaladoHoras,
coordinadorUserId: config.automatizaciones.coordinadorUserId > 0 ? config.automatizaciones.coordinadorUserId : null,
totalEscalados: Number((await this.state.get(CLAVE_CONTADOR_ESCALADOS)) ?? '0'),
ultimoEscaneo: await this.state.get(CLAVE_ESCALADO_ULTIMO_ESCANEO),
},
oportunidades: {
apartadosCreados: Number((await this.state.get(CLAVE_CONTADOR_APARTADOS)) ?? '0'),
ventasCreadas: Number((await this.state.get(CLAVE_CONTADOR_VENTAS)) ?? '0'),
ultimoEscaneo: await this.state.get(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO),
},
crons: {
habilitados: config.automatizaciones.enabled,
pollCron: config.automatizaciones.pollCron,
@@ -980,4 +377,4 @@ export const prismaStateStore: StateStore = {
}
/** Singleton usado por controllers y crons (los tests instancian el suyo) */
export const automatizacionesService = new AutomatizacionesService(mondayService, prismaStateStore)
export const automatizacionesService = new AutomatizacionesService(prismaStateStore)

View File

@@ -44,7 +44,7 @@ export interface PlanPago {
mensualidad: number
tasaMoraMensual?: number
status: StatusPlan
/** Item del tablero Apartado (Monday) ligado al plan (puente a postventa) */
/** Item del apartado ligado al plan (puente a postventa) */
mondayItemId?: string | null
/** Vendedor dueño del plan (sub del JWT); null en planes históricos */
vendedorId?: string | null

View File

@@ -1,49 +1,5 @@
import { describe, expect, it } from 'vitest'
import { claveTelefono, detalleContacto } from './contactos-detalle.service.js'
import type { LeadMonday, OportunidadMonday, ApartadoMonday } from './monday.service.js'
const MI_ID = 106736727
const leadBase: LeadMonday = {
id: 'l1', nombre: 'Ana Demo', createdAt: '', updatedAt: '', estado: 'Nuevo', origen: 'PASE',
telefono: '+52 6641234567', email: 'ana@correo.com', vendedorIds: [MI_ID],
}
const opBase: OportunidadMonday = {
id: 'o1', nombre: 'Ana Demo', createdAt: '', updatedAt: '', etapa: 'Cotizador', unidadCodigo: '',
precioFinal: 1500000, asesorIds: [MI_ID], telefono: '6641234567', email: '', origen: 'PASE',
desarrollo: 'SENDA', modeloInteres: 'SOHO',
}
const apBase: ApartadoMonday = {
id: 'a1', nombre: 'Ana Demo', unidadCodigo: 'SENDA-409-1-2', fechaLimite: '2026-09-10',
estado: 'Pago Apartado', vendedorIds: [MI_ID], telefono: '6641234567', email: '',
precioFinal: 2646000, fechaCompra: '2026-09-01',
}
function mondayMock(overrides: Record<string, unknown> = {}) {
return {
async getContactoDetalle() {
return { nombre: 'Ana Demo', Teléfono: '+52 6641234567', Email: 'ana@correo.com', Estado: 'CLIENTE', CURP: 'XAXX010101XXXXXXX0' }
},
async getLeads() { return [leadBase] },
async getOportunidades() { return [opBase] },
async getApartados() { return [apBase] },
async getHistorialItems(_b: string, ids: string[]) {
return ids.map((id, i) => ({
itemId: id,
fechaISO: `2026-09-0${(i % 9) + 1}T10:00:00.000Z`,
usuarioId: MI_ID,
evento: 'update_column_value',
}))
},
async getUnidad() { return { desarrollo: 'SENDA' } },
...overrides,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
const nombres = new Map([[MI_ID, 'Martin Zavala']])
import { claveTelefono } from './contactos-detalle.service.js'
describe('claveTelefono', () => {
it('últimos 10 dígitos, tolerante a lada, espacios y símbolos', () => {
@@ -54,69 +10,3 @@ describe('claveTelefono', () => {
expect(claveTelefono('123')).toBe('')
})
})
describe('detalleContacto (GET /api/contactos/:id/detalle)', () => {
it('arma contacto + propiedad + relaciones + historial con nombres resueltos', async () => {
const d = await detalleContacto(
mondayMock(),
'c1',
nombres,
[{ leadId: 'l1', nombreLead: 'Ana Demo', createdAt: new Date('2026-09-02T08:00:00Z'), usuario: { nombre: 'Martin Zavala' } }],
{
id: 'cl1', nombreCompleto: 'Ana Demo', proyecto: 'SENDA', unidadCodigo: 'SENDA-409-1-2',
createdAt: new Date('2026-09-03T08:00:00Z'), capturadoPor: { nombre: 'Martin Zavala' },
},
)
expect(d).not.toBeNull()
// El contacto del detalle se devuelve con campos nombrados del modelo local
expect(d!.contacto['curp']).toBe('XAXX010101XXXXXXX0')
// Propiedad comprada (apartado relacionado, enriquecido con el desarrollo)
expect(d!.propiedad).toMatchObject({
unidadCodigo: 'SENDA-409-1-2',
desarrollo: 'SENDA',
estado: 'Pago Apartado',
precioFinal: 2646000,
atendidoPor: 'Martin Zavala',
})
// Relaciones por teléfono/email con quién atiende
expect(d!.relaciones.leads[0]).toMatchObject({ id: 'l1', atendidoPor: 'Martin Zavala' })
expect(d!.relaciones.oportunidades[0]).toMatchObject({ id: 'o1', atendidoPor: 'Martin Zavala' })
expect(d!.relaciones.apartados[0]).toMatchObject({ id: 'a1', atendidoPor: 'Martin Zavala' })
// Historial: activity log + captura + alta local, ordenado desc
expect(d!.historial.some((h) => h.fuente === 'App' && h.detalle.includes('Captura'))).toBe(true)
expect(d!.historial.some((h) => h.detalle.includes('Alta de cliente'))).toBe(true)
expect(d!.historial.every((h) => h.usuario === 'Martin Zavala')).toBe(true)
const fechas = d!.historial.map((h) => h.fechaISO)
expect(fechas).toEqual([...fechas].sort((a, b) => b.localeCompare(a)))
})
it('sin relaciones ni registros locales: listas vacías y propiedad null', async () => {
const d = await detalleContacto(
mondayMock({
async getLeads() { return [] },
async getOportunidades() { return [] },
async getApartados() { return [] },
}),
'c1',
new Map(),
[],
null,
)
expect(d!.propiedad).toBeNull()
expect(d!.relaciones).toEqual({ leads: [], oportunidades: [], apartados: [] })
// El historial al menos tiene los eventos del propio contacto
expect(d!.historial).toHaveLength(1)
expect(d!.historial[0]?.fuente).toBe('Contactos')
})
it('contacto inexistente devuelve null', async () => {
const d = await detalleContacto(
mondayMock({ async getContactoDetalle() { return null } }),
'no-existe',
new Map(),
[],
null,
)
expect(d).toBeNull()
})
})

View File

@@ -1,38 +1,29 @@
import { prisma } from './prisma.js'
import { calcularScore } from './lead-scoring.service.js'
import { config } from '../config/index.js'
import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js'
import type { MondayService } from './monday.service.js'
/**
* Detalle del módulo Contactos: toda la información del contacto, la propiedad
* comprada (apartado relacionado), quién lo atiende (asesor/vendedor actual de
* cada item relacionado) y el historial completo de atención.
*
* Las relaciones se arman por teléfono (últimos 10 dígitos) o email a través de
* los tableros Leads, Oportunidades y Apartado; el historial mezcla el activity
* log de Monday de esos items + el registro local (quién capturó qué en las apps).
* Perfil Completo del Contacto: datos locales del cliente/lead, historial de
* pagos, cotizaciones, documentos, lead score y última actividad.
*/
export interface RelacionCrm {
id: string
nombre: string
estado: string // estado/etapa actual
/** Quién lo atiende ahora (columna people del tablero); null si sin asignar */
/** Quién lo atiende ahora; null si sin asignar */
atendidoPor: string | null
detalle: string // origen/desarrollo/unidad según el tablero ('' si no aplica)
}
export interface EventoHistorialContacto {
fechaISO: string
usuario: string | null // nombre resuelto; null si fue automatización o desconocido
usuario: string | null
detalle: string
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
fuente: string
}
export interface DetalleContacto {
contacto: Record<string, string> // campos nombrados del modelo local (nombre, telefono, email, …)
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
contacto: Record<string, string>
propiedad: {
id: string
unidadCodigo: string
@@ -57,173 +48,6 @@ export function claveTelefono(telefono: string): string {
return digitos.length >= 10 ? digitos.slice(-10) : ''
}
/**
* Mapeo título de columna del board Contactos (Monday) → campo nombrado del
* modelo local. Los títulos sin entrada se conservan tal cual.
*/
const CAMPOS_CONTACTO: Record<string, string> = {
nombre: 'nombre',
'Teléfono': 'telefono',
Email: 'email',
Estado: 'estado',
CURP: 'curp',
RFC: 'rfc',
'Estado Civil': 'estadoCivil',
'Fecha Nacimiento': 'fechaNacimiento',
'Actividad Economica': 'actividadEconomica',
'Dirección': 'direccion',
}
/** Normaliza un record por título de columna a campos nombrados del modelo local */
export function normalizarCamposContacto(contacto: Record<string, string>): Record<string, string> {
const normalizado: Record<string, string> = {}
for (const [titulo, valor] of Object.entries(contacto)) {
normalizado[CAMPOS_CONTACTO[titulo] ?? titulo] = valor
}
return normalizado
}
type MondayDetalle = Pick<
MondayService,
| 'getContactoDetalle'
| 'getLeads'
| 'getOportunidades'
| 'getApartados'
| 'getHistorialItems'
| 'getUnidad'
>
export interface CapturaLocal {
leadId: string
nombreLead: string
createdAt: Date
usuario: { nombre: string } | null
}
export interface ClienteLocal {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
createdAt: Date
capturadoPor: { nombre: string } | null
_count?: { documentos: number }
}
export async function detalleContacto(
monday: MondayDetalle,
contactoId: string,
nombresPorMondayId: Map<number, string>,
capturas: CapturaLocal[],
clienteLocal: ClienteLocal | null,
): Promise<DetalleContacto | null> {
const contacto = await monday.getContactoDetalle(contactoId)
if (contacto === null) return null
const nombre = (id: number | null): string | null =>
id !== null ? (nombresPorMondayId.get(id) ?? null) : null
const tel = claveTelefono(contacto['Teléfono'] ?? '')
const email = (contacto['Email'] ?? '').trim().toLowerCase()
const coincide = (t: string, e: string): boolean =>
(tel !== '' && claveTelefono(t) === tel) || (email !== '' && e.trim().toLowerCase() === email)
const [leads, oportunidades, apartados] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
monday.getApartados(),
])
const relLeads = leads.filter((l) => coincide(l.telefono, l.email))
const relOportunidades = oportunidades.filter((o) => coincide(o.telefono, o.email))
const relApartados = apartados.filter((a) => coincide(a.telefono ?? '', a.email ?? ''))
// Propiedad: el apartado relacionado "vivo" (no final) o el último del board;
// se enriquece con el desarrollo de la unidad (inventario)
let propiedad: DetalleContacto['propiedad'] = null
const aptoReciente = relApartados.find((a) => !esEstadoFinalApartado(a.estado)) ?? relApartados[relApartados.length - 1]
if (aptoReciente) {
let desarrollo = ''
if (aptoReciente.unidadCodigo !== '') {
try {
desarrollo = (await monday.getUnidad(aptoReciente.unidadCodigo))?.desarrollo ?? ''
} catch {
desarrollo = ''
}
}
propiedad = {
id: aptoReciente.id,
unidadCodigo: aptoReciente.unidadCodigo,
desarrollo,
estado: aptoReciente.estado,
precioFinal: aptoReciente.precioFinal ?? 0,
fechaCompra: aptoReciente.fechaCompra ?? '',
fechaLimite: aptoReciente.fechaLimite,
atendidoPor: nombre(aptoReciente.vendedorIds[0] ?? null),
}
}
// Historial: activity log de los items relacionados + del propio contacto,
// más los registros locales de captura/alta (Monday no guarda quién capturó)
const [evContactos, evLeads, evOportunidades, evApartado] = await Promise.all([
monday.getHistorialItems(config.monday.boards.contactos, [contactoId]),
monday.getHistorialItems(config.monday.boards.leads, relLeads.map((l) => l.id)),
monday.getHistorialItems(config.monday.boards.oportunidades, relOportunidades.map((o) => o.id)),
monday.getHistorialItems(config.monday.boards.apartado, relApartados.map((a) => a.id)),
])
const historial: EventoHistorialContacto[] = [
...evContactos.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Contactos' })),
...evLeads.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Leads' })),
...evOportunidades.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Oportunidades' })),
...evApartado.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Apartado' })),
...capturas.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: c.usuario?.nombre ?? null,
detalle: `Captura en la app (${c.nombreLead})`,
fuente: 'App',
})),
...(clienteLocal
? [{
fechaISO: clienteLocal.createdAt.toISOString(),
usuario: clienteLocal.capturadoPor?.nombre ?? null,
detalle: `Alta de cliente registrada${clienteLocal.unidadCodigo ? ` (${clienteLocal.unidadCodigo})` : ''}`,
fuente: 'App',
}]
: []),
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
return {
contacto: normalizarCamposContacto(contacto),
propiedad,
relaciones: {
leads: relLeads.map((l) => ({
id: l.id,
nombre: l.nombre,
estado: l.estado,
atendidoPor: nombre(l.vendedorIds[0] ?? null),
detalle: l.origen,
})),
oportunidades: relOportunidades.map((o) => ({
id: o.id,
nombre: o.nombre,
estado: o.etapa,
atendidoPor: nombre(o.asesorIds[0] ?? null),
detalle: [o.desarrollo, o.modeloInteres].filter((p) => p).join(' · '),
})),
apartados: relApartados.map((a) => ({
id: a.id,
nombre: a.nombre,
estado: a.estado,
atendidoPor: nombre(a.vendedorIds[0] ?? null),
detalle: a.unidadCodigo,
})),
},
historial,
}
}
// ── Perfil Completo del Contacto (modo local) ─────────────────────────
export interface PerfilCompletoContacto {
@@ -267,7 +91,7 @@ export interface PerfilCompletoContacto {
export async function perfilCompleto(contactoId: string): Promise<PerfilCompletoContacto | null> {
// Buscar el cliente
const cliente = await prisma.cliente.findFirst({
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] },
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] }, // mondayContactoId: compatibilidad de datos
})
// Buscar la captura del lead

View File

@@ -77,7 +77,7 @@ beforeEach(() => {
})
describe('listarContactosLocal', () => {
it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con la forma de ContactoMonday + fuente local', async () => {
it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con fuente local', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567', email: 'juan@correo.mx', proyecto: 'SENDA' }),
])

View File

@@ -1,17 +1,22 @@
import type { ContactoMonday } from './monday.service.js'
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
import { prisma } from './prisma.js'
/**
* Fallback de LECTURA del módulo Contactos (modo local): cuando Monday está
* caído, la lista y el detalle se arman desde las tablas locales (Cliente +
* LeadCaptura + Documento + Cotizacion) con la MISMA forma de DTO que el
* camino Monday. Lo que no existe en local (activity log, columnas del board)
* va vacío; el historial se deriva de las capturas/altas/cotizaciones locales.
* Módulo Contactos 100% local: la lista y el detalle se arman desde las
* tablas locales (Cliente + LeadCaptura + Documento + Cotizacion). El
* historial se deriva de las capturas/altas/cotizaciones locales.
*/
/** Contacto de la lista con flag aditivo de procedencia (el front lo ignora o lo muestra) */
export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' }
export interface ContactoListaLocal {
id: string
nombre: string
telefono: string
email: string
estado: string
updatedAt: string
fuente?: 'local'
}
/**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
@@ -72,10 +77,10 @@ export async function listarContactosLocal(usuarioId?: string): Promise<Contacto
/**
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
* Monday sombra) o mondayContactoId. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email sobre LeadCaptura, igual que el camino Monday;
* el historial se deriva de capturas + alta + documentos + cotizaciones
* locales (no hay activity log en local). null si no existe en ninguna tabla.
* heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se
* arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el
* historial se deriva de capturas + alta + documentos + cotizaciones locales.
* null si no existe en ninguna tabla.
*/
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
let cliente = await prisma.cliente.findFirst({

View File

@@ -15,9 +15,6 @@ const prismaMock = vi.hoisted(() => ({
create: vi.fn(),
findUnique: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
@@ -34,19 +31,6 @@ import { CrmLocalService, esIdLocal } from './crm-local.service.js'
const svc = new CrmLocalService()
/** Tipos de sync encolados, en orden, a partir de las llamadas al mock */
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
function payloadEncolado(tipo: string): Record<string, unknown> {
const llamada = prismaMock.syncPendiente.create.mock.calls.find(
(c) => (c[0] as { data: { tipo: string } }).data.tipo === tipo,
)
if (!llamada) throw new Error(`No se encoló ${tipo}`)
return JSON.parse((llamada[0] as { data: { payloadJson: string } }).data.payloadJson) as Record<string, unknown>
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
@@ -55,20 +39,19 @@ beforeEach(() => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
describe('esIdLocal', () => {
it('distingue ids locales de ids de Monday', () => {
it('distingue ids locales de ids heredados', () => {
expect(esIdLocal('loc_123e4567-e89b-12d3-a456-426614174000')).toBe(true)
expect(esIdLocal('1234567890')).toBe(false)
})
})
describe('CrmLocalService.crearLead', () => {
it('crea la captura board=LEADS etapa NUEVO pendienteSync y encola CREAR_LEAD + ASIGNAR_PERSONA', async () => {
it('crea la captura board=LEADS etapa NUEVO pendienteSync', async () => {
const r = await svc.crearLead(
{ nombre: 'Ana Prueba', telefono: '4771234567', origen: 'WhatsApp', desarrollo: 'SENDA', notas: 'Quiere 2R' },
'u-1',
@@ -90,12 +73,6 @@ describe('CrmLocalService.crearLead', () => {
pendienteSync: true,
}),
})
expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA'])
const payload = payloadEncolado('CREAR_LEAD')
expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', telefono: '4771234567', origenLabel: 'WhatsApp' })
expect(payloadEncolado('ASIGNAR_PERSONA')).toEqual({ board: 'leads', usuarioId: 'u-1' })
// Todo quedó en una sola transacción (cambio local + cola atómicos)
expect(prismaMock.$transaction).toHaveBeenCalledOnce()
})
it('sin teléfono repetido no hay duplicado; con teléfono existente devuelve { duplicado }', async () => {
@@ -103,12 +80,11 @@ describe('CrmLocalService.crearLead', () => {
const r = await svc.crearLead({ nombre: 'Ana Prueba', telefono: '4771234567' })
expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Ana Prueba' } })
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
})
describe('CrmLocalService.crearAfluencia', () => {
it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson y encola CREAR_AFLUENCIA', async () => {
it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson', async () => {
const r = await svc.crearAfluencia(
{
nombre: 'Luis Demo',
@@ -135,18 +111,11 @@ describe('CrmLocalService.crearAfluencia', () => {
})
// Perfilamiento completo en datosJson
expect(JSON.parse(creada.data['datosJson'] as string)).toMatchObject({ edad: 35, credito: 'Infonavit' })
// Auto-mapping leadId = oportunidadId (como el flujo Monday)
// Auto-mapping leadId = oportunidadId
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: r.id, oportunidadId: r.id } })
expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA'])
expect(payloadEncolado('CREAR_AFLUENCIA')['datos']).toMatchObject({
nombre: 'Luis Demo',
modeloLabel: 'SOHO',
creditoLabel: 'Infonavit',
loQuierePara: 'Habitarla',
})
})
it('con oportunidadId ACTUALIZA la fila local y encola ACTUALIZAR_AFLUENCIA (no crea duplicado)', async () => {
it('con oportunidadId ACTUALIZA la fila local (no crea duplicado)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'op-55', nombreLead: 'Luis Demo' })
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', oportunidadId: 'op-55', califica: 'Si' }, 'u-2')
expect(r).toEqual({ id: 'op-55', oportunidadId: 'op-55', actualizada: true })
@@ -155,8 +124,6 @@ describe('CrmLocalService.crearAfluencia', () => {
data: expect.objectContaining({ etapa: 'Afluencia', pendienteSync: true }),
})
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(tiposEncolados()).toEqual(['ACTUALIZAR_AFLUENCIA'])
expect(payloadEncolado('ACTUALIZAR_AFLUENCIA')['datos']).toMatchObject({ nombre: 'Luis Demo', calificaLabel: 'Si' })
})
it('con teléfono duplicado en OPORTUNIDADES devuelve { duplicado }', async () => {
@@ -167,44 +134,35 @@ describe('CrmLocalService.crearAfluencia', () => {
})
describe('CrmLocalService.cambiarEstado', () => {
it('actualiza la etapa local y encola CAMBIO_ETAPA con board y label', async () => {
it('actualiza la etapa local y guarda la fecha de la cita en datosJson', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' })
await svc.cambiarEstado('loc_1', 'Agendo Cita', 'OPORTUNIDADES', { fecha: '2026-09-10' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { etapa: 'Agendo Cita', pendienteSync: true },
data: { etapa: 'Agendo Cita', pendienteSync: true, datosJson: JSON.stringify({ fechaCita: '2026-09-10' }) },
})
expect(payloadEncolado('CAMBIO_ETAPA')).toEqual({ board: 'oportunidades', label: 'Agendo Cita', fecha: '2026-09-10' })
})
it('sin fila local y sin sombra → 404', async () => {
await expect(svc.cambiarEstado('loc_inexistente', 'Contactado', 'LEADS')).rejects.toMatchObject({ status: 404 })
})
it('con sombra crea la fila mínima (id Monday viejo) y encola igual', async () => {
it('con sombra crea la fila mínima (id heredado)', async () => {
await svc.cambiarEstado('998877', 'Contactado', 'LEADS', { sombra: true, usuarioId: 'u-1' })
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: '998877', board: 'LEADS', mondayItemId: '998877', pendienteSync: true }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
})
})
describe('CrmLocalService.descartar', () => {
it('marca etapa terminal Perdido con motivo/submotivo y encola DESCARTE', async () => {
it('marca etapa terminal Perdido con motivo/submotivo', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Luis Demo' })
await svc.descartar('loc_2', 'Precio', { subcategoria: 'Enganche', comentario: 'Se le hizo caro' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_2' },
data: { etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche', pendienteSync: true },
})
expect(payloadEncolado('DESCARTE')).toEqual({
fuente: 'oportunidad',
motivo: 'Precio',
nombreItem: 'Luis Demo',
subcategoria: 'Enganche',
comentario: 'Se le hizo caro',
})
})
it('fuente lead: etapa terminal Descartado', async () => {
@@ -231,7 +189,7 @@ describe('CrmLocalService.convertirLead', () => {
datosJson: '{"notas":null}',
}
it('marca el lead Calificado, crea la oportunidad en Agendo Cita, liga y encola CONVERSION', async () => {
it('marca el lead Calificado, crea la oportunidad en Agendo Cita y la liga', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaLead)
const op = await svc.convertirLead('loc_lead', 'u-1')
expect(op.leadId).toMatch(/^loc_/)
@@ -251,12 +209,9 @@ describe('CrmLocalService.convertirLead', () => {
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'loc_lead', oportunidadId: op.leadId },
})
const payload = payloadEncolado('CONVERSION')
expect(payload['oportunidadIdLocal']).toBe(op.leadId)
expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' })
})
it('si el lead ya fue convertido → 409 como el flujo Monday', async () => {
it('si el lead ya fue convertido → 409', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...filaLead, oportunidadId: 'loc_op_previa' })
await expect(svc.convertirLead('loc_lead')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
@@ -279,7 +234,7 @@ describe('CrmLocalService.apartar', () => {
datosJson: '{"edad":35}',
}
it('pone la oportunidad en Aparto, crea el apartado local (Pago Apartado) y encola CAMBIO_ETAPA + CREAR_APARTADO', async () => {
it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
const apartado = await svc.apartar('loc_op', 'u-1')
expect(apartado.leadId).toMatch(/^loc_/)
@@ -299,13 +254,6 @@ describe('CrmLocalService.apartar', () => {
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'apartado-creado:loc_op' } }),
)
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO'])
expect(payloadEncolado('CREAR_APARTADO')['datos']).toMatchObject({
clienteNombre: 'Luis Demo',
estadoLabel: 'Pago Apartado',
telefono: '6641112233',
email: 'luis@correo.com',
})
})
it('idempotente: con el dedup escrito devuelve el apartado existente sin encolar de nuevo', async () => {
@@ -316,7 +264,6 @@ describe('CrmLocalService.apartar', () => {
const apartado = await svc.apartar('loc_op', 'u-1')
expect(apartado.leadId).toBe('loc_ap_previo')
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
})

View File

@@ -2,36 +2,20 @@ import { randomUUID } from 'node:crypto'
import { AppError, NotFoundError } from '../utils/errors.js'
import { claveApartadoCreado } from './automatizaciones.service.js'
import { prisma } from './prisma.js'
import type { TipoSync } from './sync-monday.service.js'
/**
* CRM local (modo degradado): implementa el embudo completo contra SQLite
* mientras Monday está caído y encola cada operación en SyncPendiente para que
* el sync worker la replique cuando Monday vuelva.
* CRM local: implementa el embudo completo contra SQLite (tabla LeadCaptura).
*
* - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES |
* APARTADO + etapa). Ids locales: `loc_<uuid>` (ver `esIdLocal`).
* - Las operaciones que tocan varias tablas van en $transaction; el encolado
* viaja en la MISMA transacción para que nunca quede un cambio local sin su
* réplica pendiente.
* - Las operaciones que tocan varias tablas van en $transaction.
* - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras.
*
* Los controllers rutean aquí cuando `saludMonday.estaDisponible()` es false,
* cuando el id es `loc_*` (siempre local hasta que se sincronice) o como
* fallback tras un AppError 502 de Monday.
*/
/** Board local del embudo (columna LeadCaptura.board) */
export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO'
/** Board local → valor del payload CAMBIO_ETAPA (boards de config.monday.boards) */
const BOARD_PAYLOAD: Record<BoardLocal, 'leads' | 'oportunidades' | 'apartado'> = {
LEADS: 'leads',
OPORTUNIDADES: 'oportunidades',
APARTADO: 'apartado',
}
/** true si el id es local (aún no existe en Monday) */
/** true si el id es local (`loc_<uuid>`) en lugar de un id heredado del CRM anterior */
export function esIdLocal(id: string): boolean {
return id.startsWith('loc_')
}
@@ -40,14 +24,6 @@ function idLocal(): string {
return `loc_${randomUUID()}`
}
const hoyMas = (dias: number): string => {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** Captura rápida de lead (forma corta de /hoy) */
export interface DatosLeadLocal {
nombre: string
@@ -93,50 +69,10 @@ export interface ResultadoBusquedaLocal {
modeloInteres: string
}
/**
* Crea un registro en la cola SyncPendiente. Es el ÚNICO punto de encolado del
* modo local: los handlers del worker (sync-handlers.ts) consumen `tipo` +
* `payloadJson` y actualizan los ids Monday usando `referenciaLocal`.
*/
export async function encolarSync(
tipo: TipoSync,
payload: Record<string, unknown>,
referenciaLocal?: string | null,
): Promise<void> {
await prisma.syncPendiente.create({
data: {
tipo,
payloadJson: JSON.stringify(payload),
referenciaLocal: referenciaLocal ?? null,
},
})
}
/**
* Versión best-effort para las réplicas que hoy solo se loguean (alta cliente,
* cotización, checkbox de documento, liquidación): un fallo al encolar NUNCA
* rompe el flujo del caller.
*/
export async function encolarSyncSeguro(
tipo: TipoSync,
payload: Record<string, unknown>,
referenciaLocal?: string | null,
): Promise<void> {
try {
await encolarSync(tipo, payload, referenciaLocal)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[sync] no se pudo encolar ${tipo}:`, err)
}
}
export class CrmLocalService {
/**
* Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO' + CREAR_LEAD
* encolado. Dedup por teléfono entre los leads locales (mismo contrato 409
* que el flujo Monday). Con usuarioId también se encola la auto-asignación
* del capturista (ASIGNAR_PERSONA) para que quede como su vendedor al
* sincronizarse.
* Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO'. Dedup por
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
*/
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
// Dedup por teléfono (normalizando: últimos 10 dígitos)
@@ -157,111 +93,52 @@ export class CrmLocalService {
if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } }
}
const id = idLocal()
const datosMonday = {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
}
const operaciones = [
prisma.leadCaptura.create({
data: {
leadId: id,
oportunidadId: null, // los leads no generan oportunidad
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
board: 'LEADS',
etapa: 'NUEVO',
datosJson: JSON.stringify({ notas: datos.notas ?? null }),
pendienteSync: true,
},
}),
prisma.syncPendiente.create({
data: { tipo: 'CREAR_LEAD', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
}),
]
if (usuarioId !== undefined) {
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId }),
referenciaLocal: id,
},
}),
)
}
await prisma.$transaction(operaciones)
return { id, oportunidadId: null }
const fila = await prisma.leadCaptura.create({
data: {
leadId: id,
oportunidadId: null, // los leads no generan oportunidad
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
board: 'LEADS',
etapa: 'NUEVO',
datosJson: JSON.stringify({ notas: datos.notas ?? null }),
pendienteSync: true,
},
})
return { id: fila.leadId, oportunidadId: null }
}
/**
* Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES
* etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos
* planos) + auto-mapping LeadOportunidad + CREAR_AFLUENCIA encolado. Con
* oportunidadId (botón "Afluencia" de Mis oportunidades): actualiza la fila
* local si existe y encola ACTUALIZAR_AFLUENCIA (el id puede ser de Monday).
* planos) + auto-mapping LeadOportunidad. Con oportunidadId (botón
* "Afluencia" de Mis oportunidades): actualiza la fila local si existe.
*/
async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
const { oportunidadId, modelo, medioContacto, edad, estadoCivil, tipoEmpleo, ocupacion } = datos
const { rangoSalarial, credito, tipoEconomia, califica, loQuierePara } = datos
const datosMonday = {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: modelo,
medioContacto,
edad,
estadoCivilLabel: estadoCivil,
tipoEmpleoLabel: tipoEmpleo,
ocupacion,
rangoSalarial,
creditoLabel: credito,
tipoEconomiaLabel: tipoEconomia,
calificaLabel: califica,
loQuierePara,
notas: datos.notas,
}
const { oportunidadId, modelo } = datos
if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
const operaciones = []
if (existente) {
operaciones.push(
prisma.leadCaptura.update({
where: { leadId: oportunidadId },
data: {
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
}),
)
}
operaciones.push(
prisma.syncPendiente.create({
await prisma.leadCaptura.update({
where: { leadId: oportunidadId },
data: {
tipo: 'ACTUALIZAR_AFLUENCIA',
payloadJson: JSON.stringify({ oportunidadId, datos: datosMonday }),
referenciaLocal: oportunidadId,
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
}),
)
await prisma.$transaction(operaciones)
})
}
return { id: oportunidadId, oportunidadId, actualizada: true }
}
@@ -273,7 +150,7 @@ export class CrmLocalService {
}
const id = idLocal()
const operaciones = [
await prisma.$transaction([
prisma.leadCaptura.create({
data: {
leadId: id,
@@ -291,33 +168,17 @@ export class CrmLocalService {
pendienteSync: true,
},
}),
// Auto-mapping leadId = oportunidadId (como el flujo Monday): el alta de
// clientes encuentra así la oportunidad a mover a 'Aparto'
// Auto-mapping leadId = oportunidadId: el alta de clientes encuentra así
// la oportunidad a mover a 'Aparto'
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
prisma.syncPendiente.create({
data: { tipo: 'CREAR_AFLUENCIA', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
}),
]
if (usuarioId !== undefined) {
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'oportunidades', usuarioId }),
referenciaLocal: id,
},
}),
)
}
await prisma.$transaction(operaciones)
])
return { id, oportunidadId: id }
}
/**
* Fila local del item, creándola "sombra" si no existe y `sombra` es true.
* Las sombras cubren el fallback 502 sobre ids de Monday antiguos (creados
* antes del modo local): la acción queda reflejada en Mi día y el cambio se
* encola igual. Sin `sombra` → 404.
* Las sombras cubren ids heredados del CRM anterior sin fila local: la
* acción queda reflejada en Mi día. Sin `sombra` → 404.
*/
private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) {
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
@@ -327,7 +188,7 @@ export class CrmLocalService {
data: {
leadId: itemId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: 'Registro previo (Monday)',
nombreLead: 'Registro previo (CRM anterior)',
board,
etapa: 'NUEVO',
mondayItemId: esIdLocal(itemId) ? null : itemId,
@@ -338,8 +199,8 @@ export class CrmLocalService {
/**
* Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO):
* actualiza `etapa` (+updatedAt vía Prisma) y encola CAMBIO_ETAPA con el
* label real de la columna 'Estado' (y la fecha de la cita si aplica).
* actualiza `etapa` (+updatedAt vía Prisma) y guarda la fecha de la cita en
* datosJson si aplica.
*/
async cambiarEstado(
itemId: string,
@@ -348,8 +209,6 @@ export class CrmLocalService {
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
): Promise<void> {
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
const payload: Record<string, unknown> = { board: BOARD_PAYLOAD[board], label }
if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
@@ -361,21 +220,15 @@ export class CrmLocalService {
datos['fechaCita'] = opciones.fecha
updateData['datosJson'] = JSON.stringify(datos)
}
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: updateData,
}),
prisma.syncPendiente.create({
data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
}),
])
await prisma.leadCaptura.update({
where: { leadId: itemId },
data: updateData,
})
}
/**
* Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en
* Oportunidades), guarda motivo/submotivo y encola DESCARTE (el handler
* replica el estado + el item del tablero Motivos No Compra).
* Oportunidades); guarda motivo/submotivo.
*/
async descartar(
itemId: string,
@@ -392,31 +245,22 @@ export class CrmLocalService {
const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES'
const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido'
const payload: Record<string, unknown> = { fuente, motivo, nombreItem: row.nombreLead }
if (opciones.subcategoria !== undefined) payload['subcategoria'] = opciones.subcategoria
if (opciones.comentario !== undefined) payload['comentario'] = opciones.comentario
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: {
etapa: etapaTerminal,
motivo,
submotivo: opciones.subcategoria ?? null,
pendienteSync: true,
},
}),
prisma.syncPendiente.create({
data: { tipo: 'DESCARTE', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
}),
])
await prisma.leadCaptura.update({
where: { leadId: itemId },
data: {
etapa: etapaTerminal,
motivo,
submotivo: opciones.subcategoria ?? null,
pendienteSync: true,
},
})
}
/**
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
* los datos, se liga LeadOportunidad + `oportunidadId` en la captura y se
* encola CONVERSION. Si el lead ya fue convertido → 409 (como el flujo
* Monday). Devuelve la fila local de la oportunidad.
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Si el
* lead ya fue convertido → 409. Devuelve la fila local de la oportunidad.
*/
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
@@ -424,15 +268,6 @@ export class CrmLocalService {
if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
const oportunidadId = idLocal()
const datosOportunidad = {
nombre: lead.nombreLead,
estadoLabel: 'Agendo Cita',
telefono: lead.telefono ?? undefined,
email: lead.email ?? undefined,
origenLabel: lead.origen ?? undefined,
desarrolloLabel: lead.desarrollo ?? undefined,
modeloLabel: lead.modelo ?? undefined,
}
const resultados = await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: leadIdLocal },
@@ -456,13 +291,6 @@ export class CrmLocalService {
},
}),
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
prisma.syncPendiente.create({
data: {
tipo: 'CONVERSION',
payloadJson: JSON.stringify({ oportunidadIdLocal: oportunidadId, datos: datosOportunidad }),
referenciaLocal: leadIdLocal,
},
}),
])
return resultados[1]
}
@@ -471,11 +299,10 @@ export class CrmLocalService {
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
* Enkontrol) con los datos conocidos. Idempotente con el mismo dedup
* `apartado-creado:<opId>` que las automatizaciones. Encola CAMBIO_ETAPA +
* CREAR_APARTADO (en ese orden: la etapa primero, como en Monday).
* `apartado-creado:<opId>` de las automatizaciones.
*/
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
// Sombra para ids Monday sin fila local (fallback 502 sobre items viejos)
// Sombra para ids heredados sin fila local
const oportunidad = await this.filaLocal(
oportunidadIdLocal,
'OPORTUNIDADES',
@@ -491,15 +318,6 @@ export class CrmLocalService {
}
const apartadoId = idLocal()
const datosApartado = {
clienteNombre: oportunidad.nombreLead,
unidadCodigo: '', // la unidad se captura en la cotización/alta
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado',
telefono: oportunidad.telefono ?? undefined,
email: oportunidad.email ?? undefined,
}
const resultados = await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: oportunidadIdLocal },
@@ -525,28 +343,13 @@ export class CrmLocalService {
update: { valor: apartadoId },
create: { clave, valor: apartadoId },
}),
prisma.syncPendiente.create({
data: {
tipo: 'CAMBIO_ETAPA',
payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Aparto' }),
referenciaLocal: oportunidadIdLocal,
},
}),
prisma.syncPendiente.create({
data: {
tipo: 'CREAR_APARTADO',
payloadJson: JSON.stringify({ datos: datosApartado }),
referenciaLocal: apartadoId,
},
}),
])
return resultados[1]
}
/**
* Búsqueda local para el picker del alta de clientes (y el buscador del
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular,
* con la misma forma de respuesta que la búsqueda Monday.
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular.
*/
async buscarLocales(termino: string): Promise<ResultadoBusquedaLocal[]> {
const capturas = await prisma.leadCaptura.findMany({

View File

@@ -55,7 +55,7 @@ const ALIAS: Record<string, EtapaPipeline> = {
}
/**
* Normaliza una etapa cruda (maestro ERP o label de Monday) al label oficial.
* Normaliza una etapa cruda (maestro ERP o label del CRM anterior) al label oficial.
* Devuelve null si está vacía o no es una etapa del pipeline (p. ej. DISPONIBLE).
*/
export function normalizarEtapa(cruda: string | null | undefined): EtapaPipeline | null {

View File

@@ -1,180 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { FunnelService, type LeadOportunidadStore } from './funnel.service.js'
import type { LeadMonday, OportunidadMonday } from './monday.service.js'
/** Monday mockeado: registra cambios de columna y devuelve búsquedas controladas */
function mondayMock(
opts: { leadPorTelefono?: LeadMonday | null; oportunidadPorTelefono?: OportunidadMonday | null } = {},
) {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async buscarLeadPorTelefono(_telefono: string) {
return opts.leadPorTelefono ?? null
},
async buscarOportunidadPorTelefono(_telefono: string) {
return opts.oportunidadPorTelefono ?? null
},
}
}
/** Store en memoria (equivalente al LeadOportunidadStore de Prisma) */
function storeMock(): LeadOportunidadStore & { mapping: Map<string, string> } {
const mapping = new Map<string, string>()
return {
mapping,
async buscarPorLeadId(leadId: string) {
return mapping.get(leadId) ?? null
},
async buscarPorOportunidadId(oportunidadId: string) {
for (const opId of mapping.values()) {
if (opId === oportunidadId) return opId
}
return null
},
async guardar(leadId: string, oportunidadId: string) {
mapping.set(leadId, oportunidadId)
},
}
}
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
createdAt: '',
updatedAt: '',
estado: 'NUEVO',
origen: '',
telefono: '',
email: '',
vendedorIds: [],
...overrides,
})
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
id: 'op-1',
nombre: 'Cliente Prueba',
createdAt: '',
updatedAt: '',
etapa: 'Afluencia',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '4771234567',
email: '',
origen: '',
...overrides,
})
describe('FunnelService.moverOportunidadDeLead', () => {
it('mueve la oportunidad mapeada a la etapa indicada (board 555 en tests)', async () => {
const monday = mondayMock()
const store = storeMock()
store.mapping.set('lead-1', 'op-9')
const funnel = new FunnelService(monday, store)
await expect(funnel.moverOportunidadDeLead('lead-1', 'Agendo Cita')).resolves.toBe(true)
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Agendo Cita' }])
})
it('sin mapping es no-op (false, sin llamadas a Monday)', async () => {
const monday = mondayMock()
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.moverOportunidadDeLead('lead-sin-op', 'Perdido')).resolves.toBe(false)
expect(monday.cambios).toEqual([])
})
})
describe('FunnelService.oportunidadPorTelefono', () => {
it('resuelve lead histórico por teléfono y devuelve la oportunidad mapeada', async () => {
const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'lead-7' }) })
const store = storeMock()
store.mapping.set('lead-7', 'op-7')
const funnel = new FunnelService(monday, store)
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7')
})
it('sin lead histórico, busca el teléfono directo en Oportunidades (modelo nuevo)', async () => {
const monday = mondayMock({ oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }) })
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa')
})
it('lead histórico sin mapping cae a la búsqueda directa en Oportunidades', async () => {
const monday = mondayMock({
leadPorTelefono: leadMonday({ id: 'sin-mapping' }),
oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }),
})
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa')
})
it('sin lead ni oportunidad con ese teléfono devuelve null', async () => {
const funnel = new FunnelService(mondayMock(), storeMock())
await expect(funnel.oportunidadPorTelefono('4770000000')).resolves.toBeNull()
})
})
describe('FunnelService.moverAApartoConDedup', () => {
it('escribe el dedup apartado-creado ANTES de mover la oportunidad a Aparto', async () => {
const monday = mondayMock()
const store = storeMock()
store.mapping.set('lead-1', 'op-1')
const funnel = new FunnelService(monday, store)
const orden: string[] = []
const state = {
get: vi.fn(async () => null),
set: vi.fn(async (clave: string) => {
orden.push(`dedup:${clave}`)
}),
}
const cambiarSpy = vi.spyOn(monday, 'cambiarValorColumna').mockImplementation(async () => {
orden.push('monday:Aparto')
})
const opId = await funnel.moverAApartoConDedup('lead-1', state)
expect(opId).toBe('op-1')
expect(state.set).toHaveBeenCalledWith('apartado-creado:op-1', expect.any(String))
expect(cambiarSpy).toHaveBeenCalledWith('555', 'op-1', 'Estado', 'Aparto')
expect(orden).toEqual(['dedup:apartado-creado:op-1', 'monday:Aparto'])
})
it('con id de oportunidad directa (modelo nuevo) busca por oportunidadId y mueve ESA', async () => {
const monday = mondayMock()
const store = storeMock()
// Auto-mapping de la captura nueva: leadId = oportunidadId
store.mapping.set('op-77', 'op-77')
const funnel = new FunnelService(monday, store)
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
const opId = await funnel.moverAApartoConDedup('op-77', state)
expect(opId).toBe('op-77')
expect(state.set).toHaveBeenCalledWith('apartado-creado:op-77', expect.any(String))
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-77', columna: 'Estado', valor: 'Aparto' }])
})
it('un id que es a la vez lead de otro mapping y oportunidad: gana la oportunidad directa', async () => {
const monday = mondayMock()
const store = storeMock()
store.mapping.set('lead-1', 'op-1') // liga histórica cuya oportunidad es op-1
store.mapping.set('op-1', 'op-1') // auto-mapping de op-1 (modelo nuevo)
const funnel = new FunnelService(monday, store)
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
// 'op-1' como entrada: se resuelve como oportunidad directa
await expect(funnel.moverAApartoConDedup('op-1', state)).resolves.toBe('op-1')
// 'lead-1' como entrada: se resuelve vía la liga histórica
await expect(funnel.moverAApartoConDedup('lead-1', state)).resolves.toBe('op-1')
})
it('sin mapping no escribe dedup ni mueve nada (null)', async () => {
const monday = mondayMock()
const funnel = new FunnelService(monday, storeMock())
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
await expect(funnel.moverAApartoConDedup('sin-mapping', state)).resolves.toBeNull()
expect(state.set).not.toHaveBeenCalled()
expect(monday.cambios).toEqual([])
})
})

View File

@@ -1,106 +0,0 @@
import { config } from '../config/index.js'
import { claveApartadoCreado, type StateStore } from './automatizaciones.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Funnel comercial sobre el tablero Oportunidades:
* la captura de afluencia crea la oportunidad directamente en etapa
* 'Afluencia' (ver leads.controller), y la oportunidad avanza de etapa sola
* conforme avanza el proceso (Agendo Cita, Cotizador, Aparto, Ganado /
* Perdido).
*
* La tabla LeadOportunidad (mapping lead ↔ oportunidad) queda para las ligas
* históricas (leads del tablero Leads de agosto) y como auto-mapping
* (leadId = oportunidadId) de las afluencias nuevas, para que el alta de
* clientes encuentre la oportunidad a mover a 'Aparto' en ambos esquemas.
* Los errores de Monday se propagan al caller, que decide loguear y continuar
* (la acción principal — la cotización o el alta — no se rompe).
*/
/** Store del mapping lead ↔ oportunidad (Prisma en prod, memoria en tests) */
export interface LeadOportunidadStore {
buscarPorLeadId(leadId: string): Promise<string | null> // oportunidadId
buscarPorOportunidadId(oportunidadId: string): Promise<string | null> // oportunidadId si hay mapping
guardar(leadId: string, oportunidadId: string): Promise<void>
}
type MondayFunnel = Pick<
MondayService,
'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono'
>
export class FunnelService {
constructor(
private readonly monday: MondayFunnel,
private readonly store: LeadOportunidadStore,
) {}
/** Mueve la oportunidad del lead a otra etapa. Sin mapping → no-op (false). */
async moverOportunidadDeLead(leadId: string, etapa: string): Promise<boolean> {
const oportunidadId = await this.store.buscarPorLeadId(leadId)
if (oportunidadId === null) return false
await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', etapa)
return true
}
/** Oportunidad ligada al lead (para el match de cotizaciones); null si no hay */
async oportunidadDeLead(leadId: string): Promise<string | null> {
return this.store.buscarPorLeadId(leadId)
}
/**
* Oportunidad que tenga ese teléfono (match de cotizaciones). Convierte con
* ambos esquemas: primero el mapping histórico (lead del tablero Leads → su
* oportunidad); si no hay lead con ese número, busca el teléfono directo en
* Oportunidades (modelo nuevo: la afluencia ES la oportunidad).
*/
async oportunidadPorTelefono(telefono: string): Promise<string | null> {
const lead = await this.monday.buscarLeadPorTelefono(telefono)
if (lead) {
const mapeada = await this.store.buscarPorLeadId(lead.id)
if (mapeada !== null) return mapeada
}
const directa = await this.monday.buscarOportunidadPorTelefono(telefono)
return directa?.id ?? null
}
/**
* Alta de cliente: escribe el dedup `apartado-creado:<opId>` ANTES de mover
* la oportunidad a 'Aparto'. Este orden evita la carrera con el webhook/cron
* (escaneoOportunidades vería 'Aparto' y crearía un Apartado duplicado; con
* el dedup previo lo salta). Devuelve la oportunidadId movida o null si no
* hay mapping.
*
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad; el picker del alta la manda con fuente 'oportunidad') o un
* lead histórico del tablero Leads: se busca primero como oportunidad y
* luego como lead.
*/
async moverAApartoConDedup(id: string, state: StateStore): Promise<string | null> {
const comoOportunidad = await this.store.buscarPorOportunidadId(id)
const oportunidadId = comoOportunidad ?? (await this.store.buscarPorLeadId(id))
if (oportunidadId === null) return null
await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString())
await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', 'Aparto')
return oportunidadId
}
}
/** Store respaldado por la tabla LeadOportunidad de Prisma */
export const prismaLeadOportunidadStore: LeadOportunidadStore = {
async buscarPorLeadId(leadId) {
const row = await prisma.leadOportunidad.findUnique({ where: { leadId } })
return row?.oportunidadId ?? null
},
async buscarPorOportunidadId(oportunidadId) {
const row = await prisma.leadOportunidad.findUnique({ where: { oportunidadId } })
return row?.oportunidadId ?? null
},
async guardar(leadId, oportunidadId) {
await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } })
},
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const funnelService = new FunnelService(mondayService, prismaLeadOportunidadStore)

View File

@@ -1,114 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../config/index.js', () => ({
config: {
monday: {
boards: {
leads: '111',
oportunidades: '222',
contactos: '333',
unidades: '444',
apartado: '555',
},
},
},
}))
import { GRUPOS_POR_ESTADO, grupoDestino, moverAGrupoSegunEstado } from './grupos-estado.service.js'
interface ItemFake {
id: string
name: string
groupId: string
column_values: { id: string; text: string | null; value: string | null }[]
}
/** Monday mockeado: registra los movimientos de grupo solicitados */
function mondayMock(opts: { item?: ItemFake | null; columnaId?: string | null } = {}) {
return {
movimientos: [] as { itemId: string; groupId: string }[],
async columnaIdPorTitulo(_boardId: string, _titulo: string) {
return opts.columnaId !== undefined ? opts.columnaId : 'status_col'
},
async getItemConGrupo(_itemId: string) {
return opts.item ?? null
},
async moverItemAGrupo(itemId: string, groupId: string) {
this.movimientos.push({ itemId, groupId })
},
}
}
const itemCon = (groupId: string, estado: string): ItemFake => ({
id: '999',
name: 'Item Prueba',
groupId,
column_values: [{ id: 'status_col', text: estado, value: null }],
})
describe('grupoDestino', () => {
it('mapea labels conocidos de cada tablero', () => {
expect(grupoDestino('111', 'NUEVO')).toBe('topics')
expect(grupoDestino('111', 'CONTACTADO')).toBe('group_mm6s1jct')
expect(grupoDestino('111', 'DESCARTADO')).toBe('group_mm6s96mc')
expect(grupoDestino('222', 'Ganado')).toBe('group_mm6scqz3')
expect(grupoDestino('222', 'Afluencia')).toBe('topics')
expect(grupoDestino('333', 'INACTIVO')).toBe('group_mm6sqqq2')
expect(grupoDestino('444', 'Notaría')).toBe('group_mm6syf9c')
expect(grupoDestino('555', 'LIBERADO')).toBe('group_mm6s2yyx')
})
it('label desconocido → null', () => {
expect(grupoDestino('111', 'INVENTADO')).toBeNull()
})
it('board sin mapeo → null', () => {
expect(grupoDestino('999', 'NUEVO')).toBeNull()
})
it('etapa vacía en Unidades → null (no se mueve)', () => {
expect(grupoDestino('444', '')).toBeNull()
expect(grupoDestino('444', ' ')).toBeNull()
})
it('los 5 tableros del CRM tienen mapeo', () => {
expect(Object.keys(GRUPOS_POR_ESTADO).sort()).toEqual(['111', '222', '333', '444', '555'])
})
})
describe('moverAGrupoSegunEstado', () => {
it('mueve el item cuando el grupo actual difiere del destino', async () => {
const monday = mondayMock({ item: itemCon('topics', 'DESCARTADO') })
const r = await moverAGrupoSegunEstado(monday, '111', '999')
expect(r).toEqual({ movido: true, grupo: 'group_mm6s96mc' })
expect(monday.movimientos).toEqual([{ itemId: '999', groupId: 'group_mm6s96mc' }])
})
it('no mueve cuando el item ya está en el grupo destino', async () => {
const monday = mondayMock({ item: itemCon('group_mm6s96mc', 'DESCARTADO') })
const r = await moverAGrupoSegunEstado(monday, '111', '999')
expect(r).toEqual({ movido: false })
expect(monday.movimientos).toEqual([])
})
it('item inexistente (borrado tras el webhook) no truena ni mueve', async () => {
const monday = mondayMock({ item: null })
const r = await moverAGrupoSegunEstado(monday, '111', '999')
expect(r).toEqual({ movido: false })
expect(monday.movimientos).toEqual([])
})
it('board sin mapeo no consulta ni mueve nada', async () => {
const monday = mondayMock({ item: itemCon('topics', 'NUEVO') })
const r = await moverAGrupoSegunEstado(monday, '999', '999')
expect(r).toEqual({ movido: false })
expect(monday.movimientos).toEqual([])
})
it('label no mapeado no mueve', async () => {
const monday = mondayMock({ item: itemCon('topics', 'INVENTADO') })
const r = await moverAGrupoSegunEstado(monday, '111', '999')
expect(r).toEqual({ movido: false })
expect(monday.movimientos).toEqual([])
})
})

View File

@@ -1,144 +0,0 @@
import { config } from '../config/index.js'
import type { MondayService } from './monday.service.js'
/**
* Automatización "cambio de estado → mover item al grupo correcto".
*
* Regla de negocio: cada tablero del CRM tiene grupos que representan la etapa
* del pipeline (p. ej. Leads nuevos / Leads Progreso / Leads Descalificado) y
* una columna status que manda (en Unidades es 'Etapa', no 'Estado'). Cuando el
* status cambia, el item debe moverse al grupo que corresponde a su nuevo
* estado; el webhook de change_column_value solo dispara y este servicio
* re-consulta el item en Monday antes de mover (el payload no es confiable).
*
* Labels verificados contra Monday el 2026-09-02; se comparan tal cual los
* devuelve column_values.text. El script scripts/mover-a-grupos.py duplica este
* mapeo para el backfill de items existentes (mantener ambos sincronizados).
*/
export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Record<string, string> }> = {
// Leads: nuevo → "Leads nuevos"; en seguimiento → "Leads Progreso";
// descartado/abandono → "Leads Descalificado"
[config.monday.boards.leads]: {
columna: 'Estado',
destinos: {
NUEVO: 'topics',
'Intento de contacto': 'group_mm6s1jct',
CONTACTADO: 'group_mm6s1jct',
Interesado: 'group_mm6s1jct',
'AGENDA CITA': 'group_mm6s1jct',
CALIFICADO: 'group_mm6s1jct',
DESCARTADO: 'group_mm6s96mc',
Abandono: 'group_mm6s96mc',
},
},
// Oportunidades: ganada → "Ganados"; perdida/abandono/no asistió →
// "Perdidos"; cualquier etapa activa del funnel → "Oportunidad"
[config.monday.boards.oportunidades]: {
columna: 'Estado',
destinos: {
Ganado: 'group_mm6scqz3',
GANADA: 'group_mm6scqz3',
Perdido: 'group_mm6s4cgq',
PERDIDA: 'group_mm6s4cgq',
Abandono: 'group_mm6s4cgq',
'No Asistio': 'group_mm6s4cgq',
Afluencia: 'topics',
Contactado: 'topics',
'Intento de contacto': 'topics',
Calificado: 'topics',
Cotizador: 'topics',
'Agendo Cita': 'topics',
Aparto: 'topics',
COTIZADA: 'topics',
ENVIADA: 'topics',
NEGOCIANDO: 'topics',
},
},
// Contactos: prospecto/cliente → "Contactos activos"; inactivo → inactivos
[config.monday.boards.contactos]: {
columna: 'Estado',
destinos: {
PROSPECTO: 'topics',
CLIENTE: 'topics',
INACTIVO: 'group_mm6sqqq2',
},
},
// Unidades: la columna que manda es 'Etapa' (pipeline de 10 etapas).
// Apartado/Reserva → "Nuevos Clientes"; en proceso de venta → "Clientes
// Activos"; escriturado en adelante → "PostVenta". Etapa vacía → NO mover.
[config.monday.boards.unidades]: {
columna: 'Etapa',
destinos: {
Apartado: 'group_mm6sn41r',
Reserva: 'group_mm6sn41r',
Vendido: 'group_mm6syf9c',
Integración: 'group_mm6syf9c',
'En Análisis': 'group_mm6syf9c',
Autorizado: 'group_mm6syf9c',
Notaría: 'group_mm6syf9c',
Escriturado: 'group_mm6sjh7f',
Entrega: 'group_mm6sjh7f',
PostVenta: 'group_mm6sjh7f',
},
},
// Apartado (pipeline Enkontrol): en trámite → "Nuevos Clientes"; firma/por
// escriturar → "Clientes Activos"; escriturado/formalizado → "PostVenta";
// cancelado/liberado → "Historico"
[config.monday.boards.apartado]: {
columna: 'Estado',
destinos: {
'Pago Apartado': 'topics',
'Alta Enkontrol': 'topics',
'En Definicion': 'topics',
'Entrego Documentacion': 'topics',
'Cita Clientes': 'topics',
'Contrato Enkontrol': 'topics',
VIGENTE: 'topics',
'X Escriturar': 'group_mm6sx3rn',
Escriturado: 'group_mm6s40br',
FORMALIZADO: 'group_mm6s40br',
CANCELADO: 'group_mm6s2yyx',
Cancelacion: 'group_mm6s2yyx',
'Cancelacion Apartado': 'group_mm6s2yyx',
LIBERADO: 'group_mm6s2yyx',
},
},
}
/**
* Grupo destino para un status dado en un tablero. null si el tablero no tiene
* mapeo, si el label no está mapeado o si el status viene vacío (en Unidades
* la etapa vacía significa "sin clasificar": no se mueve).
*/
export function grupoDestino(boardId: string, statusActual: string): string | null {
const mapeo = GRUPOS_POR_ESTADO[boardId]
if (!mapeo) return null
const estado = statusActual.trim()
if (estado === '') return null
return mapeo.destinos[estado] ?? null
}
type MondayGrupos = Pick<MondayService, 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'>
/**
* Mueve un item al grupo que corresponde a su status actual, según
* GRUPOS_POR_ESTADO. Idempotente: si ya está en ese grupo no hace nada, y si el
* item ya no existe (borrado entre el webhook y la consulta) tampoco truena.
*/
export async function moverAGrupoSegunEstado(
monday: MondayGrupos,
boardId: string,
itemId: string,
): Promise<{ movido: boolean; grupo?: string }> {
const mapeo = GRUPOS_POR_ESTADO[boardId]
if (!mapeo) return { movido: false }
const item = await monday.getItemConGrupo(itemId)
if (!item) return { movido: false }
const columnaId = await monday.columnaIdPorTitulo(boardId, mapeo.columna)
if (columnaId === null) return { movido: false }
const estado = item.column_values.find((cv) => cv.id === columnaId)?.text ?? ''
const destino = grupoDestino(boardId, estado)
if (destino === null || item.groupId === destino) return { movido: false }
await monday.moverItemAGrupo(itemId, destino)
return { movido: true, grupo: destino }
}

View File

@@ -1,18 +1,15 @@
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).
* Canal local para recordatorios de cobranza: crea una ActividadLocal
* (RECORDATORIO) para el vendedor responsable del plan.
*/
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()
return true
}
async enviar(
@@ -23,7 +20,7 @@ export class LocalCanal implements CanalRecordatorio {
const actividad = await actividadLocalService.crearActividad({
titulo: mensaje.length > 250 ? `${mensaje.slice(0, 247)}...` : mensaje,
descripcion: mensaje,
usuarioId: 'sistema',
usuarioId: destinatario.vendedorId ?? 'sistema',
leadCapturaId: null,
fechaVencimiento: new Date(),
tipo: 'RECORDATORIO',

View File

@@ -1,25 +1,23 @@
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'
/**
* Mi día en modo local (Monday caído) y la unión durante la transición
* (Monday vivo + capturas locales pendientes de sincronizar).
* Mi día 100% local: resumen construido desde LeadCaptura, Cliente y
* ActividadLocal.
*/
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 }
const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor' }
const coordinador: UsuarioMiDia = { nombre: 'Coord General', rol: 'coordinador' }
const sinNombre: UsuarioMiDia = { nombre: ' ', rol: 'vendedor' }
let consecutivo = 0
function fila(parcial: Partial<FilaLocalMiDia> & { leadId: string }): FilaLocalMiDia {
@@ -55,7 +53,7 @@ function cliente(parcial: Partial<ClienteLocalMiDia> & { id: string }): ClienteL
}
}
describe('resumenMiDiaLocal (Monday caído)', () => {
describe('resumenMiDiaLocal', () => {
it('arma el ResumenMiDia completo desde LeadCaptura por board, sin terminales', () => {
const capturas = [
fila({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO' }),
@@ -67,10 +65,9 @@ describe('resumenMiDiaLocal (Monday caído)', () => {
]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.sinMapeoMonday).toBe(false)
expect(r.misLeads.map((l) => l.id).sort()).toEqual(['loc_l1']) // solo leads, no oportunidades
expect(r.misLeads.find((l) => l.id === 'loc_l1')?.fuente).toBe('lead')
// porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday)
// porEtapa cuenta TODAS las oportunidades (incluye terminales)
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 1, Perdido: 1 })
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1'])
expect(r.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' })
@@ -94,7 +91,6 @@ describe('resumenMiDiaLocal (Monday caído)', () => {
const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA)
expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
expect(rSinNombre.sinMapeoMonday).toBe(true)
})
it('la unidad del apartado sale del Cliente ligado; estadoApartado de Mis clientes cruza con el apartado local', () => {
@@ -193,9 +189,9 @@ describe('resumenMiDiaLocal (Monday caído)', () => {
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 })
expect(porVendedor.find((v) => v.nombre === 'Martin Zavala')).toMatchObject({ usuarioId: 'u1', 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 })
expect(porVendedor.find((v) => v.nombre === 'Ana Perez')).toMatchObject({ usuarioId: 'u2', leadsAsignados: 1, leadsNuevosSemana: 1 })
})
it('equipo no se genera para vendedor', () => {
@@ -206,97 +202,3 @@ describe('resumenMiDiaLocal (Monday caído)', () => {
expect(r.equipo).toBeUndefined()
})
})
// ---------- Unión (Monday vivo + pendientes locales) ----------
function resumenBase(): ResumenMiDia {
return {
generadoEn: AHORA.toISOString(),
sinMapeoMonday: false,
misLeads: [
{
id: '9001',
nombre: 'Lead Monday',
telefono: '',
email: '',
estado: 'Nuevo',
origen: 'Web',
updatedAt: '2026-09-04T10:00:00.000Z',
fuente: 'lead',
ultimoCambio: null,
},
],
misCapturas: [
{ leadId: 'loc_n1', nombre: 'Cap Local', telefono: '', estado: '', origen: '', capturadoEn: '2026-09-03T10:00:00.000Z' },
{ leadId: '9001', nombre: 'Lead Monday', telefono: '', estado: 'Nuevo', origen: 'Web', capturadoEn: '2026-09-02T10:00:00.000Z' },
],
misClientes: [],
misActividadesHoy: [],
apartadosPorVencer: [],
misOportunidades: {
porEtapa: { Afluencia: 1 },
items: [
{
id: '9002',
nombre: 'Op Monday',
telefono: '',
email: '',
etapa: 'Afluencia',
desarrollo: '',
modeloInteres: '',
unidadCodigo: '',
updatedAt: '2026-09-04T09:00:00.000Z',
ultimoCambio: null,
},
],
},
misApartados: [
{ id: '9003', nombre: 'Apt Monday', unidadCodigo: 'U-1', estado: 'Pago Apartado', fechaLimite: '2026-09-10', ultimoCambio: null },
],
conteos: { leadsNuevosSemana: 1, actividadesAbiertas: 0 },
}
}
describe('unirPendientesLocales (Monday vivo)', () => {
it('agrega los items loc_* a la lista de su board con pendienteSync y re-ordena', () => {
const pendientes = [
fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', updatedAt: new Date('2026-09-05T11:00:00') }),
fila({ leadId: 'loc_n2', board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-09-05T10:00:00') }),
fila({ leadId: 'loc_n3', board: 'APARTADO', etapa: 'Pago Apartado' }),
fila({ leadId: 'loc_n4', board: 'LEADS', etapa: 'Descartado' }), // terminal: no se agrega
]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
// loc_n1 es más reciente que el de Monday: queda primero
expect(r.misLeads.map((l) => l.id)).toEqual(['loc_n1', 'loc_n2', '9001'])
expect(r.misLeads[0]).toMatchObject({ pendienteSync: true, fuente: 'lead' })
expect(r.misLeads[1]).toMatchObject({ pendienteSync: true, fuente: 'oportunidad' })
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_n2', '9002'])
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2 })
expect(r.misApartados.map((a) => a.id)).toEqual(['9003', 'loc_n3']) // sin fecha límite al final
expect(r.misApartados[1]).toMatchObject({ pendienteSync: true })
})
it('fila con mondayItemId (cambio pendiente sobre item Monday): no duplica, solo marca el existente', () => {
const pendientes = [
fila({ leadId: '9001', board: 'LEADS', etapa: 'Contactado', mondayItemId: '9001' }),
fila({ leadId: '9003', board: 'APARTADO', etapa: 'Documentos', mondayItemId: '9003' }),
]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
expect(r.misLeads).toHaveLength(1)
expect(r.misLeads[0]).toMatchObject({ id: '9001', pendienteSync: true })
expect(r.misApartados).toHaveLength(1)
expect(r.misApartados[0]).toMatchObject({ id: '9003', pendienteSync: true })
})
it('marca Mis capturas y rellena el estado local cuando Monday aún no lo tiene', () => {
const pendientes = [fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', origen: 'WhatsApp' })]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
const captura = r.misCapturas.find((c) => c.leadId === 'loc_n1')
expect(captura).toMatchObject({ pendienteSync: true, estado: 'NUEVO', origen: 'WhatsApp' })
// La captura ya conocida por Monday conserva su estado
expect(r.misCapturas.find((c) => c.leadId === '9001')?.estado).toBe('Nuevo')
})
})

View File

@@ -1,520 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
detalleEventoUltimoCambio,
esEstadoFinalApartado,
esEstadoTerminalLead,
esEtapaTerminal,
resumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
import type {
ActividadMonday,
ApartadoMonday,
LeadMonday,
OportunidadMonday,
UltimoCambioMonday,
} from './monday.service.js'
const MI_ID = 106736727
const OTRO_ID = 111111111
const AHORA = new Date('2026-08-28T12:00:00') // viernes
const HOY = '2026-08-28'
const MANANA = '2026-08-29'
function lead(parcial: Partial<LeadMonday> & { id: string }): LeadMonday {
return {
nombre: `Lead ${parcial.id}`,
createdAt: '2026-08-25T10:00:00Z',
updatedAt: '2026-08-27T10:00:00Z',
estado: 'Nuevo',
origen: 'Facebook',
telefono: '',
email: '',
vendedorIds: [MI_ID],
...parcial,
}
}
function oportunidad(parcial: Partial<OportunidadMonday> & { id: string }): OportunidadMonday {
return {
nombre: `Lead ${parcial.id}`,
createdAt: '2026-08-25T10:00:00Z',
updatedAt: '2026-08-27T10:00:00Z',
etapa: 'Afluencia',
origen: 'Facebook',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [MI_ID],
telefono: '',
email: '',
...parcial,
}
}
function actividad(parcial: Partial<ActividadMonday> & { id: string }): ActividadMonday {
return {
nombre: `Actividad ${parcial.id}`,
tipo: 'Llamada',
fecha: HOY,
estado: 'Abierto',
responsableIds: [MI_ID],
...parcial,
}
}
function apartado(parcial: Partial<ApartadoMonday> & { id: string }): ApartadoMonday {
return {
nombre: `Cliente ${parcial.id}`,
unidadCodigo: 'U-101',
fechaLimite: '2026-09-02', // dentro de 7 días
estado: 'Pago Apartado',
vendedorIds: [MI_ID],
...parcial,
}
}
function mondayMock(datos: {
leads?: LeadMonday[]
oportunidades?: OportunidadMonday[]
actividades?: ActividadMonday[]
apartados?: ApartadoMonday[]
/** boardId → (itemId → último cambio); boards de test: Leads '444', Oportunidades '555' */
ultimosCambios?: Record<string, Map<string, UltimoCambioMonday>>
}) {
return {
async getLeads() {
return datos.leads ?? []
},
async getOportunidades() {
return datos.oportunidades ?? []
},
async getActividades() {
return datos.actividades ?? []
},
async getApartados() {
return datos.apartados ?? []
},
async getUltimosCambios(boardId: string, _itemIds: string[]) {
return datos.ultimosCambios?.[boardId] ?? new Map<string, UltimoCambioMonday>()
},
}
}
const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor', mondayUserId: MI_ID }
const sinMapeo: UsuarioMiDia = { nombre: 'Erick Peña', rol: 'vendedor', mondayUserId: null }
const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null }
describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('misLeads: merge de Leads (Vendedor=mío) y Oportunidades (Asesor=mío), sin terminales, con fuente, por updatedAt desc', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1', estado: 'Nuevo', updatedAt: '2026-08-26T10:00:00Z' }),
lead({ id: 'l2', estado: 'Contactado', telefono: '4771234567', updatedAt: '2026-08-28T08:00:00Z' }),
lead({ id: 'l3', estado: 'Descartado' }), // terminal Leads: fuera
lead({ id: 'l4', estado: 'Calificado' }), // terminal Leads: fuera
lead({ id: 'l5', estado: 'Nuevo', vendedorIds: [OTRO_ID] }), // de otro: fuera
],
oportunidades: [
oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z' }),
oportunidad({ id: 'o2', etapa: 'Calificado' }), // NO es terminal en Oportunidades
oportunidad({ id: 'o3', etapa: 'Perdido' }),
oportunidad({ id: 'o4', etapa: 'Abandono' }),
oportunidad({ id: 'o5', etapa: 'Ganado' }),
oportunidad({ id: 'o6', etapa: 'Afluencia', asesorIds: [OTRO_ID] }),
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(false)
expect(r.misLeads.map((l) => l.id)).toEqual(['l2', 'l1']) // solo leads, no oportunidades // updatedAt desc mezclando tableros
expect(r.misLeads.map((l) => l.fuente)).toEqual(['lead', 'lead']) // solo leads
expect(r.misLeads[0]?.telefono).toBe('4771234567')
})
it('ultimoCambio: último evento por item, agrupando ids por fuente y resolviendo el nombre', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1', updatedAt: '2026-08-28T08:00:00Z' })],
oportunidades: [oportunidad({ id: 'o1', updatedAt: '2026-08-27T08:00:00Z' })],
ultimosCambios: {
'444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: MI_ID, evento: 'update_status' }]]),
'555': new Map([['o1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_pulse' }]]),
},
})
const nombres = new Map([[MI_ID, 'Martin Zavala']]) // OTRO_ID no está mapeado
const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
expect(r.misLeads.find((l) => l.id === 'l1')?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T10:00:00.000Z',
usuario: 'Martin Zavala',
detalle: 'Actualización',
})
// 'o1' ya no esta en misLeads (es oportunidad)
})
it('ultimoCambio: null cuando el item no tiene eventos en el activity log', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1' })],
ultimosCambios: { '444': new Map() }, // sin eventos
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toBeNull()
})
it('ultimoCambio: usuarioId null (evento de automatización) → usuario null', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1' })],
ultimosCambios: {
'444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: null, evento: 'move_to_group' }]]),
},
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T10:00:00.000Z',
usuario: null,
detalle: 'Cambio de grupo',
})
})
it('detalleEventoUltimoCambio: humaniza los eventos conocidos y pasa el crudo si no', () => {
expect(detalleEventoUltimoCambio('create_pulse')).toBe('Alta')
expect(detalleEventoUltimoCambio('create_update')).toBe('Nota agregada')
expect(detalleEventoUltimoCambio('update_column_value')).toBe('Actualización')
expect(detalleEventoUltimoCambio('update_status')).toBe('Actualización')
expect(detalleEventoUltimoCambio('move_to_group')).toBe('Cambio de grupo')
expect(detalleEventoUltimoCambio('update_name')).toBe('update_name')
})
it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => {
const monday = mondayMock({
oportunidades: [
oportunidad({ id: '1', etapa: 'Afluencia' }),
oportunidad({ id: '2', etapa: 'Afluencia' }),
oportunidad({ id: '3', etapa: 'Ganado' }),
oportunidad({ id: '4', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: no cuenta
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2, Ganado: 1 })
})
it('misOportunidades.items: lista de trabajo sin terminales, mías, por updatedAt desc, con perfil y último cambio', async () => {
const monday = mondayMock({
oportunidades: [
oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z', desarrollo: 'SENDA', modeloInteres: 'SOHO' }),
oportunidad({ id: 'o2', etapa: 'Cotizador', updatedAt: '2026-08-28T08:00:00Z', telefono: '4771234567', email: 'a@b.c', unidadCodigo: 'U-9' }),
oportunidad({ id: 'o3', etapa: 'Ganado' }), // terminal: fuera de la lista (sí cuenta en porEtapa)
oportunidad({ id: 'o4', etapa: 'Abandono' }), // terminal: fuera
oportunidad({ id: 'o5', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: fuera
oportunidad({ id: 'o6', etapa: 'Aparto' }), // ya es apartado: sale de la lista (sigue en Mis apartados)
],
ultimosCambios: {
'555': new Map([['o2', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: MI_ID, evento: 'update_column_value' }]]),
},
})
const nombres = new Map([[MI_ID, 'Martin Zavala']])
const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['o2', 'o1'])
const o2 = r.misOportunidades.items[0]
expect(o2?.etapa).toBe('Cotizador')
expect(o2?.telefono).toBe('4771234567')
expect(o2?.email).toBe('a@b.c')
expect(o2?.unidadCodigo).toBe('U-9')
expect(o2?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T09:00:00.000Z',
usuario: 'Martin Zavala',
detalle: 'Actualización',
})
const o1 = r.misOportunidades.items[1]
expect(o1?.desarrollo).toBe('SENDA')
expect(o1?.modeloInteres).toBe('SOHO')
expect(o1?.ultimoCambio).toBeNull()
})
it('misApartados: Vendedor = yo, sin estados finales, por fecha límite asc (sin fecha al final), con último cambio', async () => {
const monday = mondayMock({
apartados: [
apartado({ id: 'p1', fechaLimite: '2026-09-05' }),
apartado({ id: 'p2', fechaLimite: '2026-09-01', estado: 'Cita Clientes' }),
apartado({ id: 'p3', fechaLimite: '', estado: 'Alta Enkontrol' }),
apartado({ id: 'p4', estado: 'Escriturado' }), // final: fuera
apartado({ id: 'p5', estado: 'Cancelacion Apartado' }), // final: fuera
apartado({ id: 'p6', vendedorIds: [OTRO_ID] }), // de otro: fuera
],
ultimosCambios: {
'666': new Map([['p1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_update' }]]),
},
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misApartados.map((a) => a.id)).toEqual(['p2', 'p1', 'p3'])
expect(r.misApartados[0]?.estado).toBe('Cita Clientes')
expect(r.misApartados[1]?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T09:00:00.000Z',
usuario: null, // OTRO_ID no está en el mapa de nombres
detalle: 'Nota agregada',
})
})
it('esEstadoFinalApartado: venta y cancelación cierran el pipeline', () => {
expect(esEstadoFinalApartado('Alta Enkontrol')).toBe(false)
expect(esEstadoFinalApartado('Cancelacion Apartado')).toBe(true)
expect(esEstadoFinalApartado('Escriturado')).toBe(true)
expect(esEstadoFinalApartado('Pago Apartado')).toBe(false)
expect(esEstadoFinalApartado('Contrato Enkontrol')).toBe(false)
})
it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
const monday = mondayMock({
actividades: [
actividad({ id: 'a1', fecha: HOY }),
actividad({ id: 'a2', fecha: MANANA, tipo: 'Reunion' }),
actividad({ id: 'a3', fecha: '2026-08-20' }), // vencida: también entra
actividad({ id: 'a4', fecha: '2026-09-05' }), // futura lejana: no
actividad({ id: 'a5', fecha: HOY, estado: 'Listo' }), // cerrada: no
actividad({ id: 'a6', fecha: HOY, responsableIds: [OTRO_ID] }), // de otro: no
actividad({ id: 'a7', fecha: '' }), // sin fecha: no
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
// Ordenadas por fecha: vencida primero
expect(r.misActividadesHoy.map((a) => a.id)).toEqual(['a3', 'a1', 'a2'])
expect(r.conteos.actividadesAbiertas).toBe(5) // abiertas mías: a1,a2,a3,a4,a7
})
it('apartados por vencer: próximos 7 días, excluye estados finales', async () => {
const monday = mondayMock({
apartados: [
apartado({ id: 'p1', fechaLimite: HOY }), // vence hoy
apartado({ id: 'p2', fechaLimite: '2026-09-04' }), // justo en 7 días
apartado({ id: 'p3', fechaLimite: '2026-09-05' }), // fuera de rango
apartado({ id: 'p4', fechaLimite: '2026-08-27' }), // ya venció
apartado({ id: 'p5', fechaLimite: '2026-09-01', estado: 'Escriturado' }),
apartado({ id: 'p6', fechaLimite: '2026-09-01', estado: 'Cancelacion' }),
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.apartadosPorVencer.map((a) => a.id)).toEqual(['p1', 'p2'])
expect(r.apartadosPorVencer[0]?.diasRestantes).toBe(0)
expect(r.apartadosPorVencer[1]?.diasRestantes).toBe(7)
})
it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => {
const monday = mondayMock({
oportunidades: [oportunidad({ id: '1' })],
actividades: [actividad({ id: 'a1' })],
apartados: [apartado({ id: 'p1' })],
})
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(true)
expect(r.misLeads).toEqual([])
expect(r.misOportunidades).toEqual({ porEtapa: {}, items: [] })
expect(r.misApartados).toEqual([])
expect(r.misActividadesHoy).toEqual([])
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
// Los apartados por vencer son del pipeline completo: sí se muestran
expect(r.apartadosPorVencer).toHaveLength(0) // solo apartados propios
})
it('conteos: leads nuevos de los últimos 7 días suman AMBOS tableros (incluye terminales recientes)', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1', createdAt: '2026-08-28T08:00:00' }), // hoy
lead({ id: 'l2', createdAt: '2026-08-10T08:00:00' }), // viejo
],
oportunidades: [
oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
oportunidad({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
oportunidad({ id: '4', createdAt: '2026-08-27T08:00:00', asesorIds: [OTRO_ID] }), // de otro
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(3) // 1 lead + 2 oportunidades
})
it('vendedor no ve sección equipo; coordinador sí (conteos por persona de AMBOS tableros y sin asignar)', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1', vendedorIds: [] }), // sin asignar, Nuevo
lead({ id: 'l2', vendedorIds: [], estado: 'Contactado' }), // sin asignar, no Nuevo
lead({ id: 'l3', vendedorIds: [OTRO_ID] }),
],
oportunidades: [
oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia
oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia
oportunidad({ id: '3', asesorIds: [MI_ID] }),
oportunidad({ id: '4', asesorIds: [MI_ID], etapa: 'Perdido' }), // terminal: no cuenta
oportunidad({ id: '5', asesorIds: [OTRO_ID] }),
oportunidad({ id: '6', asesorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
],
})
const nombres = new Map([[MI_ID, 'Martin Zavala']])
const rVendedor = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
expect(rVendedor.equipo).toBeUndefined()
const rCoord = await resumenMiDia(monday, coordinador, nombres, [], [], AHORA)
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(2) // 1 lead Nuevo + 1 afluencia sin asignar
expect(rCoord.equipo?.porVendedor).toEqual([
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 3, leadsNuevosSemana: 2 }, // 1 lead + 2 ops (1 vieja)
{ mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 },
])
})
it('corporativo y admin también ven equipo', async () => {
const monday = mondayMock({ oportunidades: [oportunidad({ id: '1' })] })
for (const rol of ['corporativo', 'admin']) {
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA)
expect(r.equipo).toBeDefined()
}
})
it('mis capturas: items en misLeads/misOportunidades no se duplican en misCapturas', async () => {
const monday = mondayMock({
oportunidades: [oportunidad({ id: '100', nombre: 'Ana López', etapa: 'Contactado', origen: 'WhatsApp', asesorIds: [OTRO_ID] })],
})
const capturas = [
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
// La captura ya aparece en misLeads (via esMio → idsCapturados), asi que no se duplica
expect(r.misCapturas).toEqual([])
})
it('mis capturas: captura rapida no se duplica si ya esta en misLeads', async () => {
const monday = mondayMock({
leads: [lead({ id: 'lead-9', nombre: 'Pepe Rápido', estado: 'Contactado', origen: 'Web', vendedorIds: [OTRO_ID] })],
})
const capturas = [
{ leadId: 'lead-9', nombreLead: 'Pepe Rápido', telefono: '4771112233', createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
// La captura ya aparece en misLeads (via esMio → idsCapturados), asi que no se duplica
expect(r.misCapturas).toEqual([])
})
it('mis capturas: si el item ya no existe en ningún tablero, estado y origen van vacíos', async () => {
const monday = mondayMock({})
const capturas = [
{ leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas).toEqual([
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
])
})
it('mis capturas: ordenadas por fecha de captura descendente', async () => {
const monday = mondayMock({})
const capturas = [
{ leadId: '1', nombreLead: 'Antiguo', telefono: null, createdAt: new Date('2026-08-20T09:00:00Z') },
{ leadId: '2', nombreLead: 'Reciente', telefono: null, createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas.map((c) => c.leadId)).toEqual(['2', '1'])
})
it('sin capturas: misCapturas va vacío', async () => {
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
expect(r.misCapturas).toEqual([])
})
it('mis clientes: mapeo con completado según proyecto (vacío = en captura)', async () => {
const clientes = [
{
id: 'c1',
nombreCompleto: 'Ana En Captura',
proyecto: '',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-08-26T09:00:00Z'),
},
{
id: 'c2',
nombreCompleto: 'Luis Completado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
mondayApartadoId: null,
createdAt: new Date('2026-08-27T09:00:00Z'),
},
]
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
expect(r.misClientes).toEqual([
{
id: 'c2',
nombre: 'Luis Completado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
completado: true,
capturadoEn: '2026-08-27T09:00:00.000Z',
estadoApartado: null,
},
{
id: 'c1',
nombre: 'Ana En Captura',
proyecto: '',
unidadCodigo: '',
completado: false,
capturadoEn: '2026-08-26T09:00:00.000Z',
estadoApartado: null,
},
])
})
it('mis clientes: estadoApartado cruza mondayApartadoId contra el tablero Apartado', async () => {
const clientes = [
{
id: 'c1',
nombreCompleto: 'Con Apartado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
mondayApartadoId: 'ap-1',
createdAt: new Date('2026-08-27T09:00:00Z'),
},
{
id: 'c2',
nombreCompleto: 'Apartado Borrado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-102',
mondayApartadoId: 'ap-fantasma', // ya no existe en Monday
createdAt: new Date('2026-08-26T09:00:00Z'),
},
{
id: 'c3',
nombreCompleto: 'Sin Apartado',
proyecto: '',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-08-25T09:00:00Z'),
},
]
const monday = mondayMock({ apartados: [apartado({ id: 'ap-1', estado: 'Alta Enkontrol' })] })
const r = await resumenMiDia(monday, vendedor, new Map(), [], clientes, AHORA)
expect(r.misClientes.map((c) => [c.id, c.estadoApartado])).toEqual([
['c1', 'Alta Enkontrol'], // estado real del item en Monday
['c2', null], // el apartado ya no existe
['c3', null], // sin apartado ligado
])
})
it('mis clientes: proyecto con solo espacios cuenta como en captura', async () => {
const clientes = [
{
id: 'c1',
nombreCompleto: 'Espacios',
proyecto: ' ',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-08-26T09:00:00Z'),
},
]
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
expect(r.misClientes[0]?.completado).toBe(false)
})
it('sin clientes: misClientes va vacío (no requiere Monday)', async () => {
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
expect(r.misClientes).toEqual([])
})
})
describe('clasificadores de estado', () => {
it('etapas terminales de oportunidad (case-insensitive)', () => {
@@ -526,7 +15,7 @@ describe('clasificadores de estado', () => {
expect(esEtapaTerminal('Intento de contacto')).toBe(false)
})
it('estados terminales de lead (tablero Leads, case-insensitive)', () => {
it('estados terminales de lead (captura ligera, case-insensitive)', () => {
expect(esEstadoTerminalLead('DESCARTADO')).toBe(true)
expect(esEstadoTerminalLead('calificado')).toBe(true)
expect(esEstadoTerminalLead('Abandono')).toBe(true)
@@ -540,65 +29,3 @@ describe('clasificadores de estado', () => {
expect(esEstadoFinalApartado('Pago Apartado')).toBe(false)
})
})
describe('resumenMiDia — cuenta compartida / uso solo-app (sin Monday)', () => {
const COMPARTIDO = 999000111
it('sin vínculo Monday: ve lo que capturó en la app (registro local)', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1' }), // capturado por mí (abajo)
lead({ id: 'l2' }), // no es mío
],
oportunidades: [oportunidad({ id: 'o1', updatedAt: '2026-08-28T10:00:00Z' })], // capturada por mí
})
const capturas = [
{ leadId: 'l1', nombreLead: 'Lead l1', telefono: null, createdAt: new Date('2026-08-27') },
{ leadId: 'o1', nombreLead: 'Lead o1', telefono: null, createdAt: new Date('2026-08-27') },
]
const r = await resumenMiDia(monday, sinMapeo, new Map(), capturas, [], AHORA)
expect(r.misLeads.map((l) => l.id)).toEqual(['l1']) // solo leads
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['o1'])
})
it('cuenta compartida: NO ve lo asignado a la cuenta compartida, solo su captura local', async () => {
const compartido: UsuarioMiDia = { nombre: 'Vendedor Piso', rol: 'vendedor', mondayUserId: COMPARTIDO }
const monday = mondayMock({
leads: [
lead({ id: 'l1', vendedorIds: [COMPARTIDO] }), // asignado a la cuenta compartida, capturado por otro
lead({ id: 'l2', vendedorIds: [COMPARTIDO] }), // asignado a la compartida, capturado por MÍ
],
})
const capturas = [{ leadId: 'l2', nombreLead: 'Lead l2', telefono: null, createdAt: new Date('2026-08-27') }]
const r = await resumenMiDia(monday, compartido, new Map(), capturas, [], AHORA, COMPARTIDO)
expect(r.misLeads.map((l) => l.id)).toEqual(['l2']) // l1 queda fuera
})
it('cuenta NO compartida: ve capturas + asignados (comportamiento de siempre)', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1', vendedorIds: [MI_ID] }), // asignado a mí
lead({ id: 'l2', vendedorIds: [OTRO_ID] }), // capturado por mí (abajo)
lead({ id: 'l3', vendedorIds: [OTRO_ID] }), // ni asignado ni capturado
],
})
const capturas = [{ leadId: 'l2', nombreLead: 'Lead l2', telefono: null, createdAt: new Date('2026-08-27') }]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA, COMPARTIDO)
expect(r.misLeads.map((l) => l.id).sort()).toEqual(['l1', 'l2'])
})
it('mis apartados incluye los ligados a mis altas de cliente aunque el Vendedor sea la cuenta compartida', async () => {
const compartido: UsuarioMiDia = { nombre: 'Vendedor Piso', rol: 'vendedor', mondayUserId: COMPARTIDO }
const monday = mondayMock({
apartados: [
apartado({ id: 'p1', vendedorIds: [COMPARTIDO] }), // apartado de MI alta (abajo)
apartado({ id: 'p2', vendedorIds: [COMPARTIDO] }), // apartado de otro
],
})
const clientes = [
{ id: 'c1', nombreCompleto: 'Cliente p1', proyecto: 'SENDA', unidadCodigo: 'U-1', mondayApartadoId: 'p1', createdAt: new Date('2026-08-27') },
]
const r = await resumenMiDia(monday, compartido, new Map(), [], clientes, AHORA, COMPARTIDO)
expect(r.misApartados.map((a) => a.id)).toEqual(['p1'])
})
})

View File

@@ -1,16 +1,8 @@
import type { MondayService } from './monday.service.js'
import { config } from '../config/index.js'
/**
* Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy.
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
* "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna
* Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES
* la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro
* local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario
* dio de alta, con su estado actual (cruza contra ambos tableros por id).
* 100% local: todo se construye desde la tabla LeadCaptura (los tabs del
* embudo), Cliente (mis clientes) y ActividadLocal (agenda). El "mío" se
* resuelve por el usuarioId de cada fila.
*/
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
@@ -42,10 +34,32 @@ export const ESTADOS_FINALES_APARTADO = [
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
const MS_DIA = 24 * 60 * 60 * 1000
/** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */
function fechaLocal(base: Date, dias: number): string {
const d = new Date(base.getTime() + dias * MS_DIA)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
export function esEstadoTerminalLead(estado: string): boolean {
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
}
export function esEtapaTerminal(etapa: string): boolean {
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
}
export function esEstadoFinalApartado(estado: string): boolean {
return ESTADOS_FINALES_APARTADO.includes(estado.trim().toUpperCase())
}
export interface UsuarioMiDia {
nombre: string
rol: string
mondayUserId: number | null
}
export interface LeadMiDia {
@@ -57,12 +71,10 @@ export interface LeadMiDia {
origen: string
desarrollo?: string
updatedAt: string
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
/** Último evento del activity log del board; null si el item no tiene eventos */
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ActividadMiDia {
@@ -86,11 +98,9 @@ export interface CapturaMiDia {
leadId: string
nombre: string
telefono: string
estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board
estado: string
origen: string
capturadoEn: string // ISO
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ClienteMiDia {
@@ -100,7 +110,7 @@ export interface ClienteMiDia {
unidadCodigo: string
completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE)
capturadoEn: string // ISO
/** Estado actual del item en el tablero Apartado (bidireccional Monday→app); null si no hay apartado ligado o ya no existe */
/** Estado actual del apartado ligado; null si no hay apartado ligado */
estadoApartado: string | null
}
@@ -115,12 +125,10 @@ export interface OportunidadMiDia {
modeloInteres: string
unidadCodigo: string
updatedAt: string
/** Último evento del activity log del board; null si el item no tiene eventos */
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
fechaCita?: string | undefined
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
@@ -130,15 +138,12 @@ export interface ApartadoMiDia {
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Último evento del activity log del board; null si el item no tiene eventos */
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
misLeads: LeadMiDia[]
misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[]
@@ -158,7 +163,7 @@ export interface ResumenMiDia {
equipo?: {
leadsNuevosSinAsignar: number
porVendedor: {
mondayUserId: number
usuarioId: string
nombre: string | null
leadsAsignados: number
/** Leads del vendedor creados en los últimos 7 días naturales */
@@ -167,407 +172,6 @@ export interface ResumenMiDia {
}
}
type MondayMiDia = Pick<
MondayService,
'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados' | 'getUltimosCambios'
>
const MS_DIA = 24 * 60 * 60 * 1000
/** Texto legible del evento del activity log (los nombres de columna no vienen legibles en el payload) */
export function detalleEventoUltimoCambio(evento: string): string {
switch (evento) {
case 'create_pulse':
return 'Alta'
case 'create_update':
return 'Nota agregada'
case 'update_column_value':
case 'update_status':
return 'Actualización'
case 'move_to_group':
return 'Cambio de grupo'
default:
return evento
}
}
/** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */
function fechaLocal(base: Date, dias: number): string {
const d = new Date(base.getTime() + dias * MS_DIA)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
export function esEstadoTerminalLead(estado: string): boolean {
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
}
export function esEtapaTerminal(etapa: string): boolean {
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
}
export function esEstadoFinalApartado(estado: string): boolean {
return ESTADOS_FINALES_APARTADO.includes(estado.trim().toUpperCase())
}
export async function resumenMiDia(
monday: MondayMiDia,
usuario: UsuarioMiDia,
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
nombresPorMondayId: Map<number, string>,
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
capturas: {
leadId: string
nombreLead: string
telefono: string | null
createdAt: Date
/** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */
oportunidadId?: string | null
}[] = [],
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
clientes: {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
mondayApartadoId: string | null
createdAt: Date
}[] = [],
ahora: Date = new Date(),
/** User ID de la cuenta Monday COMPARTIDA de vendedores (config); con ella la
* columna people ya no distingue personas y "lo mío" viene de la captura local */
compartidoId: number | null = null,
/** ids de items Monday capturados por el usuario (leads + oportunidades
* mapeadas); default: los leadId de `capturas` */
capturadosIds?: Set<string>,
): Promise<ResumenMiDia> {
const miId = usuario.mondayUserId
const sinMapeo = miId === null
// "Mío" = lo que capturé en la app (registro local) o lo que Monday me tiene
// asignado (columna people). Con la cuenta compartida, people ya no distingue
// personas: solo cuenta la captura local. Funciona SIN vínculo Monday.
const idsCapturados = capturadosIds ?? new Set(capturas.map((c) => c.leadId))
const esCompartido = miId !== null && compartidoId !== null && compartidoId > 0 && miId === compartidoId
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
const esMio = (peopleIds: number[], itemId: string): boolean => {
if (esAdmin) return true
if (idsCapturados.has(itemId)) return true
if (esCompartido || miId === null) return false
return peopleIds.includes(miId)
}
const [leads, oportunidades, actividades, apartados] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
monday.getActividades(),
monday.getApartados(),
])
const hoy = fechaLocal(ahora, 0)
const manana = fechaLocal(ahora, 1)
const en7Dias = fechaLocal(ahora, 7)
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (por captura local + asignación Monday; ver esMio) ---
// "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna
// Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES
// la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro
// local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario
// dio de alta, con su estado actual (cruza contra ambos tableros por id).
const misLeadsLeads: LeadMiDia[] = leads
.filter((l) => esMio(l.vendedorIds, l.id) && !esEstadoTerminalLead(l.estado))
.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
desarrollo: l.desarrollo ?? '',
updatedAt: l.updatedAt,
fuente: 'lead',
ultimoCambio: null,
}))
const mios = oportunidades.filter((o) => esMio(o.asesorIds, o.id))
const misLeadsOportunidades: LeadMiDia[] = mios
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
desarrollo: o.desarrollo ?? '',
updatedAt: o.updatedAt,
fuente: 'oportunidad',
ultimoCambio: null,
}))
// "Mis leads" solo muestra items del tablero Leads (captura ligera).
// Las oportunidades van exclusivamente en "Mis oportunidades".
const misLeads: LeadMiDia[] = misLeadsLeads.sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
)
// "Último cambio" por item: UNA llamada al activity log por tablero (máx 3),
// agrupando los ids por fuente. El usuario se resuelve con el mapa
// mondayUserId → nombre que pasa el controller (ecosistema + workspace).
const itemsOportunidades: OportunidadMiDia[] = mios
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
etapa: o.etapa,
desarrollo: o.desarrollo ?? '',
modeloInteres: o.modeloInteres ?? '',
unidadCodigo: o.unidadCodigo,
updatedAt: o.updatedAt,
ultimoCambio: null,
fechaCita: o.fechaCita,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
// Apartados ligados a MIS altas de cliente (registro local): con la cuenta
// compartida o sin vínculo Monday, esta liga local es la que personaliza
const apartadosDeMisClientes = new Set(
clientes.map((c) => c.mondayApartadoId).filter((id): id is string => id !== null),
)
// Set de oportunidades mías (por LeadCaptura o por vendedorIds)
const miosOportunidadesIds = new Set(
capturas
.filter((c) => c.oportunidadId)
.map((c) => c.oportunidadId)
.filter((id): id is string => id != null),
)
const miosApartadosTodos = apartados.filter((a) =>
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
)
const miosApartados = miosApartadosTodos.filter((a) => !esEstadoFinalApartado(a.estado))
const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([
monday.getUltimosCambios(
config.monday.boards.leads,
misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id),
),
monday.getUltimosCambios(
config.monday.boards.oportunidades,
itemsOportunidades.map((o) => o.id),
),
monday.getUltimosCambios(
config.monday.boards.apartado,
miosApartados.map((a) => a.id),
),
])
const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades }
for (const lead of misLeads) {
const cambio = cambiosPorFuente[lead.fuente].get(lead.id)
lead.ultimoCambio = cambio
? {
fechaISO: cambio.fechaISO,
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
detalle: detalleEventoUltimoCambio(cambio.evento),
}
: null
}
for (const op of itemsOportunidades) {
const cambio = cambiosOportunidades.get(op.id)
op.ultimoCambio = cambio
? {
fechaISO: cambio.fechaISO,
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
detalle: detalleEventoUltimoCambio(cambio.evento),
}
: null
}
// "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol,
// por fecha límite ascendente (sin fecha al final)
// Todos los apartados míos incluyendo terminales (para pestaña Escrituración);
// se calcula desde la lista ANTES de excluir estados finales
const todosApartados: ApartadoMiDia[] = miosApartadosTodos
.map((a) => ({
id: a.id,
nombre: a.nombre,
unidadCodigo: a.unidadCodigo,
estado: a.estado,
fechaLimite: a.fechaLimite,
ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null,
// Los apartados leídos de Monday ya están sincronizados por definición
pendienteSync: false,
}))
const misApartados: ApartadoMiDia[] = miosApartados
.map((a) => {
const cambio = cambiosApartados.get(a.id)
return {
id: a.id,
nombre: a.nombre,
unidadCodigo: a.unidadCodigo,
estado: a.estado,
fechaLimite: a.fechaLimite,
ultimoCambio: cambio
? {
fechaISO: cambio.fechaISO,
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
detalle: detalleEventoUltimoCambio(cambio.evento),
}
: null,
}
})
.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31'))
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
const porEtapa: Record<string, number> = {}
for (const o of mios) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday,
// cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a
// Leads; las de Alta Afluencia, a Oportunidades), más reciente primero
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o]))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => {
const lead = leadsPorId.get(c.leadId)
const op = oportunidadesPorId.get(c.leadId)
return {
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: lead?.estado ?? op?.etapa ?? '',
origen: lead?.origen ?? op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
}
})
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// Excluir de "Mis capturas" los que ya aparecen en "Mis leads" o "Mis
// oportunidades" para no duplicar visualmente cuando el lead existe en
// Monday Y tambien en LeadCaptura (caso creacion con Monday activo).
const idsEnOtrosTabs = new Set([
...misLeads.map((l) => l.id),
...mios.map((o) => o.id),
])
const misCapturasSinDuplicados = misCapturas.filter((c) => !idsEnOtrosTabs.has(c.leadId))
// "Mis clientes" es local + cruce con Monday: el alta en progreso lleva
// proyecto '', el alta completada lleva el proyecto lleno. estadoApartado
// cruza Cliente.mondayApartadoId contra el tablero Apartado (null si no hay
// apartado ligado o ya no existe en Monday)
const apartadosPorId = new Map(apartados.map((a) => [a.id, a]))
const misClientes: ClienteMiDia[] = clientes
.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
proyecto: c.proyecto,
unidadCodigo: c.unidadCodigo ?? '',
completado: c.proyecto.trim() !== '',
capturadoEn: c.createdAt.toISOString(),
estadoApartado:
c.mondayApartadoId != null ? (apartadosPorId.get(c.mondayApartadoId)?.estado ?? null) : null,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId))
const misActividadesHoy: ActividadMiDia[] = misActividades
.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana)
.sort((a, b) => a.fecha.localeCompare(b.fecha))
.map((a) => ({ id: a.id, nombre: a.nombre, tipo: a.tipo, fecha: a.fecha, estado: a.estado }))
// Apartados por vencer es del pipeline completo (Enkontrol no lleva asesor people)
const apartadosPorVencer: ApartadoPorVencer[] = apartados
.filter(
(a) =>
esMio(a.vendedorIds, a.id) &&
a.fechaLimite !== '' &&
a.fechaLimite >= hoy &&
a.fechaLimite <= en7Dias &&
!esEstadoFinalApartado(a.estado),
)
.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite))
.map((a) => ({
id: a.id,
nombre: a.nombre,
unidadCodigo: a.unidadCodigo,
fechaLimite: a.fechaLimite,
estado: a.estado,
diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA),
}))
// Conteo de la semana: míos en AMBOS tableros (leads + oportunidades)
const miosLeadRows = leads.filter((l) => esMio(l.vendedorIds, l.id))
const conteos = {
leadsNuevosSemana:
miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length +
mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length,
actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length,
}
const resumen: ResumenMiDia = {
generadoEn: ahora.toISOString(),
sinMapeoMonday: sinMapeo,
misLeads,
misCapturas: misCapturasSinDuplicados,
misClientes,
misActividadesHoy,
apartadosPorVencer,
misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados,
todosApartados,
conteos,
}
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
// Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor
// (etapa Afluencia) — lo que el round-robin aún no reparte
const sinAsignar =
leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO')
.length +
oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length
const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
// Leads y oportunidades comparten el conteo por vendedor (misma persona Monday)
for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
for (const o of oportunidades) {
if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
for (const id of o.asesorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
resumen.equipo = {
leadsNuevosSinAsignar: sinAsignar,
porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({
mondayUserId,
nombre: nombresPorMondayId.get(mondayUserId) ?? null,
leadsAsignados,
leadsNuevosSemana: nuevosPorId.get(mondayUserId) ?? 0,
}))
.sort((a, b) => b.leadsAsignados - a.leadsAsignados),
}
}
return resumen
}
// ---------- Modo local (Monday caído) y unión durante la transición ----------
/**
* Fila local del embudo (tabla LeadCaptura) tal como la necesita Mi día. El
@@ -590,14 +194,14 @@ export interface FilaLocalMiDia {
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) */
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
mondayItemId: string | null
pendienteSync: boolean
createdAt: Date
updatedAt: Date
}
/** Cliente local tal como lo necesita el modo local (misma forma que en resumenMiDia) */
/** Cliente local tal como lo necesita el resumen */
export interface ClienteLocalMiDia {
id: string
nombreCompleto: string
@@ -643,12 +247,12 @@ export interface UsuarioLocal {
}
/**
* 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.
* ResumenMiDia COMPLETO construido solo con datos locales: Mis leads (board
* LEADS, etapa no terminal), Mis oportunidades (board OPORTUNIDADES), Mis
* apartados (board APARTADO + unidad ligada vía Cliente), Mis capturas, Mis
* clientes.
*
* Parámetros opcionales para el modo local 100%:
* Parámetros opcionales:
* - `actividades`: actividades locales del usuario (tabla ActividadLocal)
* - `usuarios`: todos los usuarios activos (para la sección equipo)
*/
@@ -685,7 +289,7 @@ export function resumenMiDiaLocal(
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
// (oportunidades se muestran exclusivamente en misOportunidades)
// Conteo por etapa de TODAS las oportunidades (incluye terminales, como Monday)
// Conteo por etapa de TODAS las oportunidades (incluye terminales)
const porEtapa: Record<string, number> = {}
for (const o of oportunidadesLocales) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
@@ -855,7 +459,7 @@ export function resumenMiDiaLocal(
(c) => c.usuarioId === u.id && c.createdAt.getTime() >= hace7DiasMs,
).length
return {
mondayUserId: 0,
usuarioId: u.id,
nombre: u.nombre,
leadsAsignados,
leadsNuevosSemana,
@@ -871,7 +475,6 @@ export function resumenMiDiaLocal(
return {
generadoEn: ahora.toISOString(),
sinMapeoMonday: usuario.mondayUserId === null,
misLeads,
misCapturas: misCapturasSinDuplicadosLocal,
misClientes,
@@ -900,105 +503,3 @@ export function resumenMiDiaLocal(
...(equipo !== undefined ? { equipo } : {}),
}
}
/**
* Unión durante la transición (Monday vivo): agrega a las listas del resumen
* los items locales con pendienteSync=true que Monday aún no conoce (ids
* loc_*), con el flag pendienteSync para el badge ⏳ del front. Si la fila
* local ya tiene mondayItemId (cambio encolado sobre un item de Monday), no se
* duplica: solo se marca el item existente. También marca/rellena Mis capturas
* (estado local cuando Monday aún no lo tiene). Muta y devuelve `resumen`.
*/
export function unirPendientesLocales(
resumen: ResumenMiDia,
pendientes: FilaLocalMiDia[],
nombreUsuario: string,
): ResumenMiDia {
const leadsPorId = new Map(resumen.misLeads.map((l) => [l.id, l]))
const oportunidadesPorId = new Map(resumen.misOportunidades.items.map((o) => [o.id, o]))
const apartadosPorId = new Map(resumen.misApartados.map((a) => [a.id, a]))
const capturasPorId = new Map(resumen.misCapturas.map((c) => [c.leadId, c]))
for (const p of pendientes) {
// Mis capturas: flag + estado/origen local cuando Monday aún no lo refleja
const captura = capturasPorId.get(p.leadId)
if (captura) {
captura.pendienteSync = true
if (captura.estado === '') captura.estado = p.etapa
if (captura.origen === '') captura.origen = p.origen ?? ''
}
if (p.mondayItemId !== null) {
// Cambio pendiente sobre un item que Monday ya muestra: solo se marca
const existente =
leadsPorId.get(p.mondayItemId) ??
oportunidadesPorId.get(p.mondayItemId) ??
apartadosPorId.get(p.mondayItemId)
if (existente) existente.pendienteSync = true
continue
}
// Item puramente local (loc_*): se agrega a la lista de su board
if (p.board === 'LEADS' && !esEstadoTerminalLead(p.etapa)) {
resumen.misLeads.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
estado: p.etapa,
origen: p.origen ?? '',
updatedAt: p.updatedAt.toISOString(),
fuente: 'lead',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
} else if (p.board === 'OPORTUNIDADES') {
// porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday)
const etapa = p.etapa.trim() === '' ? '(sin etapa)' : p.etapa.trim()
resumen.misOportunidades.porEtapa[etapa] = (resumen.misOportunidades.porEtapa[etapa] ?? 0) + 1
if (!esEtapaTerminal(p.etapa)) {
resumen.misLeads.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
estado: p.etapa,
origen: p.origen ?? '',
updatedAt: p.updatedAt.toISOString(),
fuente: 'oportunidad',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
resumen.misOportunidades.items.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
etapa: p.etapa,
desarrollo: p.desarrollo ?? '',
modeloInteres: p.modelo ?? '',
unidadCodigo: '',
updatedAt: p.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
fechaCita: (() => { try { const d = JSON.parse(p.datosJson ?? '{}') as Record<string, unknown>; return typeof d.fechaCita === 'string' ? d.fechaCita : undefined } catch { return undefined } })(),
pendienteSync: true,
})
}
} else if (p.board === 'APARTADO' && !esEstadoFinalApartado(p.etapa)) {
resumen.misApartados.push({
id: p.leadId,
nombre: p.nombreLead,
unidadCodigo: '',
estado: p.etapa,
fechaLimite: '',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
}
}
resumen.misLeads.sort(porUpdatedAtDesc)
resumen.misOportunidades.items.sort(porUpdatedAtDesc)
resumen.misApartados.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31'))
return resumen
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,6 @@ import {
registrarSuscripcion,
eliminarSuscripcion,
enviarPush,
usuarioIdPorMondayUserId,
obtenerVapidPublicKey,
} from './push.service.js'
@@ -118,20 +117,6 @@ describe('push.service', () => {
})
})
describe('usuarioIdPorMondayUserId', () => {
it('devuelve el usuarioId si encuentra el usuario', async () => {
prismaMock.usuario.findFirst.mockResolvedValue({ id: 'local-user-1' })
const result = await usuarioIdPorMondayUserId(12345)
expect(result).toBe('local-user-1')
})
it('devuelve null si no encuentra el usuario', async () => {
prismaMock.usuario.findFirst.mockResolvedValue(null)
const result = await usuarioIdPorMondayUserId(99999)
expect(result).toBeNull()
})
})
describe('obtenerVapidPublicKey', () => {
it('devuelve la VAPID public key del config', () => {
expect(obtenerVapidPublicKey()).toBe('test-public-key')

View File

@@ -45,15 +45,6 @@ export function obtenerVapidPublicKey(): string {
return config.push.vapidPublicKey
}
/** Busca el usuarioId local a partir de un mondayUserId */
export async function usuarioIdPorMondayUserId(mondayUserId: number): Promise<string | null> {
const usuario = await prisma.usuario.findFirst({
where: { mondayUserId },
select: { id: true },
})
return usuario?.id ?? null
}
/** Envía una notificación push a un usuario. Silencioso si no tiene suscripción o si web-push no está configurado. */
export async function enviarPush(
usuarioId: string,

View File

@@ -51,6 +51,7 @@ describe('RecordatorioVendedorService', () => {
titulo: 'Llamar a Juan',
descripcion: 'Sobre cotización',
leadId: null,
clienteClave: null,
fechaRecordar: new Date('2026-09-10T10:00:00'),
},
})

View File

@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { Pago, PlanPago } from './cobranza.service.js'
import {
MondayActividadCanal,
RecordatoriosCobranzaService,
WhatsAppCanal,
candidatosRecordatorio,
@@ -136,34 +135,18 @@ describe('debeEnviar', () => {
// ---------- Selección de destinatario ----------
describe('elegirDestinatario', () => {
it('vendedor mapeado → vendedor; si no, coordinador; si no, ninguno', () => {
expect(elegirDestinatario(123, 999)).toEqual({ mondayUserId: 123, origen: 'vendedor' })
expect(elegirDestinatario(null, 999)).toEqual({ mondayUserId: 999, origen: 'coordinador' })
expect(elegirDestinatario(null, 0)).toEqual({ mondayUserId: null, origen: 'ninguno' })
it('vendedor del plan → vendedor; si no, ninguno', () => {
expect(elegirDestinatario('vend-1')).toEqual({ vendedorId: 'vend-1', origen: 'vendedor' })
expect(elegirDestinatario(null)).toEqual({ vendedorId: null, origen: 'ninguno' })
})
})
// ---------- Canales ----------
describe('canales', () => {
it('MondayActividadCanal solo está disponible con mondayUserId y crea la actividad', async () => {
const creadas: { nombre: string; responsableId: number }[] = []
const canal = new MondayActividadCanal({
async crearActividad(datos) {
creadas.push({ nombre: datos.nombre, responsableId: datos.responsableId })
return 'item-1'
},
})
expect(canal.disponible({ mondayUserId: null, origen: 'ninguno' })).toBe(false)
expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(true)
const id = await canal.enviar({ mondayUserId: 42, origen: 'vendedor' }, 'mensaje de prueba', '2026-08-28')
expect(id).toBe('item-1')
expect(creadas).toEqual([{ nombre: 'mensaje de prueba', responsableId: 42 }])
})
it('WhatsAppCanal es un stub inactivo: nunca disponible y solo loguea', async () => {
const canal = new WhatsAppCanal()
expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(false)
expect(canal.disponible({ vendedorId: 'vend-1', origen: 'vendedor' })).toBe(false)
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
await expect(canal.enviar()).resolves.toBe('pendiente-wa-crm')
expect(spy).toHaveBeenCalledWith(expect.stringContaining('pendiente WA CRM'))
@@ -173,12 +156,12 @@ describe('canales', () => {
// ---------- Pasada del motor (store y canal inyectados) ----------
/** Canal fake que captura envíos; solo disponible con destinatario (como Monday) */
/** Canal fake que captura envíos; solo disponible con destinatario */
function canalFake() {
const envios: { destinatario: DestinatarioRecordatorio; mensaje: string }[] = []
const canal: CanalRecordatorio = {
nombre: 'fake',
disponible: (d) => d.mondayUserId !== null,
disponible: (d) => d.vendedorId !== null,
async enviar(destinatario, mensaje) {
envios.push({ destinatario, mensaje })
return 'ok'
@@ -187,12 +170,11 @@ function canalFake() {
return { canal, envios }
}
function storeFake(planes: PlanPago[], mondayId: number | null) {
function storeFake(planes: PlanPago[]) {
const log: RecordatorioEnviado[] = []
const ultimos = new Map<string, Date>()
const store: RecordatoriosStore = {
cargarPlanes: async () => planes,
mondayUserIdDeVendedor: async () => mondayId,
ultimoRecordatorio: async (pagoId, tipo) => ultimos.get(`${pagoId}:${tipo}`) ?? null,
registrarLog: async (r) => {
log.push(r)
@@ -212,7 +194,7 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
]
it('dry-run: lista lo que enviaría sin enviar ni escribir en el log', async () => {
const { store, log } = storeFake(planes, 555)
const { store, log } = storeFake(planes)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
@@ -223,7 +205,7 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
expect(r.evaluados[0]).toMatchObject({
pagoId: 'pg1',
decision: 'enviado',
destinatario: { mondayUserId: 555, origen: 'vendedor' },
destinatario: { vendedorId: 'vend-1', origen: 'vendedor' },
})
expect(envios).toHaveLength(0)
expect(log).toHaveLength(0)
@@ -231,13 +213,13 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
})
it('pasada real: envía por el canal, registra el log y la segunda pasada deduplica', async () => {
const { store, log } = storeFake(planes, 555)
const { store, log } = storeFake(planes)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
const r1 = await svc.ejecutarPasada({ ahora: HOY })
expect(r1.enviados).toHaveLength(1)
expect(r1.enviados[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', canal: 'fake', destinatario: '555' })
expect(r1.enviados[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', canal: 'fake', destinatario: 'vend-1' })
expect(envios).toHaveLength(1)
expect(log).toHaveLength(1)
@@ -249,12 +231,18 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
expect(log).toHaveLength(1)
})
it('sin destinatario (sin mapeo ni coordinador): solo registra en el log, sin canal', async () => {
const { store, log } = storeFake(planes, null)
it('sin destinatario (plan sin vendedor): solo registra en el log, sin canal', async () => {
const planesSinVendedor = [
plan({
id: 'p1',
vendedorId: null,
pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })],
}),
]
const { store, log } = storeFake(planesSinVendedor)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
// config de test no define MONDAY_COORDINADOR_USER_ID → 0 → 'ninguno'
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(1)
expect(r.enviados[0]).toMatchObject({ canal: 'log', destinatario: 'sin-destinatario' })
@@ -263,19 +251,19 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
})
it('un fallo del canal no escribe en el log (reintentable) y se reporta en fallidos', async () => {
const { store, log } = storeFake(planes, 555)
const { store, log } = storeFake(planes)
const canalRoto: CanalRecordatorio = {
nombre: 'roto',
disponible: () => true,
enviar: async () => {
throw new Error('Monday caído')
throw new Error('canal caído')
},
}
const svc = new RecordatoriosCobranzaService(store, [canalRoto])
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(0)
expect(r.fallidos).toEqual([{ pagoId: 'pg1', error: 'Monday caído' }])
expect(r.fallidos).toEqual([{ pagoId: 'pg1', error: 'canal caído' }])
expect(log).toHaveLength(0)
})
})

View File

@@ -1,7 +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'
import { enviarPush } from './push.service.js'
@@ -12,10 +11,10 @@ import { enviarPush } from './push.service.js'
* - pagos que vencen en ≤ RECORDATORIO_DIAS_ANTES días → "próximo a vencer"
* - pagos vencidos → "vencido", re-recordatorio cada RECORDATORIO_REVOLVER_DIAS
*
* Canal actual: Actividad en Monday al vendedor del plan (si tiene
* mondayUserId; si no, al coordinador de MONDAY_COORDINADOR_USER_ID; si no,
* solo se registra en el log). La interfaz CanalRecordatorio permite enchufar
* WhatsApp después (stub WhatsAppCanal incluido, inactivo hasta el WA CRM).
* Canal actual: ActividadLocal al vendedor del plan (si el plan tiene
* vendedorId; si no, solo se registra en el log). La interfaz
* CanalRecordatorio permite enchufar WhatsApp después (stub WhatsAppCanal
* incluido, inactivo hasta el WA CRM).
*
* Dedup e historial: tabla RecordatorioLog (no se repite el mismo recordatorio
* el mismo día; los vencidos respetan la ventana de re-recordatorio).
@@ -129,21 +128,15 @@ export function debeEnviar(
// ---------- Destinatario ----------
export interface DestinatarioRecordatorio {
/** mondayUserId del responsable de la actividad; null = no hay a quién asignar */
mondayUserId: number | null
origen: 'vendedor' | 'coordinador' | 'ninguno'
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
vendedorId: string | null
origen: 'vendedor' | 'ninguno'
}
/** Regla pura de selección: vendedor mapeado → coordinador (env) → nadie */
export function elegirDestinatario(
mondayUserIdVendedor: number | null,
coordinadorUserId: number,
): DestinatarioRecordatorio {
if (mondayUserIdVendedor !== null) return { mondayUserId: mondayUserIdVendedor, origen: 'vendedor' }
if (Number.isInteger(coordinadorUserId) && coordinadorUserId > 0) {
return { mondayUserId: coordinadorUserId, origen: 'coordinador' }
}
return { mondayUserId: null, origen: 'ninguno' }
/** Regla pura de selección: vendedor del plan → nadie */
export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio {
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
return { vendedorId: null, origen: 'ninguno' }
}
// ---------- Canales ----------
@@ -152,30 +145,10 @@ export interface CanalRecordatorio {
readonly nombre: string
/** true si el canal puede entregar a este destinatario */
disponible(destinatario: DestinatarioRecordatorio): boolean
/** Entrega el recordatorio; devuelve un detalle (p. ej. el item id de Monday) */
/** Entrega el recordatorio; devuelve un identificador del envío */
enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise<string>
}
type MondayActividades = Pick<MondayService, 'crearActividad'>
/** Canal actual: Actividad en Monday asignada al responsable (tablero Actividades) */
export class MondayActividadCanal implements CanalRecordatorio {
readonly nombre = 'monday-actividad'
constructor(private readonly monday: MondayActividades) {}
disponible(destinatario: DestinatarioRecordatorio): boolean {
return destinatario.mondayUserId !== null
}
async enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise<string> {
return this.monday.crearActividad({
nombre: mensaje.length > 250 ? `${mensaje.slice(0, 247)}...` : mensaje,
tipoLabel: 'Tarea',
responsableId: destinatario.mondayUserId!,
fecha,
estadoLabel: 'Abierto',
})
}
}
/**
* Stub del canal WhatsApp: inactivo hasta que exista la integración del WA CRM.
* `disponible` siempre false (nunca se elige); `enviar` solo loguea.
@@ -219,8 +192,6 @@ export interface ResultadoPasada {
export interface RecordatoriosStore {
/** Planes ACTIVO con pagos y aplicaciones, en forma de dominio */
cargarPlanes(): Promise<PlanPago[]>
/** mondayUserId del vendedor; null si no existe o no está mapeado */
mondayUserIdDeVendedor(vendedorId: string): Promise<number | null>
/** Fecha del último recordatorio registrado para ese pago y tipo */
ultimoRecordatorio(pagoId: string, tipo: TipoRecordatorio): Promise<Date | null>
registrarLog(r: RecordatorioEnviado): Promise<void>
@@ -249,8 +220,7 @@ export class RecordatoriosCobranzaService {
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
for (const c of candidatos) {
const mondayVendedor = c.vendedorId ? await this.store.mondayUserIdDeVendedor(c.vendedorId) : null
const destinatario = elegirDestinatario(mondayVendedor, config.automatizaciones.coordinadorUserId)
const destinatario = elegirDestinatario(c.vendedorId)
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
@@ -265,8 +235,7 @@ export class RecordatoriosCobranzaService {
pagoId: c.pagoId,
tipo: c.tipo,
canal: canal?.nombre ?? 'log',
destinatario:
destinatario.mondayUserId !== null ? String(destinatario.mondayUserId) : 'sin-destinatario',
destinatario: destinatario.vendedorId ?? 'sin-destinatario',
mensaje: c.mensaje,
}
try {
@@ -286,7 +255,7 @@ export class RecordatoriosCobranzaService {
}
}
// ---------- Cableado con Prisma + Monday (singleton para crons y endpoints) ----------
// ---------- Cableado con Prisma (singleton para crons y endpoints) ----------
export const prismaRecordatoriosStore: RecordatoriosStore = {
async cargarPlanes() {
@@ -324,13 +293,6 @@ export const prismaRecordatoriosStore: RecordatoriosStore = {
),
}))
},
async mondayUserIdDeVendedor(vendedorId) {
const usuario = await prisma.usuario.findUnique({
where: { id: vendedorId },
select: { mondayUserId: true },
})
return usuario?.mondayUserId ?? null
},
async ultimoRecordatorio(pagoId, tipo) {
const ultimo = await prisma.recordatorioLog.findFirst({
where: { pagoId, tipo },
@@ -346,7 +308,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

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PlanPago } from './cobranza.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import { AppError } from '../utils/errors.js'
import type { LeadFila, OportunidadFila, Unidad } from './reportes.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -22,10 +21,9 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { cargarDatosLocalesPrisma, ReportesService, type DatosLocalesReportes } from './reportes.service.js'
/**
* 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). `parcial: false` porque en modo local los datos
* son completos (no parciales).
* Reportes 100% locales: el resumen se calcula desde tablas locales
* (LeadCaptura por board, Unidad por status, PlanPago como siempre).
* `parcial: false` porque los datos locales son completos.
*/
function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] {
@@ -50,7 +48,7 @@ function plan(id: string, overrides: Partial<PlanPago> = {}): PlanPago {
}
}
function leadMonday(id: string, estado = 'Nuevo'): LeadMonday {
function leadFila(id: string, estado = 'Nuevo'): LeadFila {
return {
id,
nombre: `Lead ${id}`,
@@ -64,7 +62,7 @@ function leadMonday(id: string, estado = 'Nuevo'): LeadMonday {
}
}
function unidadMonday(codigo: string, status = 'DISPONIBLE'): Unidad {
function unidadFila(codigo: string, status = 'DISPONIBLE'): Unidad {
return {
codigo,
modelo: '1R',
@@ -81,18 +79,8 @@ function unidadMonday(codigo: string, status = 'DISPONIBLE'): Unidad {
}
}
function mondayMock(overrides: Record<string, unknown> = {}) {
return {
getLeads: vi.fn(async () => [leadMonday('m1')]),
getOportunidades: vi.fn(async () => [] as OportunidadMonday[]),
getUnidades: vi.fn(async () => [unidadMonday('M-1')]),
getActividadesAbiertas: vi.fn(async () => 3),
...overrides,
}
}
const datosLocales: DatosLocalesReportes = {
leads: [leadMonday('loc_l1'), leadMonday('loc_l2', 'Descartado')],
leads: [leadFila('loc_l1'), leadFila('loc_l2', 'Descartado')],
oportunidades: [
{
id: 'loc_o1',
@@ -111,7 +99,7 @@ const datosLocales: DatosLocalesReportes = {
credito: 'Infonavit',
},
],
unidades: [unidadMonday('U-1'), unidadMonday('U-2', 'APARTADO')],
unidades: [unidadFila('U-1'), unidadFila('U-2', 'APARTADO')],
}
beforeEach(() => {
@@ -121,74 +109,62 @@ beforeEach(() => {
prismaMock.actividadLocal.count.mockResolvedValue(0)
})
describe('ReportesService (fallback local)', () => {
it('Monday caído (salud): resumen desde datos locales con parcial: true, sin llamar a Monday', async () => {
const monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a')], {
salud: { estaDisponible: () => false },
describe('ReportesService (resumen desde tablas locales)', () => {
it('resumen desde datos locales: leads, oportunidades, unidades y planes en una respuesta', async () => {
const svc = new ReportesService(async () => [plan('a')], {
cargarDatosLocales: async () => datosLocales,
})
const r = await svc.resumen()
expect(r.parcial).toBe(false) // modo local completo: datos no son parciales
expect(r.parcial).toBe(false)
expect(r.leads.total).toBe(2)
expect(r.leads.porEstado).toEqual({ Nuevo: 1, Descartado: 1 })
expect(r.oportunidades.total).toBe(1)
expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1 })
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.cobranza.planes.activos).toBe(1)
expect(r.actividadesAbiertas).toBe(0) // no hay actividades en el fixture
expect(monday.getLeads).not.toHaveBeenCalled()
})
it('Monday vivo: camino normal intacto (sin parcial) y cache de 60 s', async () => {
const monday = mondayMock()
const cargarPlanes = vi.fn(async () => [plan('a')])
let ahora = 1_000_000
const svc = new ReportesService(monday, cargarPlanes, {
now: () => ahora,
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
})
const r1 = await svc.resumen()
expect(r1.parcial).toBeUndefined()
expect(r1.leads.total).toBe(1)
expect(r1.actividadesAbiertas).toBe(3)
ahora += 30_000 // dentro del TTL: segunda llamada sale del cache
const r2 = await svc.resumen()
expect(r2).toBe(r1)
expect(monday.getLeads).toHaveBeenCalledTimes(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
})
it('Monday lanza AppError 502 a media lectura: cae al resumen local con parcial', async () => {
const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(502, 'Monday no respondió') }) })
const svc = new ReportesService(monday, async () => [], {
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
it('cuenta actividades abiertas locales cuando el fixture las trae', async () => {
const svc = new ReportesService(async () => [], {
cargarDatosLocales: async () => ({ ...datosLocales, actividadesAbiertas: 3 }),
})
const r = await svc.resumen()
expect(r.parcial).toBe(false) // modo local completo: datos no son parciales
expect(r.leads.total).toBe(2)
expect(r.actividadesAbiertas).toBe(3)
})
it('otro error de Monday se propaga (no se traga nada)', async () => {
const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(500, 'bug') }) })
const svc = new ReportesService(monday, async () => [], {
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
it('usa el cargador local por defecto (cargarDatosLocalesPrisma) cuando no se inyecta', async () => {
const svc = new ReportesService(async () => [])
const r = await svc.resumen()
// Con las tablas vacías el resumen sale en ceros pero completo
expect(r.parcial).toBe(false)
expect(r.leads.total).toBe(0)
expect(r.oportunidades.total).toBe(0)
expect(r.unidades.total).toBe(0)
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalled()
expect(prismaMock.unidad.findMany).toHaveBeenCalled()
expect(prismaMock.actividadLocal.count).toHaveBeenCalled()
})
it('un error del cargador local se propaga (no se traga nada)', async () => {
const svc = new ReportesService(async () => [], {
cargarDatosLocales: async () => {
throw new Error('bug')
},
})
await expect(svc.resumen()).rejects.toThrow('bug')
})
})
describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs Monday)', () => {
it('mapea boards a LeadMonday/OportunidadMonday con el perfil de datosJson y unidades al DTO', async () => {
describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs del embudo)', () => {
it('mapea boards a leads/oportunidades con el perfil de datosJson y unidades al DTO', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_l1',

View File

@@ -1,12 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import type { LeadFila, OportunidadFila, Unidad } from './reportes.service.js'
import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ----------
/** Lead del tablero Leads (captura ligera; fuente de la sección leads) */
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
function lead(id: string, overrides: Partial<LeadFila> = {}): LeadFila {
return {
id,
nombre: `Lead ${id}`,
@@ -22,7 +22,7 @@ function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
}
/** Afluencia del board Oportunidades (fuente del perfilamiento de afluencia) */
function afluencia(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
function afluencia(id: string, overrides: Partial<OportunidadFila> = {}): OportunidadFila {
return {
id,
nombre: `Lead ${id}`,
@@ -39,7 +39,7 @@ function afluencia(id: string, overrides: Partial<OportunidadMonday> = {}): Opor
}
}
function op(id: string, etapa: string): OportunidadMonday {
function op(id: string, etapa: string): OportunidadFila {
return afluencia(id, { nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101' })
}
@@ -311,61 +311,51 @@ describe('construirResumen', () => {
})
})
// ---------- ReportesService (cache en memoria) ----------
// ---------- ReportesService (resumen desde tablas locales, cache en memoria) ----------
describe('ReportesService', () => {
function mondayMock() {
function datosLocales() {
return {
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
async getLeads(): Promise<LeadMonday[]> {
this.llamadas.leads++
return [lead('1')]
},
async getOportunidades(): Promise<OportunidadMonday[]> {
this.llamadas.oportunidades++
return [afluencia('1')]
},
async getUnidades(): Promise<Unidad[]> {
this.llamadas.unidades++
return [unidad('U-101', 'ATELIER', 'DISPONIBLE')]
},
async getActividadesAbiertas(): Promise<number> {
this.llamadas.actividades++
return 7
},
leads: [lead('1')],
oportunidades: [afluencia('1')],
unidades: [unidad('U-101', 'ATELIER', 'DISPONIBLE')],
actividadesAbiertas: 7,
}
}
it('agrega todas las fuentes en una sola respuesta', async () => {
const monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
it('agrega todas las fuentes (tablas locales) en una sola respuesta', async () => {
const cargarDatos = vi.fn(async () => datosLocales())
const svc = new ReportesService(
async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })],
{ cargarDatosLocales: cargarDatos },
)
const r = await svc.resumen()
expect(r.leads.total).toBe(1) // la sección leads se alimenta del tablero Leads
expect(r.leads.total).toBe(1) // la sección leads se alimenta de LeadCaptura board LEADS
expect(r.oportunidades.total).toBe(1)
expect(r.unidades.total).toBe(1)
expect(r.cobranza.planes.activos).toBe(1)
expect(r.actividadesAbiertas).toBe(7)
expect(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(1)
expect(r.parcial).toBe(false) // los datos locales son completos
expect(cargarDatos).toHaveBeenCalledTimes(1)
})
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
it('cachea 60 s: la segunda llamada no vuelve a leer las tablas locales', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-08-22T12:00:00'))
const monday = mondayMock()
const cargarDatos = vi.fn(async () => datosLocales())
const cargarPlanes = vi.fn(async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const svc = new ReportesService(monday, cargarPlanes)
const svc = new ReportesService(cargarPlanes, { cargarDatosLocales: cargarDatos })
await svc.resumen()
await svc.resumen()
expect(monday.llamadas.oportunidades).toBe(1)
expect(cargarDatos).toHaveBeenCalledTimes(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
// 61 s después el cache expira y se vuelve a consultar
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
await svc.resumen()
expect(monday.llamadas.oportunidades).toBe(2)
expect(cargarDatos).toHaveBeenCalledTimes(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()

View File

@@ -1,18 +1,59 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import type { UnidadCotizable } from './cotizacion.service.js'
import { ETAPAS_PIPELINE } from './etapas.js'
import { prisma } from './prisma.js'
import { saludMonday, type SaludMondayService } from './salud-monday.service.js'
import { unidadLocalADto } from './unidades-local.service.js'
import { AppError } from '../utils/errors.js'
/** Lead del embudo tal como lo consume el agregador (fila de LeadCaptura mapeada) */
export type LeadFila = {
id: string
nombre: string
createdAt: string // ISO
updatedAt: string // ISO
estado: string
origen: string
telefono: string
email: string
vendedorIds: number[]
desarrollo?: string
modeloInteres?: string
}
/** Oportunidad/afluencia tal como la consume el agregador (fila de LeadCaptura mapeada) */
export type OportunidadFila = {
id: string
nombre: string
createdAt: string // ISO
updatedAt: string // ISO
etapa: string
unidadCodigo: string
precioFinal: number
asesorIds: number[]
telefono: string
email: string
origen: string
desarrollo?: string
modeloInteres?: string
medioContacto?: string
edad?: number | null
estadoCivil?: string
tipoEmpleo?: string
credito?: string
tipoEconomia?: string
califica?: string
rangoSalarial?: string
loQuierePara?: string
}
export type Unidad = UnidadCotizable
/**
* Agregador de reportes para la app de tableros (/dash/).
* En UNA respuesta junta: leads (Monday, tablero Leads — captura ligera),
* afluencia/perfilamiento (Monday, tablero Oportunidades — el Alta Afluencia
* vive ahí), oportunidades (Monday), unidades (Monday), cobranza (Prisma
* local) y actividades abiertas (Monday).
* Cache en memoria de 60 s para no martillar la API de Monday.
* En UNA respuesta junta: leads (captura ligera, tabla LeadCaptura board
* LEADS), afluencia/perfilamiento (board OPORTUNIDADES — el Alta Afluencia
* vive ahí), oportunidades, unidades (espejo local), cobranza (Prisma local)
* y actividades abiertas (ActividadLocal).
* Cache en memoria de 60 s para no martillar SQLite en cada refresh.
*/
export const CACHE_REPORTES_MS = 60 * 1000
@@ -77,7 +118,7 @@ export interface ResumenReportes {
montoVencido: number
}
actividadesAbiertas: number
/** true cuando el resumen se calculó desde tablas locales (Monday caído): métricas parciales */
/** false: el resumen se calculó desde tablas locales completas */
parcial?: boolean
}
@@ -86,8 +127,8 @@ export interface DatosResumen {
// La sección `leads` (total/nuevos/porEstado/porOrigen) lee el tablero Leads
// (captura ligera); el `perfil` de la afluencia lee Oportunidades (Alta
// Afluencia), que es también la fuente de la sección `oportunidades`
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
leads: LeadFila[]
oportunidades: OportunidadFila[]
unidades: Unidad[]
planes: PlanPago[] // dominio de cobranza (con pagos)
actividadesAbiertas: number
@@ -301,15 +342,13 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
// ---------- Servicio con cache ----------
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
/** Datos del embudo/inventario leídos de tablas locales (modo degradado) */
export interface DatosLocalesReportes {
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
leads: LeadFila[]
oportunidades: OportunidadFila[]
unidades: Unidad[]
actividadesAbiertas?: number
}
@@ -322,9 +361,7 @@ export interface OpcionesReportes {
now?: () => number
/** TTL del cache en ms (default 60 s) */
cacheMs?: number
/** Salud de Monday (default: singleton saludMonday) */
salud?: Pick<SaludMondayService, 'estaDisponible'>
/** Datos locales para el fallback (default: tablas LeadCaptura/Unidad vía Prisma) */
/** Datos locales del embudo/inventario (default: tablas LeadCaptura/Unidad vía Prisma) */
cargarDatosLocales?: CargadorDatosLocales
}
@@ -337,7 +374,7 @@ function campoTexto(datos: Record<string, unknown>, campo: string): string {
/**
* Cargador local por defecto: leads/oportunidades desde LeadCaptura (board +
* etapa; el perfil de la afluencia va en datosJson) e inventario desde la
* tabla Unidad (espejo). Misma forma de DTO que las lecturas Monday.
* tabla Unidad (espejo).
*/
export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes> {
const capturas = await prisma.leadCaptura.findMany({
@@ -345,7 +382,7 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
})
const unidadesRows = await prisma.unidad.findMany()
const leads: LeadMonday[] = capturas
const leads: LeadFila[] = capturas
.filter((c) => c.board === 'LEADS')
.map((c) => ({
id: c.leadId,
@@ -358,7 +395,7 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
email: c.email ?? '',
vendedorIds: [],
}))
const oportunidades: OportunidadMonday[] = capturas
const oportunidades: OportunidadFila[] = capturas
.filter((c) => c.board === 'OPORTUNIDADES')
.map((c) => {
let datos: Record<string, unknown> = {}
@@ -404,19 +441,16 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
export class ReportesService {
private readonly now: () => number
private readonly cacheMs: number
private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
private readonly cargarDatosLocales: CargadorDatosLocales
/** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */
private cache: Map<string, { resumen: ResumenReportes; expira: number }> = new Map()
constructor(
private readonly monday: MondayReportes,
private readonly cargarPlanes: CargadorPlanes,
opciones: OpcionesReportes = {},
) {
this.now = opciones.now ?? Date.now
this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS
this.salud = opciones.salud ?? saludMonday
this.cargarDatosLocales = opciones.cargarDatosLocales ?? cargarDatosLocalesPrisma
}
@@ -438,36 +472,17 @@ export class ReportesService {
}
/**
* Camino normal: Monday + planes locales en paralelo (con el cache de 60 s
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
* 502 a media lectura → resumen desde tablas locales con `parcial: true`.
* Resumen desde tablas locales (LeadCaptura por board, Unidad espejo,
* PlanPago y ActividadLocal), en paralelo y con el cache de 60 s de siempre.
*/
private async cargarResumen(ahoraMs: number, dias?: number): Promise<ResumenReportes> {
if (this.salud.estaDisponible()) {
try {
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs), dias)
} catch (err) {
if (!(err instanceof AppError && err.status === 502)) throw err
// Monday se cayó a media lectura: cae al camino local
}
}
const [locales, planes] = await Promise.all([this.cargarDatosLocales(), this.cargarPlanes()])
const resumen = construirResumen(
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
new Date(ahoraMs),
dias,
)
// 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í).
// Los datos son completos (todas las métricas vienen de tablas locales)
return { ...resumen, parcial: false }
}
}

View File

@@ -1,73 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { FALLOS_PARA_CAER, SaludMondayService } from './salud-monday.service.js'
describe('SaludMondayService', () => {
afterEach(() => {
delete process.env.MODO_LOCAL_FORZADO
})
it('arranca disponible (optimista)', () => {
const salud = new SaludMondayService()
expect(salud.ok()).toBe(true)
expect(salud.estaDisponible()).toBe(true)
})
it('cae tras FALLOS_PARA_CAER fallos consecutivos', () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER - 1; i++) salud.registrarFallo()
expect(salud.estaDisponible()).toBe(true) // aún aguanta
salud.registrarFallo()
expect(salud.ok()).toBe(false)
expect(salud.estaDisponible()).toBe(false)
})
it('un éxito resetea el contador de fallos', () => {
const salud = new SaludMondayService()
salud.registrarFallo()
salud.registrarFallo()
salud.registrarExito()
salud.registrarFallo()
salud.registrarFallo()
expect(salud.estaDisponible()).toBe(true) // nunca llegó a 3 seguidos
})
it('un éxito recupera el flag una vez caído', () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
expect(salud.ok()).toBe(false)
salud.registrarExito()
expect(salud.estaDisponible()).toBe(true)
})
it('MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday esté sano', () => {
const salud = new SaludMondayService()
process.env.MODO_LOCAL_FORZADO = 'true'
expect(salud.ok()).toBe(true) // el flag crudo no cambia
expect(salud.estaDisponible()).toBe(false)
process.env.MODO_LOCAL_FORZADO = 'false'
expect(salud.estaDisponible()).toBe(true)
})
it('sondear() con ping exitoso recupera el flag y devuelve true', async () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
salud.registrarPing(async () => true)
await expect(salud.sondear()).resolves.toBe(true)
expect(salud.estaDisponible()).toBe(true)
})
it('sondear() con ping fallido deja el flag caído', async () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
salud.registrarPing(async () => false)
await expect(salud.sondear()).resolves.toBe(false)
expect(salud.ok()).toBe(false)
})
it('sondear() sin ping registrado devuelve el flag actual', async () => {
const salud = new SaludMondayService()
await expect(salud.sondear()).resolves.toBe(true)
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
await expect(salud.sondear()).resolves.toBe(false)
})
})

View File

@@ -1,100 +0,0 @@
/**
* Salud de Monday (circuit breaker ligero).
*
* Estado en memoria: `mondayOk` arranca en true (optimista) y pasa a false tras
* FALLOS_PARA_CAER fallos GraphQL consecutivos reportados por monday.service.
* Mientras está caído, los callers deben saltarse Monday de inmediato (modo
* local) en lugar de pagar los reintentos por request; un cron sondea cada 60 s
* y al recuperarse dispara el sync worker.
*
* MODO_LOCAL_FORZADO=true (env var o DB) fuerza modo local aunque Monday
* responda (pruebas / toggle admin). La DB se cachea 30 s en configService.
*
* El ping se inyecta con `registrarPing()` (wiring en index.ts) para no
* importar monday.service aquí y evitar ciclos de imports.
*/
/** Fallos GraphQL consecutivos que tumban el flag */
export const FALLOS_PARA_CAER = 3
export type PingMonday = () => Promise<boolean>
export class SaludMondayService {
private _modoLocalDb = false
setModoLocalDb(v: boolean) { this._modoLocalDb = v }
private mondayOk = true
private fallosConsecutivos = 0
private pingFn: PingMonday | null = null
/** Registra el callback de ping (en index.ts: () => mondayService.ping()) */
registrarPing(fn: PingMonday): void {
this.pingFn = fn
}
/** Llamar tras cada llamada GraphQL exitosa */
registrarExito(): void {
this.fallosConsecutivos = 0
this.mondayOk = true
}
/** Llamar tras cada fallo definitivo de Monday (AppError 502 de graphql()) */
registrarFallo(): void {
this.fallosConsecutivos += 1
if (this.fallosConsecutivos >= FALLOS_PARA_CAER) this.mondayOk = false
}
/** Flag crudo (sin el override MODO_LOCAL_FORZADO); útil para el cron de sondeo */
ok(): boolean {
return this.mondayOk
}
/**
* true si los callers pueden hablar con Monday ahora mismo.
* Lee modo_local_forzado de la DB (via cache 30 s en configService) y de
* process.env como fallback. Síncrono: la DB se precarga al iniciar y se
* invalida al cambiar configs.
*/
estaDisponible(): boolean {
// Lazy import para no crear ciclos en tests (configService importa prisma)
let forzadoDb = false
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cs = require('./config.service.js') as typeof import('./config.service.js')
const syncVal = cs.obtenerConfigSync('modo_local_forzado')
if (syncVal !== undefined) {
forzadoDb = syncVal === 'true'
} else {
// Cache no cargado aún: intentar async (fire-and-forget para la próxima vez)
cs.obtenerConfig('modo_local_forzado').then((v) => {
if (v === 'true') this._modoLocalDb = true
}).catch(() => {})
}
} catch {
// configService no disponible (tests aislados, DB no inicializada)
}
const forzadoEnv = process.env.MODO_LOCAL_FORZADO === 'true'
return this.mondayOk && !forzadoDb && !this._modoLocalDb && !forzadoEnv
}
/**
* Ping a Monday. Si responde, recupera el flag y devuelve true (el caller
* decide si dispara el sync). Sin ping registrado devuelve el flag actual.
*/
async sondear(): Promise<boolean> {
if (this.pingFn === null) return this.mondayOk
const vivo = await this.pingFn()
if (vivo) this.registrarExito()
return vivo
}
/** Reset completo (tests) */
restablecer(): void {
this.mondayOk = true
this.fallosConsecutivos = 0
this.pingFn = null
}
}
/** Singleton compartido por monday.service, controllers y crons */
export const saludMonday = new SaludMondayService()

View File

@@ -1,427 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma y Funnel mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findUnique: vi.fn(),
updateMany: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
findUnique: vi.fn(),
},
cliente: {
findUnique: vi.fn(),
updateMany: vi.fn(),
},
cotizacion: {
updateMany: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
import type { StateStore } from './automatizaciones.service.js'
import type { MondayService } from './monday.service.js'
import { registrarHandlersSync } from './sync-handlers.js'
import { SyncMondayService, type SyncRegistro, type SyncStore } from './sync-monday.service.js'
// ---------- Fixtures (mismo patrón que sync-monday.service.test.ts) ----------
let consecutivo = 0
function registro(parcial: Partial<SyncRegistro> = {}): SyncRegistro {
consecutivo += 1
return {
id: parcial.id ?? `reg-${consecutivo}`,
tipo: 'CREAR_LEAD',
payloadJson: '{}',
referenciaLocal: null,
estado: 'PENDIENTE',
intentos: 0,
createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)),
...parcial,
}
}
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 storeEnMemoria(registros: SyncRegistro[]) {
const rows = new Map(registros.map((r) => [r.id, { ...r }]))
const store: SyncStore & { rows: Map<string, SyncRegistro>; ultimo: SyncRegistro } = {
rows,
ultimo: registros[registros.length - 1]!,
async pendientes(limite) {
return [...rows.values()]
.filter((r) => r.estado === 'PENDIENTE')
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, limite)
},
async marcar(id, datos) {
const row = rows.get(id)
if (!row) throw new Error(`Registro inexistente: ${id}`)
row.estado = datos.estado
if (datos.intentos !== undefined) row.intentos = datos.intentos
if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError
},
}
return store
}
/** Monday mockeado: registra llamadas y devuelve ids controlados */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
leadsCreados: [] as Record<string, unknown>[],
oportunidadesCreadas: [] as Record<string, unknown>[],
contactosCreados: [] as Record<string, unknown>[],
apartadosCreados: [] as Record<string, unknown>[],
motivos: [] as Record<string, unknown>[],
asignaciones: [] as { itemId: string; userId: number }[],
async crearLead(datos: Record<string, unknown>) {
this.leadsCreados.push(datos)
return 'monday-lead-1'
},
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'monday-op-1'
},
async actualizarOportunidadAfluencia(_id: string, _datos: Record<string, unknown>) {},
async crearContacto(datos: Record<string, unknown>) {
this.contactosCreados.push(datos)
return 'monday-cont-1'
},
async actualizarContacto(_id: string, _datos: Record<string, unknown>) {},
async crearApartado(datos: Record<string, unknown>) {
this.apartadosCreados.push(datos)
return 'monday-apt-1'
},
async crearOportunidad(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'monday-op-cot-1'
},
async actualizarOportunidadCotizacion(_id: string, _datos: Record<string, unknown>) {},
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: unknown) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async crearMotivoNoCompra(datos: Record<string, unknown>) {
this.motivos.push(datos)
return 'monday-motivo-1'
},
async asignarVendedorALead(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
} as unknown as MondayService & {
cambios: { boardId: string; itemId: string; columna: string; valor: unknown }[]
leadsCreados: Record<string, unknown>[]
oportunidadesCreadas: Record<string, unknown>[]
contactosCreados: Record<string, unknown>[]
apartadosCreados: Record<string, unknown>[]
motivos: Record<string, unknown>[]
asignaciones: { itemId: string; userId: number }[]
}
}
const saludSana = { estaDisponible: () => true }
function crearServicio(monday: MondayService, store: SyncStore, state: StateStore) {
const svc = new SyncMondayService(monday, state, store, saludSana)
registrarHandlersSync(svc)
return svc
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.updateMany.mockResolvedValue({})
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.cliente.findUnique.mockResolvedValue(null)
prismaMock.cliente.updateMany.mockResolvedValue({})
prismaMock.cotizacion.updateMany.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
})
describe('handler CREAR_LEAD', () => {
it('crea el lead en Monday y actualiza la fila local (mondayItemId + pendienteSync=false)', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CREAR_LEAD',
payloadJson: JSON.stringify({ datos: { nombre: 'Ana Prueba', telefono: '4771234567' } }),
referenciaLocal: 'loc_ana',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.leadsCreados).toEqual([{ nombre: 'Ana Prueba', telefono: '4771234567' }])
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_ana' },
data: { mondayItemId: 'monday-lead-1', pendienteSync: false },
})
expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO')
})
})
describe('handler CONVERSION', () => {
const payloadConversion = {
oportunidadIdLocal: 'loc_op',
datos: { nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' },
}
it('sin mondayItemId en el lead (CREATE aún en cola): dependencia pendiente → PENDIENTE sin sumar intento', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_lead',
mondayItemId: null, // su CREAR_LEAD aún no se sincroniza
})
const monday = mondayMock()
const store = storeEnMemoria([
registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 }) // no cuenta como fallo
const row = store.rows.get(store.ultimo.id)
expect(row?.estado).toBe('PENDIENTE')
expect(row?.intentos).toBe(0) // sin sumar intento
expect(row?.ultimoError).toContain('conversión')
expect(monday.oportunidadesCreadas).toEqual([]) // no tocó Monday
})
it('con el lead ya sincronizado: crea la oportunidad, marca el lead Calificado y actualiza ambos ids locales', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', mondayItemId: 'monday-lead-9' })
const monday = mondayMock()
const store = storeEnMemoria([
registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.oportunidadesCreadas).toEqual([payloadConversion.datos])
// board 444 = MONDAY_BOARD_LEADS de test
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'monday-lead-9', columna: 'Estado', valor: 'Calificado' },
])
// Oportunidad local actualizada con su id Monday
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_op' },
data: { mondayItemId: 'monday-op-1', pendienteSync: false },
})
// Mapping con ids Monday para el alta de clientes
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'monday-lead-9', oportunidadId: 'monday-op-1' },
})
})
})
describe('handler CREAR_CONTACTO', () => {
it('crea el Contacto y liga Cliente.mondayContactoId', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CREAR_CONTACTO',
payloadJson: JSON.stringify({ datos: { nombre: 'Cliente Demo', estadoLabel: 'CLIENTE', curp: 'CURP1' } }),
referenciaLocal: 'cli-1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.contactosCreados).toHaveLength(1)
expect(prismaMock.cliente.updateMany).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'monday-cont-1' },
})
})
})
describe('handler COTIZACION_OPORTUNIDAD', () => {
const datosCot = {
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
tipoPropuestaLabel: 'CREDITO BANCARIO',
precioLista: 1_000_000,
descuentoPct: 0,
precioFinal: 1_000_000,
fechaCotizacion: '2026-09-05',
estadoLabel: 'Cotizador',
}
it('con oportunidadId Monday: ACTUALIZA esa oportunidad y liga Cotizacion.mondayItemId', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'op-77' }),
referenciaLocal: 'cot-1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.oportunidadesCreadas).toEqual([]) // no duplica
expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'op-77' },
})
})
it('con oportunidadId local (loc_*) sin sincronizar: dependencia pendiente', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', mondayItemId: null })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'loc_op' }),
referenciaLocal: 'cot-2',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 })
expect(store.rows.get(store.ultimo.id)?.estado).toBe('PENDIENTE')
expect(store.rows.get(store.ultimo.id)?.intentos).toBe(0)
})
it('sin oportunidad: match por teléfono como el controller; sin match crea una nueva', async () => {
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, clienteTelefono: '4771234567' }),
referenciaLocal: 'cot-3',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567')
expect(monday.oportunidadesCreadas).toEqual([datosCot])
expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({
where: { id: 'cot-3' },
data: { mondayItemId: 'monday-op-cot-1' },
})
})
})
describe('handler CAMBIO_ETAPA', () => {
it('aplica el cambio al mondayItemId resuelto desde la referencia local', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'op-55' })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CAMBIO_ETAPA',
payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Cotizador' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
// board 555 = MONDAY_BOARD_OPORTUNIDADES de test
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-55', columna: 'Estado', valor: 'Cotizador' }])
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { pendienteSync: false },
})
})
})
describe('handler APARTO_OPORTUNIDAD', () => {
it('mueve la oportunidad a Aparto con dedup previo (como moverAApartoConDedup)', async () => {
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ leadId: 'lead-9', oportunidadId: 'op-9' })
const monday = mondayMock()
const state = stateEnMemoria()
const store = storeEnMemoria([
registro({ tipo: 'APARTO_OPORTUNIDAD', payloadJson: JSON.stringify({ leadId: 'lead-9' }), referenciaLocal: 'lead-9' }),
])
const svc = crearServicio(monday, store, state)
await svc.procesarPendientes()
// Dedup ANTES del cambio de etapa (prismaStateStore, como moverAApartoConDedup)
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'apartado-creado:op-9' } }),
)
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Aparto' }])
})
})
describe('handler LIQUIDADO_ESCRITURAR', () => {
it('mueve el apartado a X Escriturar (board 666 de test)', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'LIQUIDADO_ESCRITURAR',
payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }),
referenciaLocal: 'apt-55',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
expect(monday.cambios).toEqual([{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' }])
})
})
describe('handler ASIGNAR_PERSONA', () => {
it('resuelve el mondayUserId del usuario local y asigna en el board correcto', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' })
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 777 })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.asignaciones).toEqual([{ itemId: 'lead-55', userId: 777 }])
})
it('sin mapeo Monday del usuario: no-op exitoso (reintentar no crearía el mapeo)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' })
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: null })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.asignaciones).toEqual([])
expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO')
})
})

View File

@@ -1,289 +0,0 @@
import { config } from '../config/index.js'
import { claveApartadoCreado, prismaStateStore } from './automatizaciones.service.js'
import { esIdLocal } from './crm-local.service.js'
import { funnelService } from './funnel.service.js'
import type {
DatosApartado,
DatosContacto,
DatosLead,
DatosOportunidad,
DatosOportunidadAfluencia,
} from './monday.service.js'
import { prisma } from './prisma.js'
import {
DependenciaPendienteError,
syncMondayService,
type SyncMondayService,
} from './sync-monday.service.js'
/**
* Handlers del sync worker (modo local): una réplica Monday por cada tipo de
* SyncPendiente que encolan crm-local.service y los controllers.
*
* Convenciones:
* - `ctx.referenciaLocal` es el id local de la entidad (leadId loc_* /
* clienteId / cotizacionId); los payloads traen TODO lo necesario para la
* llamada Monday (los mismos datos que mandan hoy los controllers).
* - Cuando la réplica necesita el mondayItemId de algo que aún no se ha creado
* (su CREATE sigue en la cola), se lanza DependenciaPendienteError: el worker
* lo deja PENDIENTE sin sumar intento y lo reintenta en la próxima pasada.
* - Tras el éxito se actualizan los ids locales (LeadCaptura.mondayItemId +
* pendienteSync=false, Cliente.mondayContactoId/mondayApartadoId,
* Cotizacion.mondayItemId).
*
* Wiring: index.ts importa este módulo por su efecto lateral (registro sobre
* el singleton). Los tests llaman `registrarHandlersSync(svcPropio)`.
*/
/**
* Resuelve el id Monday de un item del embudo:
* - id Monday directo (o fila local ya sincronizada): se devuelve tal cual.
* - id local loc_*: el mondayItemId de su fila LeadCaptura; null si su CREATE
* aún no se sincroniza (→ DependenciaPendienteError en el caller).
*/
async function resolverMondayId(id: string | null | undefined): Promise<string | null> {
if (id === null || id === undefined) return null
const row = await prisma.leadCaptura.findUnique({ where: { leadId: id } })
if (row) return row.mondayItemId ?? (esIdLocal(row.leadId) ? null : row.leadId)
// Sin fila local: un id Monday viejo se usa tal cual; un loc_* huérfano no
// tiene destino (null → dependencia pendiente)
return esIdLocal(id) ? null : id
}
/** Lanza DependenciaPendienteError si el id Monday aún no se puede resolver */
function requerirMondayId(id: string | null, que: string): string {
if (id === null) {
throw new DependenciaPendienteError(`${que}: el item aún no existe en Monday (su CREATE sigue en la cola)`)
}
return id
}
/** Marca la fila local del embudo como sincronizada (updateMany: no truena si no existe) */
async function marcarCapturaSincronizada(leadId: string, mondayItemId?: string): Promise<void> {
await prisma.leadCaptura.updateMany({
where: { leadId },
data: { ...(mondayItemId !== undefined ? { mondayItemId } : {}), pendienteSync: false },
})
}
/** Registra todos los handlers sobre el servicio dado (singleton en prod) */
export function registrarHandlersSync(svc: SyncMondayService = syncMondayService): void {
// ---------- Captura (tableros Leads y Oportunidades) ----------
svc.registrarHandler('CREAR_LEAD', async (payload, ctx) => {
const id = await ctx.monday.crearLead(payload['datos'] as DatosLead)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id)
return id
})
svc.registrarHandler('CREAR_AFLUENCIA', async (payload, ctx) => {
const id = await ctx.monday.crearOportunidadAfluencia(payload['datos'] as DatosOportunidadAfluencia)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id)
return id
})
svc.registrarHandler('ACTUALIZAR_AFLUENCIA', async (payload, ctx) => {
const id = requerirMondayId(
await resolverMondayId(ctx.referenciaLocal ?? (payload['oportunidadId'] as string | undefined)),
'actualizar afluencia',
)
await ctx.monday.actualizarOportunidadAfluencia(id, payload['datos'] as DatosOportunidadAfluencia)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return id
})
svc.registrarHandler('CAMBIO_ETAPA', async (payload, ctx) => {
const board = payload['board'] as 'leads' | 'oportunidades' | 'apartado'
const label = payload['label'] as string
const fecha = payload['fecha'] as string | undefined
const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), `cambio de etapa a "${label}"`)
const boardId = config.monday.boards[board]
await ctx.monday.cambiarValorColumna(boardId, id, 'Estado', label)
if (fecha !== undefined) await ctx.monday.cambiarValorColumna(boardId, id, 'Fecha', fecha)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return id
})
svc.registrarHandler('DESCARTE', async (payload, ctx) => {
const fuente = (payload['fuente'] as 'lead' | 'oportunidad' | undefined) ?? 'oportunidad'
const motivo = payload['motivo'] as string
const subcategoria = payload['subcategoria'] as string | undefined
const comentario = payload['comentario'] as string | undefined
const nombreItem = (payload['nombreItem'] as string | undefined) ?? ''
const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'descarte')
if (fuente === 'lead') {
const boardLeads = config.monday.boards.leads
await ctx.monday.cambiarValorColumna(boardLeads, id, 'Estado', 'Descartado')
if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardLeads, id, 'Notas', comentario)
} else {
const boardOportunidades = config.monday.boards.oportunidades
await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Estado', 'Perdido')
if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'COMENTARIOS', comentario)
await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Motivo No Compra', motivo)
}
const motivoItemId = await ctx.monday.crearMotivoNoCompra({
nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return motivoItemId
})
svc.registrarHandler('CONVERSION', async (payload, ctx) => {
const oportunidadIdLocal = payload['oportunidadIdLocal'] as string
const datos = payload['datos'] as DatosOportunidadAfluencia
const leadMondayId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'conversión (lead)')
const oportunidadId = await ctx.monday.crearOportunidadAfluencia(datos)
// El lead queda Calificado (convertido: sale de Mis leads)
await ctx.monday.cambiarValorColumna(config.monday.boards.leads, leadMondayId, 'Estado', 'Calificado')
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
await marcarCapturaSincronizada(oportunidadIdLocal, oportunidadId)
// Mapping con los ids de Monday para el alta de clientes (moverAApartoConDedup)
try {
await prisma.leadOportunidad.create({ data: { leadId: leadMondayId, oportunidadId } })
} catch {
// ya existe (reintento tras pasada a medias): el dedup sync:<id> lo cubre
}
return oportunidadId
})
// ---------- Alta de clientes (tableros Contactos y Apartado) ----------
svc.registrarHandler('CREAR_CONTACTO', async (payload, ctx) => {
const id = await ctx.monday.crearContacto(payload['datos'] as DatosContacto)
if (ctx.referenciaLocal !== null) {
await prisma.cliente.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayContactoId: id } })
}
return id
})
svc.registrarHandler('ACTUALIZAR_CONTACTO', async (payload, ctx) => {
const cliente = ctx.referenciaLocal !== null
? await prisma.cliente.findUnique({ where: { id: ctx.referenciaLocal } })
: null
const id = cliente?.mondayContactoId ?? null
if (id === null) {
throw new DependenciaPendienteError('actualizar contacto: el Contacto aún no existe en Monday')
}
await ctx.monday.actualizarContacto(id, payload['datos'] as Partial<DatosContacto>)
return id
})
svc.registrarHandler('CREAR_APARTADO', async (payload, ctx) => {
const id = await ctx.monday.crearApartado(payload['datos'] as DatosApartado)
// Alta de clientes: la referencia es el cliente local
if (typeof payload['clienteId'] === 'string') {
await prisma.cliente.updateMany({ where: { id: payload['clienteId'] }, data: { mondayApartadoId: id } })
}
if (ctx.referenciaLocal !== null) {
// "Aparto" local: la referencia es la fila LeadCaptura board=APARTADO
await marcarCapturaSincronizada(ctx.referenciaLocal, id)
// Clientes mínimos creados durante la caída (subida de documentos) ligados
// al id local del apartado: apuntan ahora al item real de Monday
await prisma.cliente.updateMany({
where: { mondayApartadoId: ctx.referenciaLocal },
data: { mondayApartadoId: id },
})
}
return id
})
// ---------- Cotizador ----------
svc.registrarHandler('COTIZACION_OPORTUNIDAD', async (payload, ctx) => {
const datos = payload['datos'] as DatosOportunidad
const oportunidadIdPayload = payload['oportunidadId'] as string | undefined
const clienteTelefono = payload['clienteTelefono'] as string | undefined
let oportunidadId: string | null = null
if (oportunidadIdPayload !== undefined) {
oportunidadId = requerirMondayId(await resolverMondayId(oportunidadIdPayload), 'cotización (oportunidad)')
} else if (clienteTelefono !== undefined) {
// Monday ya está vivo en la pasada: mismo match por teléfono que el controller
oportunidadId = await funnelService.oportunidadPorTelefono(clienteTelefono)
}
if (oportunidadId !== null) {
await ctx.monday.actualizarOportunidadCotizacion(oportunidadId, datos)
} else {
oportunidadId = await ctx.monday.crearOportunidad(datos)
}
if (ctx.referenciaLocal !== null) {
await prisma.cotizacion.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayItemId: oportunidadId } })
}
return oportunidadId
})
// ---------- Documentos y pipeline de apartados ----------
svc.registrarHandler('CHECKBOX_DOC', async (payload, ctx) => {
const checkbox = payload['checkbox'] as string
// El item Monday del apartado viaja en el payload (puede ser loc_* si el
// apartado nació en modo local: se resuelve por su fila LeadCaptura)
const itemId = (payload['itemId'] as string | undefined) ?? ctx.referenciaLocal
const id = requerirMondayId(await resolverMondayId(itemId), `checkbox "${checkbox}"`)
await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, checkbox, true)
return id
})
svc.registrarHandler('APARTO_OPORTUNIDAD', async (payload, ctx) => {
const leadId = payload['leadId'] as string
// Misma resolución que funnel.moverAApartoConDedup: primero como
// oportunidad directa (modelo nuevo), luego como lead con mapping; los ids
// pueden ser locales loc_* (alta hecha en modo local)
let oportunidadId: string | null = null
const porOportunidad = await prisma.leadOportunidad.findUnique({ where: { oportunidadId: leadId } })
if (porOportunidad) {
oportunidadId = porOportunidad.oportunidadId
} else {
const porLead = await prisma.leadOportunidad.findUnique({ where: { leadId } })
oportunidadId = porLead?.oportunidadId ?? null
}
if (oportunidadId === null) {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId } })
if (captura?.board === 'OPORTUNIDADES') oportunidadId = captura.leadId
}
if (oportunidadId === null) throw new Error(`APARTO_OPORTUNIDAD sin mapping para ${leadId}`)
const opMonday = requerirMondayId(await resolverMondayId(oportunidadId), 'mover a Aparto')
// Dedup ANTES del cambio de etapa (el webhook/cron no debe crear el Apartado)
await prismaStateStore.set(claveApartadoCreado(opMonday), new Date().toISOString())
await ctx.monday.cambiarValorColumna(config.monday.boards.oportunidades, opMonday, 'Estado', 'Aparto')
return opMonday
})
svc.registrarHandler('LIQUIDADO_ESCRITURAR', async (payload, ctx) => {
const id = requerirMondayId(await resolverMondayId(payload['mondayItemId'] as string), 'liquidado → X Escriturar')
await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, 'Estado', 'X Escriturar')
return id
})
// Réplica del status de una unidad cambiado en modo local (espejo → Monday)
svc.registrarHandler('DISPONIBILIDAD_UNIDAD', async (payload, ctx) => {
const codigo = payload['codigo'] as string
const status = payload['status'] as 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
await ctx.monday.actualizarDisponibilidadUnidad(codigo, status)
return codigo
})
svc.registrarHandler('ASIGNAR_PERSONA', async (payload, ctx) => {
const board = payload['board'] as 'leads' | 'oportunidades'
const itemId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'asignación de persona')
let userId = (payload['mondayUserId'] as number | undefined) ?? null
if (userId === null && typeof payload['usuarioId'] === 'string') {
const usuario = await prisma.usuario.findUnique({
where: { id: payload['usuarioId'] },
select: { mondayUserId: true },
})
userId = usuario?.mondayUserId ?? null
}
// Sin cuenta Monday vinculada no hay a quién asignar: no es un error
// (reintentar no crearía el mapeo), queda como no-op exitoso
if (userId === null) return 'sin-mapeo'
if (board === 'leads') await ctx.monday.asignarVendedorALead(itemId, userId)
else await ctx.monday.asignarAsesorAOportunidad(itemId, userId)
return itemId
})
}
// Wiring en prod: registrar sobre el singleton al importar el módulo (index.ts)
registrarHandlersSync()

View File

@@ -1,225 +0,0 @@
import { describe, expect, it } from 'vitest'
import { AppError } from '../utils/errors.js'
import type { StateStore } from './automatizaciones.service.js'
import type { MondayService } from './monday.service.js'
import {
claveSync,
esFalloMonday,
MAX_INTENTOS_SYNC,
SyncMondayService,
type SyncRegistro,
type SyncStore,
} from './sync-monday.service.js'
// ---------- Fixtures ----------
let consecutivo = 0
function registro(parcial: Partial<SyncRegistro> = {}): SyncRegistro {
consecutivo += 1
return {
id: parcial.id ?? `reg-${consecutivo}`,
tipo: 'CREAR_LEAD',
payloadJson: JSON.stringify({ nombre: 'Lead de prueba' }),
referenciaLocal: null,
estado: 'PENDIENTE',
intentos: 0,
// createdAt creciente: el orden del arreglo ES el orden FIFO
createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)),
...parcial,
}
}
/** StateStore en memoria (mismo patrón que automatizaciones.service.test.ts) */
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)
},
}
}
/** SyncStore en memoria: guarda los registros y el historial de marcados */
function storeEnMemoria(registros: SyncRegistro[]) {
const rows = new Map(registros.map((r) => [r.id, { ...r }]))
const marcados: { id: string; datos: { estado: string; intentos?: number; ultimoError?: string | null } }[] = []
const store: SyncStore & { rows: Map<string, SyncRegistro>; marcados: typeof marcados } = {
rows,
marcados,
async pendientes(limite) {
return [...rows.values()]
.filter((r) => r.estado === 'PENDIENTE')
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, limite)
},
async marcar(id, datos) {
marcados.push({ id, datos })
const row = rows.get(id)
if (!row) throw new Error(`Registro inexistente: ${id}`)
row.estado = datos.estado
if (datos.intentos !== undefined) row.intentos = datos.intentos
if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError
},
}
return store
}
const saludSana = { estaDisponible: () => true }
const saludCaida = { estaDisponible: () => false }
// En fase A no hay handlers por defecto; monday solo se pasa al contexto
const mondayDummy = {} as MondayService
function crearServicio(store: SyncStore, state: StateStore, salud = saludSana) {
return new SyncMondayService(mondayDummy, state, store, salud)
}
// ---------- Tests ----------
describe('esFalloMonday', () => {
it('solo los AppError 502 cuentan como fallo de Monday', () => {
expect(esFalloMonday(new AppError(502, 'Monday no respondió'))).toBe(true)
expect(esFalloMonday(new AppError(500, 'otro'))).toBe(false)
expect(esFalloMonday(new Error('x'))).toBe(false)
})
})
describe('SyncMondayService.procesarPendientes', () => {
it('no-op cuando Monday no está disponible (no toca la cola)', async () => {
const store = storeEnMemoria([registro()])
const state = stateEnMemoria()
const svc = crearServicio(store, state, saludCaida)
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 })
expect(store.marcados).toHaveLength(0)
expect(store.rows.get('reg-1')?.estado).toBe('PENDIENTE')
})
it('procesa FIFO y marca HECHO tras ejecutar el handler', async () => {
const store = storeEnMemoria([
registro({ id: 'a', createdAt: new Date('2026-09-05T12:00:02Z') }),
registro({ id: 'b', createdAt: new Date('2026-09-05T12:00:01Z') }),
])
const state = stateEnMemoria()
const svc = crearServicio(store, state)
const orden: string[] = []
svc.registrarHandler('CREAR_LEAD', async (payload) => {
orden.push(String(payload.orden))
return 'item-1'
})
// Reescribo payloads para distinguir orden de ejecución
store.rows.get('a')!.payloadJson = JSON.stringify({ orden: 'segundo-en-fila' })
store.rows.get('b')!.payloadJson = JSON.stringify({ orden: 'primero-en-fila' })
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 2, fallos: 0 })
expect(orden).toEqual(['primero-en-fila', 'segundo-en-fila']) // b (más viejo) primero
expect(store.rows.get('a')?.estado).toBe('HECHO')
expect(store.rows.get('b')?.estado).toBe('HECHO')
// Dedup persistido con el id devuelto por el handler
expect(state.mapa.get(claveSync('a'))).toBe('item-1')
expect(state.mapa.get(claveSync('b'))).toBe('item-1')
})
it('marca ERROR con mensaje claro cuando el tipo no tiene handler', async () => {
const store = storeEnMemoria([registro({ id: 'x', tipo: 'DESCARTE' })])
const svc = crearServicio(store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.ultimoError).toMatch(/sin handler registrado: "DESCARTE"/)
})
it('marca ERROR cuando el payloadJson no es JSON válido', async () => {
const store = storeEnMemoria([registro({ id: 'x', payloadJson: '{roto' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes()
expect(r.fallos).toBe(1)
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payloadJson inválido/)
})
it('fallo Monday (502): vuelve a PENDIENTE con intentos+1 (backoff)', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: 2 })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió tras 4 intentos')
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('PENDIENTE')
expect(row?.intentos).toBe(3)
expect(row?.ultimoError).toMatch(/Monday no respondió/)
})
it('al superar MAX_INTENTOS_SYNC el fallo Monday queda ERROR definitivo', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: MAX_INTENTOS_SYNC })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió')
})
await svc.procesarPendientes()
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.intentos).toBe(MAX_INTENTOS_SYNC + 1)
})
it('un error que NO es de Monday marca ERROR sin reintento', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new Error('payload incompleto: falta nombre')
})
await svc.procesarPendientes()
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payload incompleto/)
})
it('idempotencia: con sync:<id> ya en el state no reejecuta el handler', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const state = stateEnMemoria()
state.mapa.set(claveSync('x'), 'item-ya-creado')
const svc = crearServicio(store, state)
let llamadas = 0
svc.registrarHandler('CREAR_LEAD', async () => {
llamadas++
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(llamadas).toBe(0) // no duplica el item en Monday
expect(store.rows.get('x')?.estado).toBe('HECHO')
})
it('respeta el límite por pasada', async () => {
const store = storeEnMemoria([registro({ id: 'a' }), registro({ id: 'b' }), registro({ id: 'c' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes({ limite: 2 })
expect(r.procesados).toBe(2)
expect(store.rows.get('c')?.estado).toBe('PENDIENTE')
})
it('una pasada en curso bloquea la siguiente (mutex)', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
let liberar!: () => void
const bloqueo = new Promise<void>((res) => {
liberar = res
})
svc.registrarHandler('CREAR_LEAD', async () => {
await bloqueo
})
const pasada1 = svc.procesarPendientes()
// Segunda pasada mientras la primera sigue: no-op inmediato
await expect(svc.procesarPendientes()).resolves.toEqual({ procesados: 0, fallos: 0 })
liberar()
await expect(pasada1).resolves.toEqual({ procesados: 1, fallos: 0 })
})
})

View File

@@ -1,216 +0,0 @@
import { AppError } from '../utils/errors.js'
import { prismaStateStore, type StateStore } from './automatizaciones.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday, type SaludMondayService } from './salud-monday.service.js'
/**
* Sync worker del modo local: drena la cola SyncPendiente hacia Monday.
*
* - Solo corre cuando Monday está disponible (saludMonday); si está caído es no-op.
* - FIFO por createdAt (la captura encoló en orden: lead → conversión →
* afluencia → apartado → contacto) y SECUENCIAL: SQLite serializa las
* escrituras (P1008) si se procesa en paralelo.
* - Idempotencia con AutomatizacionState, clave `sync:<id>` (mismo patrón que
* `apartado-creado:<id>` de automatizaciones): si el proceso muere a media
* pasada, al reintentar no duplica el item en Monday.
* - Fallo Monday (AppError 502): vuelve a PENDIENTE con intentos+1; al superar
* MAX_INTENTOS queda ERROR (visible en /api/sistema/estado). Cualquier otro
* error (tipo sin handler, payload inválido) marca ERROR de inmediato.
*
* Los handlers por tipo los registra la fase B (con los payloads reales de
* cada flujo) vía `registrarHandler`; el mapa queda extensible y defensivo.
*/
export const TIPOS_SYNC = [
'CREAR_LEAD',
'CREAR_AFLUENCIA',
'ACTUALIZAR_AFLUENCIA',
'CAMBIO_ETAPA',
'DESCARTE',
'CONVERSION',
'CREAR_CONTACTO',
'ACTUALIZAR_CONTACTO',
'CREAR_APARTADO',
'COTIZACION_OPORTUNIDAD',
'CHECKBOX_DOC',
'APARTO_OPORTUNIDAD',
'LIQUIDADO_ESCRITURAR',
'ASIGNAR_PERSONA',
'DISPONIBILIDAD_UNIDAD',
] as const
export type TipoSync = (typeof TIPOS_SYNC)[number]
/** Intentos máximos antes de declarar ERROR definitivo */
export const MAX_INTENTOS_SYNC = 10
/** Registro de la cola tal como lo consume el worker */
export interface SyncRegistro {
id: string
tipo: string
payloadJson: string
referenciaLocal: string | null
estado: string
intentos: number
createdAt: Date
}
/** Acceso mínimo a la tabla SyncPendiente (Prisma en prod, memoria en tests) */
export interface SyncStore {
/** Hasta `limite` registros PENDIENTE en orden FIFO (createdAt asc) */
pendientes(limite: number): Promise<SyncRegistro[]>
marcar(
id: string,
datos: { estado: string; intentos?: number; ultimoError?: string | null },
): Promise<void>
}
export interface ContextoSync {
monday: MondayService
referenciaLocal: string | null
}
/**
* Ejecuta la réplica en Monday. Devuelve el id creado (o cualquier cadena) para
* guardarlo en el dedup `sync:<id>`; void se persiste como 'ok'.
*/
export type HandlerSync = (
payload: Record<string, unknown>,
ctx: ContextoSync,
) => Promise<string | void>
export interface ResultadoSync {
procesados: number
fallos: number
}
/** true si el error es un fallo de Monday retryable (AppError 502 de graphql) */
export function esFalloMonday(err: unknown): boolean {
return err instanceof AppError && err.status === 502
}
/**
* El handler no puede ejecutarse TODAVÍA porque una operación previa de la
* cola aún no se sincroniza (p.ej. CAMBIO_ETAPA sobre un item cuyo CREATE no
* ha corrido: no hay mondayItemId). El worker lo deja PENDIENTE SIN sumar
* intento: se reintenta en la siguiente pasada, cuando su dependencia ya se
* haya aplicado (la cola es FIFO).
*/
export class DependenciaPendienteError extends Error {
constructor(message: string) {
super(message)
this.name = 'DependenciaPendienteError'
}
}
/** Clave de dedup/idempotencia del registro en AutomatizacionState */
export function claveSync(id: string): string {
return `sync:${id}`
}
export class SyncMondayService {
private readonly handlers = new Map<string, HandlerSync>()
/** Mutex en memoria: evita solapar el cron de 2 min con el disparo al recuperar salud */
private enEjecucion = false
constructor(
private readonly monday: MondayService,
private readonly state: StateStore,
private readonly db: SyncStore,
private readonly salud: Pick<SaludMondayService, 'estaDisponible'> = saludMonday,
) {}
/** Registra el handler de un tipo de réplica (lo usa la fase B por flujo) */
registrarHandler(tipo: TipoSync, handler: HandlerSync): void {
this.handlers.set(tipo, handler)
}
/**
* Una pasada por la cola (máx `limite` registros). No-op si Monday está
* caído / MODO_LOCAL_FORZADO, o si ya hay una pasada en curso.
*/
async procesarPendientes({ limite = 25 }: { limite?: number } = {}): Promise<ResultadoSync> {
if (!this.salud.estaDisponible()) return { procesados: 0, fallos: 0 }
if (this.enEjecucion) return { procesados: 0, fallos: 0 }
this.enEjecucion = true
try {
const pendientes = await this.db.pendientes(limite)
let procesados = 0
let fallos = 0
for (const registro of pendientes) {
await this.db.marcar(registro.id, { estado: 'PROCESANDO' })
try {
await this.ejecutar(registro)
await this.db.marcar(registro.id, { estado: 'HECHO', ultimoError: null })
procesados++
} catch (err) {
const mensaje = err instanceof Error ? err.message : String(err)
if (err instanceof DependenciaPendienteError) {
// Falta una operación previa de la cola: queda PENDIENTE sin sumar
// intento (no es un fallo; se reintenta en la próxima pasada)
await this.db.marcar(registro.id, { estado: 'PENDIENTE', ultimoError: mensaje })
} else {
fallos++
const intentos = registro.intentos + 1
if (esFalloMonday(err)) {
// Monday volvió a caer a media pasada: reintenta en la siguiente
await this.db.marcar(registro.id, {
estado: intentos > MAX_INTENTOS_SYNC ? 'ERROR' : 'PENDIENTE',
intentos,
ultimoError: mensaje,
})
} else {
// Error de datos/código: reintentar no lo arregla
await this.db.marcar(registro.id, { estado: 'ERROR', intentos, ultimoError: mensaje })
}
}
}
}
if (procesados + fallos > 0) {
// eslint-disable-next-line no-console
console.log(`[sync] procesados ${procesados}, fallos ${fallos}`)
}
return { procesados, fallos }
} finally {
this.enEjecucion = false
}
}
/** Ejecuta un registro con dedup `sync:<id>`; lanza para que el caller clasifique */
private async ejecutar(registro: SyncRegistro): Promise<void> {
const handler = this.handlers.get(registro.tipo)
if (!handler) throw new Error(`Tipo de sync sin handler registrado: "${registro.tipo}"`)
let payload: Record<string, unknown>
try {
payload = JSON.parse(registro.payloadJson) as Record<string, unknown>
} catch {
throw new Error(`payloadJson inválido (no es JSON): registro ${registro.id}`)
}
const clave = claveSync(registro.id)
const previo = await this.state.get(clave)
if (previo !== null) return // ya se aplicó en una pasada previa (dedup)
const resultado = await handler(payload, {
monday: this.monday,
referenciaLocal: registro.referenciaLocal,
})
await this.state.set(clave, typeof resultado === 'string' && resultado !== '' ? resultado : 'ok')
}
}
/** Store respaldado por la tabla SyncPendiente de Prisma */
export const prismaSyncStore: SyncStore = {
pendientes(limite) {
return prisma.syncPendiente.findMany({
where: { estado: 'PENDIENTE' },
orderBy: { createdAt: 'asc' },
take: limite,
})
},
async marcar(id, datos) {
await prisma.syncPendiente.update({ where: { id }, data: datos })
},
}
/** Singleton usado por los crons de index.ts (los tests instancian el suyo) */
export const syncMondayService = new SyncMondayService(mondayService, prismaStateStore, prismaSyncStore)

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan BD) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
@@ -10,23 +9,14 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidades: vi.fn(),
getUnidad: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import {
getUnidadesLocal,
getUnidadLocal,
listarUnidadesConFallback,
obtenerUnidadConFallback,
unidadLocalADto,
type FilaUnidad,
} from './unidades-local.service.js'
import { saludMonday } from './salud-monday.service.js'
function fila(parcial: Partial<FilaUnidad> & { codigo: string }): FilaUnidad {
return {
@@ -45,14 +35,12 @@ function fila(parcial: Partial<FilaUnidad> & { codigo: string }): FilaUnidad {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.findUnique.mockResolvedValue(null)
})
describe('unidadLocalADto', () => {
it('mapea la fila local al DTO Unidad (misma forma que Monday)', () => {
it('mapea la fila local al DTO Unidad', () => {
const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' }))
expect(dto).toEqual({
codigo: 'SENDA-101',
@@ -70,7 +58,7 @@ describe('unidadLocalADto', () => {
})
})
it('etapa vacía → null (como el mapeo Monday)', () => {
it('etapa vacía → null', () => {
expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull()
})
})
@@ -100,42 +88,3 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
expect(await getUnidadLocal('U-X')).toBeNull()
})
})
describe('fallbacks con circuit breaker', () => {
it('Monday caído (salud) → lee local sin llamar a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })])
prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' }))
const lista = await listarUnidadesConFallback()
expect(lista.map((u) => u.codigo)).toEqual(['U-1'])
expect(mondayMock.getUnidades).not.toHaveBeenCalled()
const unidad = await obtenerUnidadConFallback('U-1')
expect(unidad?.codigo).toBe('U-1')
expect(mondayMock.getUnidad).not.toHaveBeenCalled()
})
it('Monday vivo → camino normal (sin tocar local)', async () => {
mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1' }])
mondayMock.getUnidad.mockResolvedValue({ codigo: 'M-1' })
expect(await listarUnidadesConFallback()).toEqual([{ codigo: 'M-1' }])
expect(await obtenerUnidadConFallback('M-1')).toEqual({ codigo: 'M-1' })
expect(prismaMock.unidad.findMany).not.toHaveBeenCalled()
})
it('Monday lanza AppError 502 → fallback local', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió'))
mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })])
prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' }))
expect((await listarUnidadesConFallback()).map((u) => u.codigo)).toEqual(['U-1'])
expect((await obtenerUnidadConFallback('U-1'))?.codigo).toBe('U-1')
})
it('otro error de Monday se propaga (no se traga nada)', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug'))
await expect(listarUnidadesConFallback()).rejects.toThrow('bug')
})
})

View File

@@ -1,14 +1,10 @@
import { mondayService, type Unidad } from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
import { AppError } from '../utils/errors.js'
import type { UnidadCotizable } from './cotizacion.service.js'
/**
* Fallback de LECTURA del inventario (modo local): la tabla `Unidad` es el
* espejo local del tablero Unidades de Monday (la siembra
* scripts/seed-unidades-locales.js y la mantiene fresco el write-through de
* monday.service.getUnidades). Cuando Monday está caído (saludMonday) o lanza
* AppError 502, el cotizador lee de aquí con la MISMA forma de DTO.
* Espejo local del inventario de unidades (tabla `Unidad`, sembrada por
* scripts/seed-unidades-locales.js). Es la única fuente de lectura del
* inventario: misma forma de DTO que consumen controllers y fronts.
*
* La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el
* seed curado): salen en null, que el cotizador ya tolera (plazo desconocido).
@@ -29,7 +25,7 @@ export interface FilaUnidad {
}
/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */
export function unidadLocalADto(u: FilaUnidad): Unidad {
export function unidadLocalADto(u: FilaUnidad): UnidadCotizable {
return {
codigo: u.codigo,
modelo: u.modelo,
@@ -46,15 +42,10 @@ export function unidadLocalADto(u: FilaUnidad): Unidad {
}
}
/** true si el error es un fallo de Monday (AppError 502) que amerita fallback local */
function esFalloMonday502(err: unknown): boolean {
return err instanceof AppError && err.status === 502
}
/** Unidades del espejo local; filtros equivalentes a los del controller */
export async function getUnidadesLocal(
filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {},
): Promise<Unidad[]> {
): Promise<UnidadCotizable[]> {
const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } })
let lista = rows.map(unidadLocalADto)
if (filtro.soloDisponibles === true) {
@@ -68,33 +59,7 @@ export async function getUnidadesLocal(
}
/** Una unidad local por código; null si no existe en el espejo */
export async function getUnidadLocal(codigo: string): Promise<Unidad | null> {
export async function getUnidadLocal(codigo: string): Promise<UnidadCotizable | null> {
const row = await prisma.unidad.findUnique({ where: { codigo } })
return row === null ? null : unidadLocalADto(row)
}
/**
* Lectura con fallback: Monday si está disponible; espejo local si el circuit
* breaker lo da por caído o si la llamada lanza AppError 502. Cualquier otro
* error se propaga intacto (no se traga nada del camino feliz).
*/
export async function listarUnidadesConFallback(): Promise<Unidad[]> {
if (!saludMonday.estaDisponible()) return getUnidadesLocal()
try {
return await mondayService.getUnidades()
} catch (err) {
if (esFalloMonday502(err)) return getUnidadesLocal()
throw err
}
}
/** Igual que listarUnidadesConFallback pero para una unidad por código */
export async function obtenerUnidadConFallback(codigo: string): Promise<Unidad | null> {
if (!saludMonday.estaDisponible()) return getUnidadLocal(codigo)
try {
return await mondayService.getUnidad(codigo)
} catch (err) {
if (esFalloMonday502(err)) return getUnidadLocal(codigo)
throw err
}
}