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

@@ -16,9 +16,6 @@
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
"seed:config": "tsx scripts/seed-config.js",
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
"setup:crm": "tsx scripts/setup-crm.ts",
"sync:desarrollos": "tsx scripts/sync-desarrollos.ts",
"sync:etapas": "tsx scripts/sync-etapas.ts",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {

View File

@@ -1,189 +0,0 @@
/**
* Importa los inventarios de ATELIER, ESTANZA, SENDA y SOLANA al board
* "Unidades" de Monday, desde reference/cotizadores/{DEV}.json.
*
* - Idempotente: salta items cuyo nombre (código prefijado) ya existe.
* - Crea la columna "Meses para firma" (numbers) si falta (la usa SOLANA).
* - create_labels_if_missing: la columna status "Disponibilidad" gana las
* etiquetas nuevas (VENDIDO, ESCRITURADO, APARTADO, BLOQUEADO).
* - Delay entre mutaciones para cuidar el rate limit.
*
* Uso: npx tsx scripts/importar-unidades.ts (desde api-gateway/)
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const REF_DIR = path.resolve(process.cwd(), '..', 'reference', 'cotizadores')
const DELAY_MS = 400
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
interface UnidadJson {
codigo: string
modelo: string | null
fechaEntrega: string | null
supEstacionamiento: number | null
supConstruccion: number | null
supExcedente: number | null
status: string
extras: Record<string, string | number | null>
}
interface DefinicionDesarrollo {
id: string
archivo: string
codigoMonday: (u: UnidadJson) => string
precio: (u: UnidadJson) => number | null
mesesParaFirma?: (u: UnidadJson) => number | null
}
const DESARROLLOS: DefinicionDesarrollo[] = [
{
id: 'ATELIER',
archivo: 'ATELIER.json',
codigoMonday: (u) => `ATELIER-${u.codigo}`,
precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null),
},
{
id: 'ESTANZA',
archivo: 'ESTANZA.json',
codigoMonday: (u) => `ESTANZA-${u.codigo.replace(/^CASA-/, '')}`,
precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null),
},
{
id: 'SENDA',
archivo: 'SENDA.json',
codigoMonday: (u) => `SENDA-${u.codigo}`,
precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null),
},
{
id: 'SOLANA',
archivo: 'SOLANA.json',
codigoMonday: (u) => `SOLANA-${u.codigo}`,
precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null),
mesesParaFirma: (u) =>
typeof u.extras['mesesParaFirma'] === 'number' ? u.extras['mesesParaFirma'] : null,
},
]
const svc = new MondayService()
const boardId = config.monday.boards.unidades
/** Columnas del board mapeadas por título */
async function columnasBoard(): Promise<Map<string, { id: string; title: string; type: string }>> {
const data = await svc.graphql<{ boards: { columns: { id: string; title: string; type: string }[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
return new Map((data.boards[0]?.columns ?? []).map((c) => [c.title, c]))
}
async function asegurarColumnaMeses(): Promise<void> {
const cols = await columnasBoard()
if (cols.has('Meses para firma')) return
const data = await svc.graphql<{ create_column: { id: string } }>(
`mutation ($boardId: ID!, $title: String!) {
create_column(board_id: $boardId, title: $title, column_type: numbers) { id }
}`,
{ boardId, title: 'Meses para firma' },
)
console.log(`Columna "Meses para firma" creada (${data.create_column.id})`)
}
/** Nombres de TODOS los items del board (paginado por cursor) */
async function itemsExistentes(): Promise<Set<string>> {
const nombres = new Set<string>()
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: { name: string }[] } }[] } =
cursor === null
? await svc.graphql(
`query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 500) { cursor items { name } } } }`,
{ ids: [boardId] },
)
: await svc.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) { items_page(limit: 500, cursor: $cursor) { cursor items { name } } }
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
for (const item of page.items) nombres.add(item.name)
cursor = page.cursor
} while (cursor !== null)
return nombres
}
function columnValuesDe(
def: DefinicionDesarrollo,
u: UnidadJson,
cols: Map<string, { id: string; title: string; type: string }>,
): string {
const valores: Record<string, unknown> = {}
const poner = (titulo: string, valor: unknown) => {
const col = cols.get(titulo)
if (!col) throw new Error(`Columna "${titulo}" no existe en el board`)
valores[col.id] = valor
}
const precio = def.precio(u)
poner('Desarrollo', def.id)
poner('Modelo', u.modelo ?? '')
if (u.fechaEntrega) poner('Fecha entrega', { date: u.fechaEntrega })
if (u.supConstruccion !== null) poner('Sup. construcción', u.supConstruccion)
if (u.supExcedente !== null) poner('Sup. excedente', u.supExcedente)
if (u.supEstacionamiento !== null) poner('Sup. estacionamiento', u.supEstacionamiento)
if (precio !== null) {
// Desarrollos de precio único: ambos precios replican el precio de lista
poner('Precio sin acabados', precio)
poner('Precio con acabados', precio)
}
poner('Disponibilidad', { label: u.status })
const meses = def.mesesParaFirma?.(u)
if (meses !== null && meses !== undefined) poner('Meses para firma', meses)
return JSON.stringify(valores)
}
async function main(): Promise<void> {
await asegurarColumnaMeses()
const cols = await columnasBoard()
const existentes = await itemsExistentes()
console.log(`Board ${boardId}: ${existentes.size} items existentes`)
for (const def of DESARROLLOS) {
const raw = JSON.parse(await readFile(path.join(REF_DIR, def.archivo), 'utf8')) as {
unidades: UnidadJson[]
}
let creados = 0
let saltados = 0
for (const u of raw.unidades) {
const nombre = def.codigoMonday(u)
if (existentes.has(nombre)) {
saltados++
continue
}
const columnValues = columnValuesDe(def, u, cols)
await svc.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, create_labels_if_missing: true) { id }
}`,
{ boardId, itemName: nombre, columnValues },
)
existentes.add(nombre)
creados++
if (creados % 25 === 0) console.log(` ${def.id}: ${creados} creados…`)
await sleep(DELAY_MS)
}
console.log(`${def.id}: ${creados} creados, ${saltados} ya existían (total ${raw.unidades.length})`)
}
svc.invalidarCacheUnidades()
console.log('Importación terminada.')
process.exit(0)
}
main().catch((err) => {
console.error('Falló la importación:', err instanceof Error ? err.message : err)
process.exit(1)
})

View File

@@ -1,240 +0,0 @@
/**
* Setup idempotente del workspace Frasa-CAS para alinearlo con el CRM FRASA real:
*
* - Leads: labels 'Intento de contacto' y 'Abandono' en el status Estado.
* - Oportunidades: labels de Etapa del funnel CRM + dropdown 'Motivo No Compra'.
* - Apartado: labels del pipeline Enkontrol + checkboxes CURP / RFC / Comprobante.
* - Contactos: dropdowns del perfil demográfico + date Fecha Nacimiento.
* - Tableros nuevos 'Motivos No Compra' y 'Actividades' (si MONDAY_BOARD_MOTIVOS /
* MONDAY_BOARD_ACTIVIDADES están vacíos en .env). Imprime los IDs creados.
*
* Uso: npm run setup:crm (desde api-gateway)
* Es seguro re-ejecutarlo: solo crea lo que falta.
*/
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const monday = new MondayService()
// ---------- Vocabulario CRM FRASA (normalizado, sin los typos del original) ----------
const ETIQUETAS_LEADS_ESTADO = ['Intento de contacto', 'Abandono']
const ETIQUETAS_ETAPA_OPORTUNIDAD = [
'Afluencia',
'Cotizador',
'Agendo Cita',
'Aparto',
'Ganado',
'Perdido',
'Abandono',
'No Asistio',
]
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
]
const ETIQUETAS_APARTADO_ESTADO = [
'Entrego Documentacion',
'Pago Apartado',
'Alta Enkontrol',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente (Venta)',
'Cancelacion',
'En Definicion',
// Postventa (cobranza): al liquidarse el plan de pagos
'X Escriturar',
'Escriturado',
]
const PERFIL_CONTACTOS: { titulo: string; tipo: string; labels?: string[] }[] = [
{ titulo: 'Sexo', tipo: 'dropdown', labels: ['Hombre', 'Mujer', 'No define'] },
{ titulo: 'Estado Civil', tipo: 'dropdown', labels: ['Soltero', 'Casado', 'Union Libre', 'Divorciado', 'Viudo'] },
{ titulo: 'Rango de Edad', tipo: 'dropdown', labels: ['Menor de 24', 'Entre 24 y 40', 'Mayor de 40'] },
{
titulo: 'Actividad Economica',
tipo: 'dropdown',
labels: ['Empleado', 'Empresario', 'Pensionado', 'Comerciante'],
},
{
titulo: 'Vivienda Actual',
tipo: 'dropdown',
labels: ['Propia', 'Rentada', 'Prestada', 'Comparte con familia'],
},
{ titulo: 'Proposito de Compra', tipo: 'dropdown', labels: ['Habitarla', 'Inversion'] },
{ titulo: 'Fecha Nacimiento', tipo: 'date' },
]
// ---------- Helpers ----------
interface Columna {
id: string
title: string
type: string
settings_str: string
}
async function columnas(boardId: string): Promise<Columna[]> {
const data = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type settings_str } } }`,
{ ids: [boardId] },
)
return data.boards[0]?.columns ?? []
}
/** Labels actuales de una columna status/dropdown (parsea settings_str) */
function labelsDe(col: Columna): string[] {
try {
const s = JSON.parse(col.settings_str) as { labels?: unknown }
if (Array.isArray(s.labels)) {
// dropdown: [{id, name}]
return s.labels.map((l) => (typeof l === 'object' && l !== null ? String((l as { name?: string }).name) : String(l)))
}
if (s.labels !== null && typeof s.labels === 'object') {
// status: {"0": "Label", ...}
return Object.values(s.labels as Record<string, string>)
}
} catch {
// settings no parseable: sin labels conocidos
}
return []
}
async function asegurarColumna(boardId: string, titulo: string, tipo: string, defaults?: unknown): Promise<void> {
const cols = await columnas(boardId)
if (cols.some((c) => c.title === titulo)) {
console.log(` = columna "${titulo}" ya existe (${boardId})`)
return
}
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!, $defaults: JSON) {
create_column(board_id: $boardId, title: $title, column_type: $tipo, defaults: $defaults) { id }
}`,
{ boardId, title: titulo, tipo, ...(defaults !== undefined ? { defaults: JSON.stringify(defaults) } : {}) },
)
console.log(` + columna "${titulo}" (${tipo}) creada en ${boardId}`)
}
/**
* Agrega labels faltantes a una columna status/dropdown usando un item scratch
* con create_labels_if_missing (change_column_metadata no siempre está disponible).
*/
async function asegurarEtiquetas(boardId: string, tituloColumna: string, etiquetas: string[]): Promise<void> {
const cols = await columnas(boardId)
const col = cols.find((c) => c.title === tituloColumna)
if (!col) throw new Error(`Columna "${tituloColumna}" no existe en ${boardId}`)
const actuales = new Set(labelsDe(col).map((l) => l.toLowerCase()))
const faltantes = etiquetas.filter((e) => !actuales.has(e.toLowerCase()))
if (faltantes.length === 0) {
console.log(` = "${tituloColumna}" ya tiene todas las etiquetas (${boardId})`)
return
}
// Item scratch: se crea, se le escriben los labels nuevos y se borra
const creado = await monday.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $name: String!) { create_item(board_id: $boardId, item_name: $name) { id } }`,
{ boardId, name: '__scratch_labels__' },
)
const itemId = creado.create_item.id
try {
for (const etiqueta of faltantes) {
const valor = col.type === 'dropdown' ? { labels: [etiqueta] } : { label: etiqueta }
await monday.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, itemId, values: JSON.stringify({ [col.id]: valor }) },
)
console.log(` + etiqueta "${etiqueta}" en "${tituloColumna}" (${boardId})`)
}
} finally {
await monday.graphql(`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, { itemId })
}
}
async function crearTablero(nombre: string): Promise<string> {
const workspaces = await monday.graphql<{ workspaces: { id: string; name: string }[] }>(
`query { workspaces { id name } }`,
)
const ws = workspaces.workspaces.find((w) => w.name === config.monday.workspace)
const data = await monday.graphql<{ create_board: { id: string } }>(
`mutation ($name: String!, $kind: BoardKind!, $ws: ID) {
create_board(board_name: $name, board_kind: $kind, workspace_id: $ws) { id }
}`,
{ name: nombre, kind: 'public', ...(ws ? { ws: ws.id } : {}) },
)
return data.create_board.id
}
// ---------- Main ----------
async function main(): Promise<void> {
const b = config.monday.boards
console.log('== Leads: etiquetas de Estado ==')
await asegurarEtiquetas(b.leads, 'Estado', ETIQUETAS_LEADS_ESTADO)
console.log('== Oportunidades: Etapa (columna status "Estado") + Motivo No Compra ==')
await asegurarEtiquetas(b.oportunidades, 'Estado', ETIQUETAS_ETAPA_OPORTUNIDAD)
await asegurarColumna(b.oportunidades, 'Motivo No Compra', 'dropdown')
await asegurarEtiquetas(b.oportunidades, 'Motivo No Compra', MOTIVOS_NO_COMPRA)
console.log('== Apartado: pipeline Enkontrol + checkboxes de documentos ==')
await asegurarEtiquetas(b.apartado, 'Estado', ETIQUETAS_APARTADO_ESTADO)
for (const titulo of ['CURP', 'RFC', 'Comprobante']) {
await asegurarColumna(b.apartado, titulo, 'checkbox')
}
console.log('== Contactos: perfil demográfico ==')
for (const col of PERFIL_CONTACTOS) {
await asegurarColumna(b.contactos, col.titulo, col.tipo)
if (col.labels) await asegurarEtiquetas(b.contactos, col.titulo, col.labels)
}
console.log('== Tablero Motivos No Compra ==')
let motivosId = b.motivos
if (motivosId === '') {
motivosId = await crearTablero('Motivos No Compra')
console.log(` + tablero creado: ${motivosId} → MONDAY_BOARD_MOTIVOS="${motivosId}" en .env`)
} else {
console.log(` = ya configurado: ${motivosId}`)
}
await asegurarColumna(motivosId, 'Categoria', 'status')
await asegurarEtiquetas(motivosId, 'Categoria', MOTIVOS_NO_COMPRA)
await asegurarColumna(motivosId, 'Descripcion', 'long_text')
await asegurarColumna(motivosId, 'Accion Recomendada', 'long_text')
await asegurarColumna(motivosId, 'Responsable', 'people')
console.log('== Tablero Actividades ==')
let actividadesId = b.actividades
if (actividadesId === '') {
actividadesId = await crearTablero('Actividades')
console.log(` + tablero creado: ${actividadesId} → MONDAY_BOARD_ACTIVIDADES="${actividadesId}" en .env`)
} else {
console.log(` = ya configurado: ${actividadesId}`)
}
await asegurarColumna(actividadesId, 'Tipo', 'status')
await asegurarEtiquetas(actividadesId, 'Tipo', ['Llamada', 'Reunion', 'Tarea'])
await asegurarColumna(actividadesId, 'Responsable', 'people')
await asegurarColumna(actividadesId, 'Fecha', 'date')
await asegurarColumna(actividadesId, 'Estado', 'status')
await asegurarEtiquetas(actividadesId, 'Estado', ['Abierto', 'Listo'])
// board_relation NO se puede crear vía API ("column type is not supported yet"):
// la columna "Lead" (relación al tablero Leads) debe crearse a mano en la UI.
// El servicio la usa si existe y la omite si no.
console.log(' ! crea a mano la columna "Lead" (Conectar tableros → Leads) en el tablero Actividades')
console.log('\nListo. Si se crearon tableros nuevos, copia los IDs a .env y .env.example.')
}
main().catch((err) => {
console.error('setup-crm falló:', err)
process.exit(1)
})

View File

@@ -1,196 +0,0 @@
/**
* Sincroniza el tablero Desarrollos (catálogo maestro, estilo CRM FRASA) con
* los inventarios reales:
* - DIAGONAL: apps/cotizador/src/data/unidades.ts
* - ATELIER / ESTANZA / SENDA / SOLANA: reference/cotizadores/*.json
*
* Calcula (solo unidades DISPONIBLE): Precio Desde/Hasta, M2 Construccion
* (rango), Fecha Entrega (la más próxima) y Modelos; y fija el Tipo de
* Inmueble (DIAGONAL/ATELIER = Departamentos; el resto = Casas).
*
* Idempotente: crea las columnas y los items que falten; actualiza valores.
* Uso: npm run sync:desarrollos (desde api-gateway)
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const monday = new MondayService()
const TIPO_POR_DESARROLLO: Record<string, string> = {
DIAGONAL: 'Departamentos',
ATELIER: 'Departamentos',
ESTANZA: 'Casas',
SENDA: 'Casas',
SOLANA: 'Casas',
}
interface UnidadInventario {
codigo: string
modelo: string
fechaEntrega: string | null
supConstruccion: number | null
precioSinAcabados: number | null
precioConAcabados: number | null
status: string
extras?: { precioLista?: number | null; precioPreventa?: number | null } & Record<string, unknown>
}
interface StatsDesarrollo {
nombre: string
tipoInmueble: string
precioDesde: number | null
precioHasta: number | null
m2Construccion: string // 'min - max' o ''
fechaEntrega: string | null // la más próxima entre DISPONIBLEs
modelos: string // únicos, separados por coma
disponibles: number
}
/** Precio de referencia: con acabados, si no sin acabados, si no lista/preventa (extras) */
function precioDe(u: UnidadInventario): number | null {
return u.precioConAcabados ?? u.precioSinAcabados ?? u.extras?.precioLista ?? u.extras?.precioPreventa ?? null
}
export function calcularStats(nombre: string, unidades: UnidadInventario[]): StatsDesarrollo {
const disponibles = unidades.filter((u) => u.status.toUpperCase() === 'DISPONIBLE')
const precios = disponibles.map(precioDe).filter((p): p is number => p !== null && p > 0)
const m2 = disponibles.map((u) => u.supConstruccion).filter((s): s is number => s !== null && s > 0)
const fechas = disponibles
.map((u) => u.fechaEntrega)
.filter((f): f is string => f !== null && f !== '')
.sort()
const modelos = [...new Set(disponibles.map((u) => u.modelo).filter((m) => m !== ''))].sort()
const redondeo = (n: number): number => Math.round(n * 100) / 100
return {
nombre,
tipoInmueble: TIPO_POR_DESARROLLO[nombre] ?? 'Casas',
precioDesde: precios.length > 0 ? Math.min(...precios) : null,
precioHasta: precios.length > 0 ? Math.max(...precios) : null,
m2Construccion: m2.length > 0 ? `${redondeo(Math.min(...m2))} - ${redondeo(Math.max(...m2))}` : '',
fechaEntrega: fechas[0] ?? null,
modelos: modelos.join(', '),
disponibles: disponibles.length,
}
}
async function cargarInventarios(): Promise<Map<string, UnidadInventario[]>> {
const mapa = new Map<string, UnidadInventario[]>()
// DIAGONAL vive en el seed del cotizador (TS)
const mod = (await import(
pathToFileURL2('../../apps/cotizador/src/data/unidades.ts')
)) as { UNIDADES_SEED: UnidadInventario[] }
mapa.set('DIAGONAL', mod.UNIDADES_SEED)
for (const nombre of ['ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']) {
const crudo = await readFile(path.join(RAIZ, `reference/cotizadores/${nombre}.json`), 'utf8')
const json = JSON.parse(crudo) as { unidades: UnidadInventario[] }
mapa.set(nombre, json.unidades)
}
return mapa
}
/** Ruta file:// absoluta para import dinámico de TS fuera del paquete */
function pathToFileURL2(relDesdeScript: string): string {
const abs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), relDesdeScript)
return `file://${abs}`
}
// ---------- Monday ----------
interface Columna {
id: string
title: string
type: string
}
async function asegurarColumna(boardId: string, titulo: string, tipo: string, existentes: Columna[]): Promise<void> {
if (existentes.some((c) => c.title === titulo)) return
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!) {
create_column(board_id: $boardId, title: $title, column_type: $tipo) { id }
}`,
{ boardId, title: titulo, tipo },
)
console.log(` + columna "${titulo}" (${tipo})`)
}
async function main(): Promise<void> {
const boardId = config.monday.boards.desarrollos
const colsData = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const columnas = colsData.boards[0]?.columns ?? []
await asegurarColumna(boardId, 'Precio Desde', 'numbers', columnas)
await asegurarColumna(boardId, 'Precio Hasta', 'numbers', columnas)
await asegurarColumna(boardId, 'M2 Construccion', 'text', columnas)
await asegurarColumna(boardId, 'Tipo de Inmueble', 'dropdown', columnas)
await asegurarColumna(boardId, 'Fecha Entrega', 'date', columnas)
await asegurarColumna(boardId, 'Modelos', 'text', columnas)
// Recargar columnas tras posibles creaciones y mapear por título
const colsFrescas = await monday.graphql<{ boards: { columns: Columna[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const porTitulo = new Map((colsFrescas.boards[0]?.columns ?? []).map((c) => [c.title, c]))
const itemsData = await monday.graphql<{ boards: { items_page: { items: { id: string; name: string }[] } }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 100) { items { id name } } } }`,
{ ids: [boardId] },
)
const itemsPorNombre = new Map(
(itemsData.boards[0]?.items_page.items ?? []).map((i) => [i.name.trim().toUpperCase(), i.id]),
)
const inventarios = await cargarInventarios()
for (const [nombre, unidades] of inventarios) {
const stats = calcularStats(nombre, unidades)
const valores: Record<string, unknown> = {}
const poner = (titulo: string, valor: unknown): void => {
const col = porTitulo.get(titulo)
if (col) valores[col.id] = valor
}
if (stats.precioDesde !== null) poner('Precio Desde', stats.precioDesde)
if (stats.precioHasta !== null) poner('Precio Hasta', stats.precioHasta)
if (stats.m2Construccion !== '') poner('M2 Construccion', stats.m2Construccion)
poner('Tipo de Inmueble', { labels: [stats.tipoInmueble] })
if (stats.fechaEntrega !== null) poner('Fecha Entrega', { date: stats.fechaEntrega })
if (stats.modelos !== '') poner('Modelos', stats.modelos)
const columnValues = JSON.stringify(valores)
const itemId = itemsPorNombre.get(nombre)
if (itemId === undefined) {
await monday.graphql(
`mutation ($boardId: ID!, $name: String!, $values: JSON!) {
create_item(board_id: $boardId, item_name: $name, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, name: nombre, values: columnValues },
)
console.log(`+ ${nombre}: item creado (${stats.disponibles} disponibles)`)
} else {
await monday.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, itemId, values: columnValues },
)
console.log(`= ${nombre}: actualizado (${stats.disponibles} disponibles)`)
}
console.log(
` ${stats.tipoInmueble} | $${stats.precioDesde ?? '-'} - $${stats.precioHasta ?? '-'} | ` +
`m2 ${stats.m2Construccion || '-'} | entrega ${stats.fechaEntrega ?? '-'} | modelos: ${stats.modelos || '-'}`,
)
}
console.log('\nSincronización de Desarrollos completa.')
}
main().catch((err) => {
console.error('sync-desarrollos falló:', err)
process.exit(1)
})

View File

@@ -1,368 +0,0 @@
/**
* Sincroniza la columna status "Etapa" del tablero Unidades (pipeline de 10
* etapas: Apartado → Reserva → Vendido → Integración → En Análisis → Autorizado
* → Notaría → Escriturado → Entrega → PostVenta) desde un JSON de etapas.
*
* Formatos de entrada aceptados:
* - Simple: [{ "codigo": "ATELIER-1", "etapa": "NOTARIA" }, ...]
* - Maestro: reference/maestro/maestro-unidades.json (codigo_nuestro +
* etapa_maestro + mapeo.confianza; solo se usan confianza
* alta/media). Es el default.
*
* Idempotente: solo escribe en Monday si la etapa cambió.
*
* Traspasos de departamento (dedup en AutomatizacionState, clave
* `traspaso:<codigo>:<etapa>`):
* - al llegar a Vendido → actividad "Venta de {codigo} lista para Escrituración"
* - al llegar a Escriturado → actividad "{codigo} escriturado: pasa a SAC"
* Una etapa alta implica las anteriores (p. ej. Escriturado cubre Vendido).
*
* Flags:
* --archivo <path> JSON de etapas (default: reference/maestro/maestro-unidades.json)
* --dry-run no escribe en Monday ni en la base; solo reporta
* --semilla registra el dedup de traspasos SIN crear actividades.
* Usarlo en la PRIMERA corrida (carga histórica): las ~50
* unidades ya vendidas/escrituradas no deben generar
* actividades retroactivas. Las corridas siguientes (sin
* --semilla) solo crean actividad en transiciones nuevas.
*
* Uso: npm run sync:etapas -- [--archivo <path>] [--dry-run] [--semilla]
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
import { ETAPAS_PIPELINE, normalizarEtapa, rangoEtapa, TRASPASOS, type EtapaPipeline } from '../src/services/etapas.js'
import { prisma } from '../src/services/prisma.js'
const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const ARCHIVO_DEFAULT = path.join(RAIZ, 'reference/maestro/maestro-unidades.json')
const PAUSA_MS = 400 // entre mutaciones, para no pegarle al rate limit de Monday
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
// ---------- Argumentos ----------
interface Opciones {
archivo: string
dryRun: boolean
semilla: boolean
}
function parseArgs(argv: string[]): Opciones {
const ops: Opciones = { archivo: ARCHIVO_DEFAULT, dryRun: false, semilla: false }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--dry-run') ops.dryRun = true
else if (arg === '--semilla') ops.semilla = true
else if (arg === '--archivo') {
const valor = argv[++i]
if (!valor) throw new Error('--archivo requiere una ruta')
ops.archivo = path.resolve(process.cwd(), valor)
} else {
throw new Error(`Argumento desconocido: ${arg}`)
}
}
return ops
}
// ---------- Entrada ----------
interface EtapaUnidad {
codigo: string
etapa: EtapaPipeline // ya normalizada al label oficial
}
interface EntradaMaestro {
codigo_nuestro?: string | null
etapa_maestro?: string | null
mapeo?: { confianza?: string }
}
interface EntradaSimple {
codigo?: string
etapa?: string | null
}
/** Lee el JSON de etapas y lo deja en pares {codigo, etapa oficial}; avisa lo que descarta */
async function cargarEtapas(archivo: string): Promise<EtapaUnidad[]> {
const json = JSON.parse(await readFile(archivo, 'utf8')) as unknown[]
if (!Array.isArray(json)) throw new Error(`${archivo} no es un array JSON`)
const esMaestro = json.some((e) => typeof e === 'object' && e !== null && 'codigo_nuestro' in e)
const out: EtapaUnidad[] = []
let descartadas = 0
for (const cruda of json) {
if (typeof cruda !== 'object' || cruda === null) {
descartadas++
continue
}
let codigo: string | undefined
let etapaCruda: string | null | undefined
if (esMaestro) {
const e = cruda as EntradaMaestro
const confianza = e.mapeo?.confianza ?? 'ninguna'
if (confianza !== 'alta' && confianza !== 'media') {
descartadas++
continue
}
codigo = e.codigo_nuestro ?? undefined
etapaCruda = e.etapa_maestro
} else {
const e = cruda as EntradaSimple
codigo = e.codigo
etapaCruda = e.etapa
}
if (!codigo) {
descartadas++
continue
}
const etapa = normalizarEtapa(etapaCruda)
if (etapa === null) {
// DISPONIBLE / vacío / desconocido: la unidad no tiene etapa del pipeline;
// no se escribe nada (nunca se borra una etapa existente desde este script)
descartadas++
continue
}
out.push({ codigo, etapa })
}
if (descartadas > 0) console.log(` (${descartadas} entradas descartadas: sin match, sin etapa o confianza baja)`)
return out
}
// ---------- Monday: columna Etapa ----------
const monday = new MondayService()
interface ColumnaBoard {
id: string
title: string
type: string
settings_str?: string
}
async function columnasDe(boardId: string): Promise<ColumnaBoard[]> {
const data = await monday.graphql<{ boards: { columns: ColumnaBoard[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type settings_str } } }`,
{ ids: [boardId] },
)
return data.boards[0]?.columns ?? []
}
/** Labels actuales de una columna status (de settings_str: {"labels":{"0":"Apartado",...}}) */
function labelsDe(col: ColumnaBoard): Set<string> {
try {
const settings = JSON.parse(col.settings_str ?? '{}') as { labels?: Record<string, string> }
return new Set(Object.values(settings.labels ?? {}))
} catch {
return new Set()
}
}
/**
* Garantiza que exista la columna status "Etapa" con los 10 labels oficiales.
* Monday no crea labels vía defaults de create_column en este workspace, así
* que los labels faltantes se siembran escribiéndolos en un item scratch con
* create_labels_if_missing:true y borrando el item al final.
*/
async function asegurarColumnaEtapa(boardId: string, dryRun: boolean): Promise<void> {
let col = (await columnasDe(boardId)).find((c) => c.title === 'Etapa')
if (col && col.type !== 'status') throw new Error(`La columna "Etapa" existe pero es tipo ${col.type}, no status`)
if (!col) {
if (dryRun) {
console.log(' [dry-run] se crearía la columna "Etapa" (status) con los 10 labels')
return
}
await monday.graphql(
`mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!) {
create_column(board_id: $boardId, title: $title, column_type: $tipo) { id }
}`,
{ boardId, title: 'Etapa', tipo: 'status' },
)
console.log(' + columna "Etapa" creada')
col = (await columnasDe(boardId)).find((c) => c.title === 'Etapa')
if (!col) throw new Error('No se encontró la columna "Etapa" recién creada')
}
const faltantes = ETAPAS_PIPELINE.filter((l) => !labelsDe(col).has(l))
if (faltantes.length === 0) {
console.log(' columna "Etapa" ya tiene los 10 labels')
return
}
if (dryRun) {
console.log(` [dry-run] faltarían labels en "Etapa": ${faltantes.join(', ')}`)
return
}
const creado = await monday.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $name: String!) { create_item(board_id: $boardId, item_name: $name) { id } }`,
{ boardId, name: 'tmp-sync-etapas (borrar)' },
)
const scratchId = creado.create_item.id
try {
for (const label of faltantes) {
await monday.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id }
}`,
{ boardId, itemId: scratchId, values: JSON.stringify({ [col.id]: { label } }) },
)
await sleep(PAUSA_MS)
}
} finally {
await monday.graphql(`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, { itemId: scratchId })
}
console.log(` + labels sembrados en "Etapa": ${faltantes.join(', ')}`)
}
// ---------- Traspasos de departamento ----------
const claveDedup = (codigo: string, etapa: string): string => `traspaso:${codigo}:${etapa}`
const nombreActividad = (codigo: string, etapa: EtapaPipeline): string =>
etapa === 'Vendido' ? `Venta de ${codigo} lista para Escrituración` : `${codigo} escriturado: pasa a SAC`
const fechaHoy = (): string => new Date().toISOString().slice(0, 10)
interface ResumenTraspasos {
actividadesCreadas: number
registradasSemilla: number
yaRegistradas: number
errores: number
}
/**
* Para la etapa vigente de una unidad, cubre los traspasos (Vendido →
* Escrituración, Escriturado → SAC) con rango <= la etapa, con dedup persistente.
*/
async function cubrirTraspasos(
codigo: string,
etapaVigente: EtapaPipeline,
ops: Opciones,
resumen: ResumenTraspasos,
): Promise<void> {
const rango = rangoEtapa(etapaVigente)
for (const [etapaTraspaso, depto] of Object.entries(TRASPASOS) as [EtapaPipeline, string][]) {
if (rangoEtapa(etapaTraspaso) > rango) continue
const clave = claveDedup(codigo, etapaTraspaso)
const ya = await prisma.automatizacionState.findUnique({ where: { clave } })
if (ya) {
resumen.yaRegistradas++
continue
}
if (ops.dryRun) {
console.log(` [dry-run] traspaso pendiente: ${codigo} → ${depto} (${ops.semilla ? 'se registraría sin actividad' : 'crearía actividad'})`)
continue
}
if (ops.semilla) {
await prisma.automatizacionState.upsert({
where: { clave },
update: { valor: 'semilla' },
create: { clave, valor: 'semilla' },
})
resumen.registradasSemilla++
continue
}
try {
await monday.crearActividad({
nombre: nombreActividad(codigo, etapaTraspaso),
tipoLabel: 'Tarea',
fecha: fechaHoy(),
estadoLabel: 'Abierto',
responsableId: config.automatizaciones.coordinadorUserId > 0 ? config.automatizaciones.coordinadorUserId : undefined,
})
await prisma.automatizacionState.upsert({
where: { clave },
update: { valor: 'actividad-creada' },
create: { clave, valor: 'actividad-creada' },
})
resumen.actividadesCreadas++
console.log(` + actividad: ${nombreActividad(codigo, etapaTraspaso)}`)
await sleep(PAUSA_MS)
} catch (err) {
// Sin dedup: la próxima corrida reintenta la actividad
resumen.errores++
console.error(` ! no se pudo crear la actividad de traspaso de ${codigo}:`, err instanceof Error ? err.message : err)
}
}
}
// ---------- Main ----------
async function main(): Promise<void> {
const ops = parseArgs(process.argv.slice(2))
const boardId = config.monday.boards.unidades
console.log(`sync-etapas: ${ops.archivo}${ops.dryRun ? ' [dry-run]' : ''}${ops.semilla ? ' [semilla]' : ''}`)
await asegurarColumnaEtapa(boardId, ops.dryRun)
const entradas = await cargarEtapas(ops.archivo)
console.log(` ${entradas.length} unidades con etapa en el archivo`)
const unidades = await monday.getUnidades()
const porCodigo = new Map(unidades.map((u) => [u.codigo, u]))
let escritas = 0
let sinCambio = 0
const noEncontradas: string[] = []
const resumenTraspasos: ResumenTraspasos = { actividadesCreadas: 0, registradasSemilla: 0, yaRegistradas: 0, errores: 0 }
for (const { codigo, etapa } of entradas) {
const unidad = porCodigo.get(codigo)
if (!unidad) {
noEncontradas.push(codigo)
continue
}
const etapaActual = normalizarEtapa(unidad.etapa)
if (etapaActual !== etapa) {
if (ops.dryRun) {
console.log(` [dry-run] ${codigo}: ${unidad.etapa ?? '(vacío)'} → ${etapa}`)
} else {
const itemId = await monday.itemIdDeUnidad(codigo)
if (!itemId) {
noEncontradas.push(codigo)
continue
}
await monday.cambiarValorColumna(boardId, itemId, 'Etapa', etapa)
escritas++
console.log(` = ${codigo}: ${unidad.etapa ?? '(vacío)'} → ${etapa}`)
await sleep(PAUSA_MS)
}
} else {
sinCambio++
}
await cubrirTraspasos(codigo, etapa, ops, resumenTraspasos)
}
console.log('\nResumen:')
console.log(` etapas escritas: ${escritas}${ops.dryRun ? ' (dry-run: 0 reales)' : ''}`)
console.log(` sin cambio: ${sinCambio}`)
console.log(` no encontradas: ${noEncontradas.length}${noEncontradas.length > 0 ? ` (${noEncontradas.join(', ')})` : ''}`)
console.log(` actividades creadas: ${resumenTraspasos.actividadesCreadas}`)
console.log(` traspasos sembrados: ${resumenTraspasos.registradasSemilla}`)
console.log(` traspasos ya dedup: ${resumenTraspasos.yaRegistradas}`)
if (resumenTraspasos.errores > 0) console.log(` ERRORES en actividades: ${resumenTraspasos.errores}`)
// Conteo final por etapa (lectura fresca del board)
if (!ops.dryRun) {
monday.invalidarCacheUnidades()
const frescas = await monday.getUnidades()
const conteo = new Map<string, number>()
for (const u of frescas) {
const e = normalizarEtapa(u.etapa)
if (e) conteo.set(e, (conteo.get(e) ?? 0) + 1)
}
console.log('\nUnidades por etapa (board):')
for (const etapa of ETAPAS_PIPELINE) {
const n = conteo.get(etapa) ?? 0
if (n > 0) console.log(` ${etapa}: ${n}`)
}
const sinEtapa = frescas.length - [...conteo.values()].reduce((a, b) => a + b, 0)
console.log(` (sin etapa): ${sinEtapa}`)
}
await prisma.$disconnect()
}
main().catch((err) => {
console.error('sync-etapas falló:', err)
process.exit(1)
})

View File

@@ -19,44 +19,12 @@ export const config = {
// host (sync de estatus → POST /api/cotizaciones/alertas-inventario). Vacío =
// desactivado: el endpoint exige JWT de usuario como siempre.
internalSyncToken: env('INTERNAL_SYNC_TOKEN', ''),
monday: {
apiKey: env('MONDAY_API_KEY'),
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
apiVersion: '2024-10',
workspace: env('MONDAY_WORKSPACE', ''),
boards: {
contactos: env('MONDAY_BOARD_CONTACTOS'),
desarrollos: env('MONDAY_BOARD_DESARROLLOS'),
unidades: env('MONDAY_BOARD_UNIDADES'),
leads: env('MONDAY_BOARD_LEADS'),
oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'),
apartado: env('MONDAY_BOARD_APARTADO'),
ventas: env('MONDAY_BOARD_VENTAS'),
// Tableros creados por scripts/setup-crm.ts; '' = aún no configurado
motivos: env('MONDAY_BOARD_MOTIVOS', ''),
actividades: env('MONDAY_BOARD_ACTIVIDADES', ''),
},
},
automatizaciones: {
// 'false' desactiva los crons (útil en tests/CI); los endpoints admin siguen vivos
enabled: env('MONDAY_AUTOMATIONS_ENABLED', 'true') !== 'false',
// 'false' desactiva la creación de actividades en Monday (default true; false en tests)
actividadesEnabled: env('ACTIVIDADES_ENABLED', 'true') !== 'false',
// IDs de usuario Monday separados por coma; vacío = usar el equipo rrEquipo
rrUserIds: env('MONDAY_RR_USER_IDS', ''),
// Equipo (team) de Monday cuyos miembros entran al round-robin
rrEquipo: env('MONDAY_RR_TEAM', 'vendedores'),
enabled: env('AUTOMATIONS_ENABLED', 'true') !== 'false',
inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')),
// Horas que un lead asignado puede seguir en NUEVO/Intento de contacto antes de escalar
escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')),
// User ID de Monday del coordinador que recibe los escalados; vacío = al vendedor asignado
coordinadorUserId: Number(env('MONDAY_COORDINADOR_USER_ID', '0')),
// User ID de Monday de la cuenta COMPARTIDA de vendedores (los vendedores
// de piso operan solo la app, todos ligados a una sola cuenta Monday).
// Cuando un usuario del ecosistema está ligado a esa cuenta, sus listas
// personales de Mi día se filtran por su captura local, NO por la columna
// people (si no, todos verían los leads de todos). 0 = desactivado.
vendedorCompartidoId: Number(env('MONDAY_VENDEDOR_COMPARTIDO_ID', '0')),
pollCron: env('POLL_CRON', '*/5 * * * *'),
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
},

View File

@@ -55,9 +55,9 @@ export const VOCABULARIO = {
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
},
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
// (afluencia), verificados y sembrados el 2026-09-04
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
// Labels oficiales de desarrollo (verificados el 2026-09-04)
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
} as const

View File

@@ -1,13 +1,11 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
cambiarEstadoApartado,
cambiarEstadoApartadoEndpoint,
FASES_APARTADO_PERMITIDAS,
subirDocumentosApartado,
TIPOS_DOCUMENTO_APARTADO,
} from './apartados.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -25,9 +23,6 @@ const prismaMock = vi.hoisted(() => ({
documento: {
create: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
@@ -36,75 +31,7 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
function mondayMock(grupoActual = 'topics') {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientos: [] as { itemId: string; grupo: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async columnaIdPorTitulo(_boardId: string, titulo: string) {
return titulo === 'Estado' ? 'status' : null
},
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Cliente Demo',
groupId: grupoActual,
column_values: [
{ id: 'status', text: (this.cambios.find((c) => c.columna === 'Estado')?.valor as string) ?? '', value: '{}' },
],
}
},
async moverItemAGrupo(itemId: string, grupo: string) {
this.movimientos.push({ itemId, grupo })
},
}
}
describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
it('cambia la fase (board 666 = MONDAY_BOARD_APARTADO de test)', async () => {
const monday = mondayMock()
await cambiarEstadoApartado(monday, 'apt-1', 'Alta Enkontrol')
expect(monday.cambios).toEqual([
{ boardId: '666', itemId: 'apt-1', columna: 'Estado', valor: 'Alta Enkontrol' },
])
// 'Alta Enkontrol' mapea al grupo topics: ya estaba ahí → no se mueve
expect(monday.movimientos).toEqual([])
})
it('Alta Enkontrol se queda en "Nuevos Clientes" (grupo topics)', async () => {
const monday = mondayMock()
await cambiarEstadoApartado(monday, 'apt-2', 'Alta Enkontrol')
expect(monday.movimientos).toEqual([])
})
it('Cancelacion Apartado mueve el item a "Historico"', async () => {
const monday = mondayMock()
await cambiarEstadoApartado(monday, 'apt-3', 'Cancelacion Apartado')
expect(monday.movimientos).toEqual([{ itemId: 'apt-3', grupo: 'group_mm6s2yyx' }])
})
it('las fases permitidas son los labels reales del board Apartado', () => {
expect(FASES_APARTADO_PERMITIDAS).toEqual([
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Cancelacion Apartado',
])
})
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
})
})
// ---------- Modo local (saludMonday caído / ids loc_*) ----------
describe('ruteo modo local (apartados)', () => {
describe('cambiarEstadoApartadoEndpoint (POST /api/apartados/:id/estado) — local', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
@@ -113,14 +40,10 @@ describe('ruteo modo local (apartados)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data }))
prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data }))
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
@@ -128,8 +51,7 @@ describe('ruteo modo local (apartados)', () => {
vi.restoreAllMocks()
})
it('cambiarEstadoApartadoEndpoint con id loc_*: fase local + CAMBIO_ETAPA encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // el id loc_* manda aunque Monday viva
it('con id loc_*: fase en la fila local', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_apt', board: 'APARTADO' })
const { res, json } = resFake()
@@ -142,14 +64,77 @@ describe('ruteo modo local (apartados)', () => {
where: { leadId: 'loc_apt' },
data: { etapa: 'Alta Enkontrol', pendienteSync: true },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tipo: 'CAMBIO_ETAPA', referenciaLocal: 'loc_apt' }),
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_apt', estado: 'Alta Enkontrol' })
})
it('subirDocumentosApartado con Monday caído: nombre desde LeadCaptura local y checkbox encolado (CHECKBOX_DOC)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('con id heredado sin fila local: crea la sombra (mondayItemId)', async () => {
const { res, json } = resFake()
await cambiarEstadoApartadoEndpoint(
{ params: { itemId: '99887766' }, body: { estado: 'Cancelacion Apartado' } } as unknown as Request,
res,
)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: '99887766', board: 'APARTADO', mondayItemId: '99887766' }),
})
expect(json).toHaveBeenCalledWith({ itemId: '99887766', estado: 'Cancelacion Apartado' })
})
it('rechaza una fase fuera del vocabulario (zod 400)', async () => {
const { res } = resFake()
await expect(
cambiarEstadoApartadoEndpoint(
{ params: { itemId: 'loc_apt' }, body: { estado: 'Fase Inventada' } } as unknown as Request,
res,
),
).rejects.toThrow()
expect(prismaMock.leadCaptura.update).not.toHaveBeenCalled()
})
it('las fases permitidas son el vocabulario canónico del CRM (pipeline Enkontrol)', () => {
expect(FASES_APARTADO_PERMITIDAS).toEqual([
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
'Venta',
'Seguimiento',
'Integracion',
'Llamada Escrituras',
'X Escriturar',
'Banco/Infonavit',
'X Autorizar',
'Autorizado',
'Escriturado',
'Titulado',
'Cancelo',
])
})
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
})
})
describe('subirDocumentosApartado (POST /api/apartados/:id/documentos) — solo local', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data }))
prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data }))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('crea el cliente mínimo con el nombre de la fila local y guarda el documento', async () => {
// El apartado local nació del "Aparto" de la oportunidad: su fila tiene el nombre
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_apt', nombreLead: 'Luis Demo' })
const archivo = {
@@ -158,27 +143,46 @@ describe('ruteo modo local (apartados)', () => {
mimetype: 'application/pdf',
size: 1234,
} as Express.Multer.File
const { res, status } = resFake()
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
const res = { json, status } as unknown as Response
await subirDocumentosApartado(
{ params: { itemId: 'loc_apt' }, body: { tipo: 'CURP' }, files: [archivo], usuarioToken: { sub: 'u-1' } } as unknown as Request,
res,
)
// Cliente mínimo creado con el nombre de la fila local (no truena por obtenerNombreItem)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Luis Demo', mondayApartadoId: 'loc_apt' }),
})
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clienteId: 'cli-local', tipo: 'CURP' }),
})
// El checkbox no se intenta en Monday: queda encolado para el worker
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'CHECKBOX_DOC',
payloadJson: JSON.stringify({ checkbox: 'CURP', itemId: 'loc_apt' }),
referenciaLocal: 'loc_apt',
}),
// Solo local: nada en la cola de réplica
expect(status).toHaveBeenCalledWith(201)
})
it('sin fila local usa "Cliente" como nombre y guarda igual', async () => {
const archivo = {
originalname: 'ine.pdf',
path: '/tmp/uploads/apartados/loc_x/OTRO_2.pdf',
mimetype: 'application/pdf',
size: 42,
} as Express.Multer.File
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
const res = { json, status } as unknown as Response
await subirDocumentosApartado(
{ params: { itemId: 'loc_x' }, body: {}, files: [archivo] } as unknown as Request,
res,
)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Cliente' }),
})
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tipo: 'OTRO' }),
})
expect(status).toHaveBeenCalledWith(201)
})

View File

@@ -5,41 +5,17 @@ import path from 'node:path'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { VOCABULARIO } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
/**
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
* mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES
* de la columna status 'Estado' del board (verificados en Monday) — el
* frontend los manda tal cual.
* mover desde los botones de "Mis apartados" en Mi día. Es el vocabulario
* canónico del CRM (config/vocabulario.ts) — el frontend los manda tal cual.
*/
export const FASES_APARTADO_PERMITIDAS = [
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
'Venta',
'Seguimiento',
'Integracion',
'Llamada Escrituras',
'X Escriturar',
'Banco/Infonavit',
'X Autorizar',
'Autorizado',
'Escriturado',
'Titulado',
'Cancelo',
] as const
export const FASES_APARTADO_PERMITIDAS = VOCABULARIO.FASES_APARTADO
const cambiarEstadoSchema = z.object({
estado: z.enum(FASES_APARTADO_PERMITIDAS),
@@ -48,48 +24,18 @@ const cambiarEstadoSchema = z.object({
comentario: z.string().optional(),
})
type MondayApartados = Pick<
MondayService,
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
>
/**
* Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que
* corresponde (idempotente; el webhook de change haría lo mismo, pero así la
* UI responde al instante).
* Cambia la fase del apartado (etapa de la fila local) y encola el cambio para
* la réplica. SIEMPRE local: la fila LeadCaptura board=APARTADO es la verdad;
* ids heredados del CRM anterior sin fila crean sombra.
*/
export async function cambiarEstadoApartado(
monday: MondayApartados,
itemId: string,
estado: (typeof FASES_APARTADO_PERMITIDAS)[number],
): Promise<void> {
const boardApartado = config.monday.boards.apartado
await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado)
await moverAGrupoSegunEstado(monday, boardApartado, itemId)
}
/** POST /api/apartados/:itemId/estado { estado } */
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
// Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local
// + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
await cambiarEstadoApartado(mondayService, itemId, body.estado)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: true,
})
}
}
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
res.json({ itemId, estado: body.estado })
const detalle = body.motivo
? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}`
@@ -114,7 +60,7 @@ const EXT_POR_MIME_APARTADO: Record<string, string> = {
'image/png': 'png',
}
// Storage propio: archiva por item de Monday (uploads/apartados/<itemId>/)
// Storage propio: archiva por apartado (uploads/apartados/<itemId>/)
const storageApartados = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = path.join(config.uploadsDir, 'apartados', String(req.params['itemId']))
@@ -141,30 +87,18 @@ export const uploadDocumentosApartado = multer({
})
/**
* Cliente local ligado al apartado de Monday; si aún no existe (apartado
* creado sin pasar por el alta progresiva), se crea la ficha mínima para que
* los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday
* está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del
* apartado (board APARTADO, creada por el "Aparto" local) como fallback.
* Cliente local ligado al apartado; si aún no existe (apartado creado sin
* pasar por el alta progresiva), se crea la ficha mínima para que los
* documentos tengan dónde vivir. El nombre sale de la fila LeadCaptura del
* apartado (creada por el "Aparto" local o por la sombra de un item viejo).
*/
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
if (existente) return existente
let nombre: string | null = null
if (!esIdLocal(itemId) && saludMonday.estaDisponible()) {
try {
nombre = await mondayService.obtenerNombreItem(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err)
}
}
if (nombre === null) {
const captura = await prisma.leadCaptura.findFirst({
where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] },
})
nombre = captura?.nombreLead ?? 'Cliente'
}
const captura = await prisma.leadCaptura.findFirst({
where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] },
})
const nombre = captura?.nombreLead ?? 'Cliente'
return prisma.cliente.create({
data: {
nombreCompleto: nombre,
@@ -180,9 +114,8 @@ async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
/**
* POST /api/apartados/:itemId/documentos — multipart, campo `archivos` (hasta
* 10) + campo `tipo`. Los documentos se guardan en el CLIENTE ligado al
* apartado (Documento.clienteId); si el tipo tiene checkbox en el tablero
* Apartado (CURP/RFC/Comprobante), se prende en Monday.
* 10) + campo `tipo`. SOLO local: los documentos se guardan en el CLIENTE
* ligado al apartado (Documento.clienteId).
*/
export async function subirDocumentosApartado(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
@@ -204,24 +137,6 @@ export async function subirDocumentosApartado(req: Request, res: Response): Prom
}),
),
)
// Prende el checkbox del documento en el item de Apartado (si el tipo tiene
// checkbox); un fallo de Monday no rompe la subida: queda encolado
// (CHECKBOX_DOC) y el worker lo aplica al sincronizar.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null) {
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
} else {
try {
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
}
}
}
res.status(201).json(documentos)
}

View File

@@ -6,12 +6,12 @@ export async function estadoAutomatizaciones(_req: Request, res: Response): Prom
res.json(await automatizacionesService.estado())
}
/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación */
/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación (local) */
export async function correrRoundRobin(_req: Request, res: Response): Promise<void> {
res.json(await automatizacionesService.runRoundRobin())
res.json(await automatizacionesService.runRoundRobinLocal())
}
/** GET /api/automatizaciones/inactividad/preview — leads que serían recordados hoy (sin escribir) */
export async function previewInactividad(_req: Request, res: Response): Promise<void> {
res.json(await automatizacionesService.escaneoInactividad({ preview: true }))
res.json(await automatizacionesService.escaneoInactividadLocal({ preview: true }))
}

View File

@@ -1,356 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { contactoDesdeDatos, textoDireccion } from './clientes.controller.js'
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
crearContacto: vi.fn(),
actualizarContacto: vi.fn(),
crearApartado: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
moverAApartoConDedup: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
const reqFake = (body: unknown, params: Record<string, string> = {}): Request =>
({ body, params }) as unknown as Request
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const datosWizard = {
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
email: 'ana@correo.com',
celular: '6649990003',
estadoCivil: 'SOLTERO',
tipoVivienda: 'RENTADA',
tipoCompra: 'VIVIR_AHI',
fechaNacimiento: '1990-01-01',
},
empleo: { tipoEmpleo: 'ASALARIADO' },
viviendaCredito: {
proyecto: 'DIAGONAL',
domicilioFiscal: {
calle: 'Av. Siempre Viva',
numExt: '123',
numInt: '4',
colonia: 'Col. Centro',
cp: '22000',
municipio: 'Tijuana',
estado: 'Baja California',
},
},
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.actualizarContacto.mockResolvedValue(undefined)
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
})
describe('textoDireccion (domicilio del wizard → columna Dirección)', () => {
it('arma calle con ext/int, colonia, CP, municipio y estado', () => {
expect(
textoDireccion({
calle: 'Av. Siempre Viva',
numExt: '123',
numInt: '4',
colonia: 'Col. Centro',
cp: '22000',
municipio: 'Tijuana',
estado: 'Baja California',
}),
).toBe('Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California')
})
it('omite las partes vacías y devuelve undefined si no hay nada', () => {
expect(textoDireccion({ calle: 'Calle Uno', municipio: 'Tijuana' })).toBe('Calle Uno, Tijuana')
expect(textoDireccion({})).toBeUndefined()
expect(textoDireccion({ calle: ' ' })).toBeUndefined()
})
})
describe('contactoDesdeDatos (estado del wizard → campos del Contacto)', () => {
it('deriva todos los campos mapeables presentes', () => {
const c = contactoDesdeDatos(datosWizard)
expect(c).toMatchObject({
nombre: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
email: 'ana@correo.com',
telefono: '6649990003',
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
estadoCivilLabel: 'Soltero',
viviendaActualLabel: 'Rentada',
propositoCompraLabel: 'Habitarla',
rangoEdadLabel: 'Entre 24 y 40',
fechaNacimiento: '1990-01-01',
actividadEconomicaLabel: 'Empleado',
})
})
it('solo incluye campos presentes (no inventa datos)', () => {
const c = contactoDesdeDatos({ datosGenerales: { nombreCompleto: 'Solo Nombre' } })
expect(c).toEqual({ nombre: 'Solo Nombre' })
})
it('sin domicilioFiscal usa el texto libre datosGenerales.direccion', () => {
const c = contactoDesdeDatos({
datosGenerales: { nombreCompleto: 'Ana', direccion: 'Calle Libre 1' },
})
expect(c.direccion).toBe('Calle Libre 1')
})
})
describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => {
it('crea el Cliente local y el Contacto en Monday con Estado PROSPECTO', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await iniciarProgreso(
reqFake({
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
celular: '6649990003',
},
}),
res,
)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: 'cont-1' })
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
nombre: 'Ana Demo Progreso',
estadoLabel: 'PROSPECTO',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
telefono: '6649990003',
})
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'cont-1' },
})
})
it('requiere solo datosGenerales.nombreCompleto', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status } = resFake()
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
nombre: 'Solo Nombre',
estadoLabel: 'PROSPECTO',
})
await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow()
})
it('fallo de Monday: crea el local igual y devuelve mondayContactoId null', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } })
;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' }
await iniciarProgreso(req, resFake().res)
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
)
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res)
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
)
})
})
describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => {
it('deriva los campos presentes y actualiza el Contacto (incl. Dirección armada)', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { payloadJson: JSON.stringify(datosWizard) },
})
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
'cont-1',
expect.objectContaining({
nombre: 'Ana Demo Progreso',
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
actividadEconomicaLabel: 'Empleado',
telefono: '6649990003',
}),
)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: true })
})
it('sin mondayContactoId responde mondaySync: false y no toca Monday', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: null })
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('fallo de Monday: 200 igual con mondaySync: false', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
mondayMock.actualizarContacto.mockRejectedValue(new Error('Monday caído'))
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('rechaza un body que no sea objeto', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
await expect(actualizarProgreso(reqFake('no-objeto', { id: 'cli-1' }), resFake().res)).rejects.toThrow()
})
})
describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () => {
const altaCompleta = {
...datosWizard,
viviendaCredito: {
...datosWizard.viviendaCredito,
ubicacion: 'U-999-DEMO2',
valorVenta: 1500000,
enganche: 20000,
},
firma: { firmaDataUrl: 'data:image/png;base64,AAAA' },
leadMondayId: 'lead-9',
}
it('pasa el Contacto a CLIENTE, crea el Apartado y mueve el lead a Aparto (dedup)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
const { prismaStateStore } = await import('../services/automatizaciones.service.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: null,
})
const { res, status, json } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
'cont-1',
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Ana Demo Progreso',
unidadCodigo: 'U-999-DEMO2',
montoApartado: 20000,
estadoLabel: 'Pago Apartado',
}),
)
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
expect(json.mock.calls[0]?.[0]).toMatchObject({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: 'ap-1',
})
})
it('el Apartado lleva la ficha completa: contacto, precio, Forma de Pago y Vendedor del capturista original', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: null,
capturadoPorId: 'usr-9', // quien inició el alta progresiva
})
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const altaConCredito = {
...altaCompleta,
viviendaCredito: { ...altaCompleta.viviendaCredito, tiposCredito: ['COFI_FOVISSSTE'] },
}
const { res, status } = resFake()
await completarProgreso(reqFake(altaConCredito, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Ana Demo Progreso',
unidadCodigo: 'U-999-DEMO2',
telefono: '6649990003',
email: 'ana@correo.com',
curp: 'AEDA900101MBCXXX00',
precioFinal: 1500000,
enganche: 20000,
vendedorId: 12345,
formaPagoLabel: 'Cofi-Fovissste',
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
}),
)
})
it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: null,
mondayApartadoId: null,
})
const { res, status } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
expect(mondayMock.crearContacto).toHaveBeenCalledWith(
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
)
})
it('rechaza un payload incompleto (misma validación que POST /clientes)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
await expect(
completarProgreso(
reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }),
resFake().res,
),
).rejects.toThrow()
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
})
it('404 si el progreso no existe', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(
completarProgreso(reqFake(altaCompleta, { id: 'no-existe' }), resFake().res),
).rejects.toThrow('Cliente no encontrado')
})
})

View File

@@ -1,45 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
actividadEconomicaLabel,
checkboxParaTipoDocumento,
perfilDemografico,
rangoEdadLabel,
} from './clientes.controller.js'
import { checkboxParaTipoDocumento, formaPagoLabel } from './clientes.controller.js'
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
leadCaptura: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
leadOportunidad: {
findUnique: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
}))
const mondayMock = vi.hoisted(() => ({
crearContacto: vi.fn(),
crearApartado: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
moverAApartoConDedup: vi.fn(),
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => {
it('mapea los tipos con checkbox al título de la columna', () => {
describe('checkboxParaTipoDocumento (tipos de documento del expediente)', () => {
it('mapea los tipos con checkbox al nombre del checkbox', () => {
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
expect(checkboxParaTipoDocumento('RFC')).toBe('RFC')
expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC')
@@ -59,50 +54,26 @@ describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', ()
})
})
describe('rangoEdadLabel', () => {
const hoy = new Date('2026-08-22T12:00:00')
it('deriva el rango del CRM a partir de la fecha de nacimiento', () => {
expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años
expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy
expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40')
expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo
expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40')
describe('formaPagoLabel (tipos de crédito del alta)', () => {
it('mapea los 8 valores del enum del formato', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit')
expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit')
expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit')
expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito')
expect(formaPagoLabel(['CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste')
expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste')
expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario')
})
it('devuelve undefined con fechas inválidas o ausentes', () => {
expect(rangoEdadLabel(undefined, hoy)).toBeUndefined()
expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined()
expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined()
})
})
describe('perfilDemografico (alta → Contacto Monday)', () => {
it('mapea los valores del formato a los labels del CRM', () => {
const perfil = perfilDemografico({
estadoCivil: 'UNION_LIBRE',
tipoVivienda: 'FAMILIAR',
tipoCompra: 'VIVIR_AHI',
fechaNacimiento: '1990-05-10',
})
expect(perfil.estadoCivilLabel).toBe('Union Libre')
expect(perfil.viviendaActualLabel).toBe('Comparte con familia')
expect(perfil.propositoCompraLabel).toBe('Habitarla')
expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40')
expect(perfil.fechaNacimiento).toBe('1990-05-10')
})
it('omite campos vacíos o desconocidos (no inventa datos)', () => {
expect(perfilDemografico({})).toEqual({})
expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({})
})
})
describe('actividadEconomicaLabel', () => {
it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => {
expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado')
expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina
expect(actividadEconomicaLabel(undefined)).toBeUndefined()
it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined()
expect(formaPagoLabel([])).toBeUndefined()
expect(formaPagoLabel(undefined)).toBeUndefined()
expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo
})
})
@@ -125,53 +96,141 @@ describe('crearClienteSchema', () => {
})
})
describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () => {
// Import diferido para que los vi.mock de arriba apliquen al controller
const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
viviendaCredito: { proyecto: 'SENDA' },
}
// ---------- Alta local (POST /api/clientes) ----------
const reqFake = (body: unknown): Request => ({ body } as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
viviendaCredito: { proyecto: 'SENDA' },
}
const reqFake = (body: unknown, params: Record<string, string> = {}, usuarioToken?: { sub: string }): Request =>
({ body, params, usuarioToken } as unknown as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const oportunidadLocal = {
leadId: 'loc_op_9',
board: 'OPORTUNIDADES',
nombreLead: 'Lead Prueba',
usuarioId: 'u-1',
telefono: '4771234567',
email: null,
desarrollo: 'SENDA',
modelo: 'SOHO',
datosJson: null,
}
describe('crearCliente (POST /api/clientes) — alta local', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
mondayMock.crearContacto.mockResolvedValue('cont-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => {
it('201: persiste el Cliente en SQLite y responde mondayContactoId null', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { prismaStateStore } = await import('../services/automatizaciones.service.js')
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
const { res, status, json } = resFake()
await crearCliente(reqFake(altaBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Cliente Demo', proyecto: 'SENDA' }),
})
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null })
})
it('con leadMondayId mueve la oportunidad a Aparto y crea el apartado local (dedup)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
// El id del picker es una oportunidad directa (modelo nuevo)
prismaMock.leadOportunidad.findUnique
.mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // buscarPorOportunidadId
.mockResolvedValue(null)
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res)
expect(status).toHaveBeenCalledWith(201)
// Apartado local creado (board APARTADO, primer paso del pipeline) y encolado
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }),
})
// La ficha del cliente queda ligada al apartado local
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayApartadoId: expect.stringMatching(/^loc_/) },
})
expect(json.mock.calls[0]?.[0]).toMatchObject({
id: 'cli-1',
mondayContactoId: null,
mondayApartadoId: expect.stringMatching(/^loc_/),
})
})
it('con leadMondayId de un lead histórico resuelve la oportunidad vía mapping', async () => {
const { crearCliente } = await import('./clientes.controller.js')
prismaMock.leadOportunidad.findUnique
.mockResolvedValueOnce(null) // no es oportunidad directa
.mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // mapping lead → oportunidad
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_lead_1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO' }),
})
})
it('sin leadMondayId no toca el funnel', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { res, status } = resFake()
await crearCliente(reqFake(altaBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.moverAApartoConDedup).not.toHaveBeenCalled()
expect(prismaMock.leadOportunidad.findUnique).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
})
it('con apartadoMondayId (botón "Alta Enkontrol" desde Mis apartados) LIGA ese apartado sin crear otro', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, apartadoMondayId: 'loc_apt_existente' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayApartadoId: 'loc_apt_existente' },
})
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'loc_apt_existente' })
})
it('un fallo del funnel no rompe el alta (201 igual)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído'))
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' })
prismaMock.leadCaptura.findUnique.mockResolvedValue(null) // apartar → NotFoundError
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res)
espia.mockRestore()
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayApartadoId: null })
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
@@ -187,193 +246,159 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
})
})
describe('formaPagoLabel (tipos de crédito del alta → dropdown Forma de Pago)', () => {
it('mapea los 8 valores del enum del formato a los labels sembrados en Monday', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit')
expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit')
expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit')
expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito')
expect(formaPagoLabel(['CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste')
expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste')
expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario')
})
it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined()
expect(formaPagoLabel([])).toBeUndefined()
expect(formaPagoLabel(undefined)).toBeUndefined()
expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo
})
})
describe('crearCliente — Apartado con ficha completa', () => {
const altaConVenta = {
datosGenerales: {
nombreCompleto: 'Cliente Demo',
curp: 'CURP123',
rfc: 'RFC123',
email: 'cli@correo.com',
celular: '6641112233',
},
viviendaCredito: {
proyecto: 'SENDA',
ubicacion: 'U-101',
valorVenta: 1500000,
enganche: 20000,
tiposCredito: ['INFONAVIT'],
},
}
const reqFake = (body: unknown, usuarioToken?: { sub: string }): Request =>
({ body, usuarioToken } as unknown as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
describe('alta progresiva — local', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' })
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => {
const { crearCliente } = await import('./clientes.controller.js')
it('iniciarProgreso crea el alta en progreso (sin réplica externa)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
montoApartado: 20000,
estadoLabel: 'Pago Apartado',
telefono: '6641112233',
email: 'cli@correo.com',
curp: 'CURP123',
precioFinal: 1500000,
enganche: 20000,
formaPagoLabel: 'Infonavit',
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
await iniciarProgreso(
reqFake({
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
celular: '6649990003',
},
}),
res,
)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'ap-1' })
})
it('con capturista vinculado a Monday, el Apartado lo lleva como Vendedor (people)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
await crearCliente(reqFake(altaConVenta, { sub: 'usr-1' }), resFake().res)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: 12345 }))
})
it('sin capturista vinculado, el Apartado se crea sin Vendedor', async () => {
const { crearCliente } = await import('./clientes.controller.js')
await crearCliente(reqFake(altaConVenta), resFake().res)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined }))
})
})
describe('réplica diferida (encolado cuando Monday falla)', () => {
const altaConVenta = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6641112233' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000 },
}
const reqFake = (body: unknown): Request => ({ body } as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.syncPendiente.create.mockResolvedValue({})
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('falla crearContacto: 201 con ids null y CREAR_CONTACTO + CREAR_APARTADO encolados', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_CONTACTO', 'CREAR_APARTADO'])
// El payload del Contacto lleva los datos del alta (el worker no necesita nada más)
const llamadaContacto = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaContacto.data.payloadJson)['datos']).toMatchObject({
nombre: 'Cliente Demo',
estadoLabel: 'CLIENTE',
curp: 'CURP123',
})
expect(llamadaContacto.data.referenciaLocal).toBe('cli-1')
// El del Apartado referencia al cliente para ligar mondayApartadoId al sincronizar
const llamadaApartado = prismaMock.syncPendiente.create.mock.calls[1]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaApartado.data.payloadJson)).toMatchObject({
clienteId: 'cli-1',
datos: { clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', estadoLabel: 'Pago Apartado' },
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Ana Demo Progreso' }),
})
})
it('falla solo el Apartado: conserva mondayContactoId (lo persiste) y encola CREAR_APARTADO', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayContactoId: 'cont-1', mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_APARTADO']) // el Contacto ya existe: no se duplica
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'cont-1', mondayApartadoId: null },
})
})
it('falla moverAApartoConDedup: encola APARTO_OPORTUNIDAD con el leadMondayId', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído'))
it('iniciarProgreso requiere solo datosGenerales.nombreCompleto', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res)
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(tiposEncolados()).toEqual(['APARTO_OPORTUNIDAD'])
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'APARTO_OPORTUNIDAD',
payloadJson: JSON.stringify({ leadId: 'lead-9' }),
referenciaLocal: 'lead-9',
}),
await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow()
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } })
;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' }
await iniciarProgreso(req, resFake().res)
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
)
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res)
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
)
})
it('actualizarProgreso guarda el avance del wizard localmente (mondaySync false)', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
const datosWizard = { datosGenerales: { nombreCompleto: 'Ana', celular: '6649990003' } }
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
const { res, json } = resFake()
await actualizarProgreso({ body: datosWizard, params: { id: 'cli-1' } } as unknown as Request, res)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { payloadJson: JSON.stringify(datosWizard) },
})
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('actualizarProgreso con id desconocido → 404', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(
actualizarProgreso({ body: {}, params: { id: 'no-existe' } } as unknown as Request, resFake().res),
).rejects.toThrow('Cliente no encontrado')
})
it('actualizarProgreso rechaza un body que no sea objeto', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
await expect(
actualizarProgreso({ body: 'no-objeto', params: { id: 'cli-1' } } as unknown as Request, resFake().res),
).rejects.toThrow()
})
it('completarProgreso cierra el alta y mueve la oportunidad del funnel a Aparto', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
capturadoPorId: 'usr-9',
mondayApartadoId: null,
})
const altaCompleta = {
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
leadMondayId: 'loc_op_9',
}
const { res, status, json } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: expect.objectContaining({ proyecto: 'SENDA', unidadCodigo: 'U-999' }),
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }),
})
// La ficha del apartado local se enriquece con lo capturado en el alta
const enriquecimiento = prismaMock.leadCaptura.update.mock.calls.find(
(c) => (c[0] as { where: { leadId: string } }).where.leadId !== 'loc_op_9',
)
expect(enriquecimiento).toBeDefined()
const datos = JSON.parse(
(enriquecimiento![0] as { data: { datosJson: string } }).data.datosJson,
) as Record<string, unknown>
expect(datos).toMatchObject({ unidadCodigo: 'U-999', precioFinal: 1500000, enganche: 20000, estadoLabel: 'Pago Apartado' })
expect(json.mock.calls[0]?.[0]).toMatchObject({
id: 'cli-1',
mondayContactoId: null,
mondayApartadoId: expect.stringMatching(/^loc_/),
})
})
it('completarProgreso rechaza un payload incompleto (misma validación que POST /clientes)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
await expect(
completarProgreso(
reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }),
resFake().res,
),
).rejects.toThrow()
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
})
it('completarProgreso 404 si el progreso no existe', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(
completarProgreso(reqFake(altaBase, { id: 'no-existe' }), resFake().res),
).rejects.toThrow('Cliente no encontrado')
})
})

View File

@@ -5,13 +5,8 @@ import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type DatosApartado, type DatosContacto } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { crmLocal } from '../services/crm-local.service.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
@@ -27,7 +22,7 @@ export const crearClienteSchema = z
email: z.string().optional(),
celular: z.string().optional(),
direccion: z.string().optional(),
// Perfil demográfico (opcionales; se replican al Contacto en Monday)
// Perfil demográfico (opcionales; viajan en el payload del alta)
fechaNacimiento: z.string().optional(),
estadoCivil: z.string().optional(),
tipoVivienda: z.string().optional(),
@@ -48,90 +43,9 @@ export const crearClienteSchema = z
})
.passthrough() // el resto del payload pasa tal cual a payloadJson
// ---------- Mapeos al vocabulario del CRM FRASA (lógica pura, testeable) ----------
const ESTADO_CIVIL_LABEL: Record<string, string> = {
SOLTERO: 'Soltero',
CASADO: 'Casado',
UNION_LIBRE: 'Union Libre',
DIVORCIADO: 'Divorciado',
VIUDO: 'Viudo',
}
const VIVIENDA_ACTUAL_LABEL: Record<string, string> = {
PRESTADA: 'Prestada',
RENTADA: 'Rentada',
FAMILIAR: 'Comparte con familia',
PROPIA: 'Propia',
}
const PROPOSITO_COMPRA_LABEL: Record<string, string> = {
INVERSION: 'Inversion',
VIVIR_AHI: 'Habitarla',
}
// El formato de alta solo permite inferir 'Empleado' (ASALARIADO); un
// INDEPENDIENTE puede ser Comerciante o Empresario: no se adivina.
const ACTIVIDAD_ECONOMICA_LABEL: Record<string, string> = {
ASALARIADO: 'Empleado',
}
/** Label del dropdown "Rango de Edad" derivado de la fecha de nacimiento */
export function rangoEdadLabel(fechaNacimiento: string | undefined, hoy: Date = new Date()): string | undefined {
if (!fechaNacimiento) return undefined
const nacimiento = new Date(`${fechaNacimiento.slice(0, 10)}T00:00:00`)
if (Number.isNaN(nacimiento.getTime())) return undefined
let edad = hoy.getFullYear() - nacimiento.getFullYear()
const aunNoCumple =
hoy.getMonth() < nacimiento.getMonth() ||
(hoy.getMonth() === nacimiento.getMonth() && hoy.getDate() < nacimiento.getDate())
if (aunNoCumple) edad--
if (edad < 0 || edad > 120) return undefined
if (edad < 24) return 'Menor de 24'
if (edad <= 40) return 'Entre 24 y 40'
return 'Mayor de 40'
}
/** Labels demográficos del Contacto a partir del payload del formato de alta */
export function perfilDemografico(dg: {
estadoCivil?: string | undefined
tipoVivienda?: string | undefined
tipoCompra?: string | undefined
fechaNacimiento?: string | undefined
}): {
estadoCivilLabel?: string | undefined
viviendaActualLabel?: string | undefined
propositoCompraLabel?: string | undefined
rangoEdadLabel?: string | undefined
fechaNacimiento?: string | undefined
} {
const limpio = (v: string | undefined): string => (v ?? '').trim().toUpperCase()
const fecha = /^\d{4}-\d{2}-\d{2}/.test(dg.fechaNacimiento ?? '')
? dg.fechaNacimiento!.slice(0, 10)
: undefined
return {
...(ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] !== undefined
? { estadoCivilLabel: ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] }
: {}),
...(VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] !== undefined
? { viviendaActualLabel: VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] }
: {}),
...(PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] !== undefined
? { propositoCompraLabel: PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] }
: {}),
...(rangoEdadLabel(fecha) !== undefined ? { rangoEdadLabel: rangoEdadLabel(fecha) } : {}),
...(fecha !== undefined ? { fechaNacimiento: fecha } : {}),
}
}
/** Label de "Actividad Economica" derivado del tipo de empleo del formato */
export function actividadEconomicaLabel(tipoEmpleo: string | undefined): string | undefined {
return ACTIVIDAD_ECONOMICA_LABEL[(tipoEmpleo ?? '').trim().toUpperCase()]
}
/**
* Checkbox del tablero Apartado que corresponde a un tipo de documento.
* INE y el resto no tienen checkbox (devuelve null).
* Checkbox que corresponde a un tipo de documento en el expediente del
* apartado. INE y el resto no tienen checkbox (devuelve null).
*/
export function checkboxParaTipoDocumento(tipo: string): string | null {
switch (tipo.trim().toUpperCase()) {
@@ -162,33 +76,7 @@ const hoyMas = (dias: number): string => {
return `${d.getFullYear()}-${m}-${dia}`
}
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
/**
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico del
* tablero Leads; moverAApartoConDedup resuelve ambos vía LeadOportunidad.
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
* Un fallo aquí no rompe el alta.
*/
async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<void> {
const leadMondayId = body['leadMondayId']
if (typeof leadMondayId === 'string' && leadMondayId !== '') {
try {
await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err)
// El worker lo reintenta cuando Monday vuelva (con el mismo dedup)
await encolarSyncSeguro('APARTO_OPORTUNIDAD', { leadId: leadMondayId }, leadMondayId)
}
}
}
/** Label del dropdown "Forma de Pago" (tablero Apartado) a partir del enum del formato de alta */
/** Label de "Forma de Pago" a partir del enum del formato de alta */
const FORMA_PAGO_LABEL: Record<string, string> = {
INFONAVIT: 'Infonavit',
APOYO_INFONAVIT: 'Apoyo Infonavit',
@@ -201,9 +89,8 @@ const FORMA_PAGO_LABEL: Record<string, string> = {
}
/**
* Label de "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo
* del wizard; se toma el PRIMERO reconocido — la columna del board es de una
* sola forma de pago). undefined si no hay ninguno reconocible.
* "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo del
* wizard; se toma el PRIMERO reconocido). undefined si no hay ninguno.
*/
export function formaPagoLabel(tiposCredito: unknown): string | undefined {
if (!Array.isArray(tiposCredito)) return undefined
@@ -214,80 +101,54 @@ export function formaPagoLabel(tiposCredito: unknown): string | undefined {
return undefined
}
// ---------- Helpers locales compartidos (alta directa y alta progresiva) ----------
const apartadoLigadoDeBody = (body: Record<string, unknown>): string | null => {
const id = body['apartadoMondayId']
return typeof id === 'string' && id !== '' ? id : null
}
/**
* Datos del item en el tablero Apartado (pipeline Enkontrol) para el alta;
* null si no hay valor de venta (no aplica apartado). Lo usan tanto la réplica
* directa (crearApartadoSiAplica) como el encolado diferido (CREAR_APARTADO).
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto' y se
* crea el apartado local (crmLocal.apartar; idempotente por dedup). El id
* puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico: se
* resuelve ambos vía LeadOportunidad, como hacía moverAApartoConDedup.
* Devuelve el id de la fila de apartado local creada (null si no aplica).
* Un fallo aquí no rompe el alta.
*/
async function datosApartadoDeAlta(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
async function moverLeadAApartoSiAplica(
body: Record<string, unknown>,
capturadoPorId: string | null,
): Promise<DatosApartado | null> {
const valorVenta = aNumero(vc.valorVenta)
if (valorVenta <= 0) return null
const enganche = aNumero(vc.enganche)
// Vendedor del apartado: el usuario Monday vinculado de quien capturó el alta
let vendedorId: number | undefined
if (capturadoPorId !== null) {
const capturista = await prisma.usuario.findUnique({
where: { id: capturadoPorId },
select: { mondayUserId: true },
): Promise<string | null> {
const leadId = body['leadMondayId']
if (typeof leadId !== 'string' || leadId === '') return null
try {
const comoOportunidad = await prisma.leadOportunidad.findUnique({
where: { oportunidadId: leadId },
})
vendedorId = capturista?.mondayUserId ?? undefined
const mapping =
comoOportunidad ??
(await prisma.leadOportunidad.findUnique({ where: { leadId } }))
const oportunidadId = comoOportunidad?.oportunidadId ?? mapping?.oportunidadId ?? null
if (oportunidadId === null) return null
const apartado = await crmLocal.apartar(oportunidadId, capturadoPorId ?? undefined)
return apartado.leadId
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadId}:`, err)
return null
}
return {
clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion ?? '',
...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
telefono: dg.celular,
email: dg.email,
curp: dg.curp,
precioFinal: valorVenta,
enganche: enganche > 0 ? enganche : undefined,
fechaCompra: hoyMas(0),
vendedorId,
formaPagoLabel: formaPagoLabel(vc.tiposCredito),
}
}
/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */
async function crearApartadoSiAplica(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
): Promise<string | null> {
const datos = await datosApartadoDeAlta(dg, vc, capturadoPorId)
if (datos === null) return null
return mondayService.crearApartado(datos)
}
/**
* Réplica diferida del alta (modo local): cuando la réplica Monday falla a
* media operación, encola en SyncPendiente solo lo que no alcanzó a crearse
* (Contacto y/o Apartado) para que el worker lo aplique cuando Monday vuelva.
* Si el alta venía ligada a un apartado Monday existente (apartadoMondayId),
* no se encola la creación: el item ya existe (solo falló enriquecerlo).
* Devuelve el mondayApartadoId efectivo tras el fallback.
* Enriquece la fila de apartado local con lo capturado en el alta (unidad,
* precio, enganche, forma de pago, contacto): el "Alta Enkontrol" desde un
* apartado encuentra así la ficha completa al prellenar. Best-effort.
*/
async function encolarReplicasPendientesCliente(
clienteId: string,
datosContacto: DatosContacto,
mondayContactoId: string | null,
mondayApartadoId: string | null,
async function enriquecerApartadoLocal(
apartadoId: string,
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
@@ -295,57 +156,60 @@ async function encolarReplicasPendientesCliente(
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
apartadoMondayId: unknown,
): Promise<string | null> {
if (mondayContactoId === null) {
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContacto }, clienteId)
): Promise<void> {
try {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } })
if (!captura) return
let datos: Record<string, unknown> = {}
if (captura.datosJson) {
try { datos = JSON.parse(captura.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
}
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? ''
if (valorVenta > 0) datos['precioFinal'] = valorVenta
if (enganche > 0) {
datos['enganche'] = enganche
datos['montoApartado'] = enganche
}
datos['estadoLabel'] = 'Pago Apartado'
datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0)
datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15)
datos['fechaCompra'] = hoyMas(0)
const formaPago = formaPagoLabel(vc.tiposCredito)
if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago
if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular
if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email
if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp
await prisma.leadCaptura.update({
where: { leadId: apartadoId },
data: {
datosJson: JSON.stringify(datos),
...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}),
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err)
}
if (mondayApartadoId !== null) return mondayApartadoId
const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null
if (existente !== null) return existente
const datosApartado = await datosApartadoDeAlta(dg, vc, capturadoPorId)
if (datosApartado !== null) {
await encolarSyncSeguro('CREAR_APARTADO', { datos: datosApartado, clienteId }, clienteId)
}
return null
}
/**
* Apartado Monday del alta: con apartadoMondayId (alta arrancada desde un
* apartado existente, botón "Alta Enkontrol" de Mis apartados) se
* LIGA y enriquece ese item; sin él, se crea uno nuevo como siempre.
* Liga el apartado del alta a la ficha del Cliente (mondayApartadoId conserva
* el nombre histórico: es el id de la fila de apartado, local o heredado).
* Best-effort: un fallo no rompe el alta.
*/
async function apartadoDeAlta(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
apartadoMondayId: unknown,
): Promise<string | null> {
const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null
if (existente === null) return crearApartadoSiAplica(dg, vc, capturadoPorId)
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
// Solo se mandan los campos con valor (exactOptionalPropertyTypes)
await mondayService.actualizarApartado(existente, {
clienteNombre: dg.nombreCompleto,
...(vc.ubicacion !== undefined && vc.ubicacion !== '' ? { unidadCodigo: vc.ubicacion } : {}),
...(dg.celular !== undefined && dg.celular !== '' ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' ? { email: dg.email } : {}),
...(dg.curp !== undefined && dg.curp !== '' ? { curp: dg.curp } : {}),
...(valorVenta > 0 ? { precioFinal: valorVenta } : {}),
...(enganche > 0 ? { enganche } : {}),
...(formaPagoLabel(vc.tiposCredito) !== undefined ? { formaPagoLabel: formaPagoLabel(vc.tiposCredito) } : {}),
})
return existente
async function ligarApartadoACliente(clienteId: string, apartadoId: string): Promise<void> {
try {
await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err)
}
}
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
/** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
@@ -366,62 +230,22 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
},
})
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
let mondayContactoId: string | null = null
let mondayApartadoId: string | null = null
const datosContacto: DatosContacto = {
nombre: dg.nombreCompleto,
estadoLabel: 'CLIENTE',
...(dg.celular !== undefined ? { telefono: dg.celular } : {}),
...(dg.email !== undefined ? { email: dg.email } : {}),
curp: dg.curp,
rfc: dg.rfc,
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
// Perfil demográfico: solo lo que el formato de alta captura.
// Sin fuente en el formulario: Sexo (no se pregunta) y Actividad
// Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo).
...perfilDemografico(dg),
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
}
try {
mondayContactoId = await mondayService.crearContacto(datosContacto)
mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId)
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
// Encola la réplica diferida de lo que faltó y conserva los ids que sí se crearon
mondayApartadoId = await encolarReplicasPendientesCliente(
cliente.id,
datosContacto,
mondayContactoId,
mondayApartadoId,
dg,
vc,
req.usuarioToken?.sub ?? null,
body.apartadoMondayId,
)
if (mondayContactoId !== null || mondayApartadoId !== null) {
try {
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err2) {
// eslint-disable-next-line no-console
console.error('No se pudieron guardar los ids Monday del cliente:', err2)
}
// Apartado del alta: el existente (botón "Alta Enkontrol" desde Mis
// apartados) se LIGA tal cual; si el alta viene del funnel se mueve la
// oportunidad a 'Aparto' y se crea/enriquece el apartado local.
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, req.usuarioToken?.sub ?? null)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(cliente.id, mondayApartadoId)
}
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
res.status(201).json({ ...cliente, mondayContactoId: null, mondayApartadoId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id)
}
@@ -429,7 +253,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
/**
* POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son
* opcionales porque el Contacto PROSPECTO se crea en cuanto hay nombre.
* opcionales porque se completan en los pasos siguientes.
*/
export const iniciarProgresoSchema = z
.object({
@@ -455,62 +279,7 @@ export const iniciarProgresoSchema = z
/** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */
const actualizarProgresoSchema = z.object({}).passthrough()
const seccionDe = (datos: Record<string, unknown>, clave: string): Record<string, unknown> => {
const v = datos[clave]
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
}
const textoDe = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v : undefined
/** Texto de la columna Dirección del Contacto a partir del domicilio capturado */
export function textoDireccion(dom: Record<string, unknown>): string | undefined {
const t = (k: string): string => textoDe(dom[k])?.trim() ?? ''
const calleNum = [t('calle'), t('numExt')].filter((p) => p !== '').join(' ')
const numInt = t('numInt')
const linea1 = [calleNum, numInt !== '' ? `Int. ${numInt}` : ''].filter((p) => p !== '').join(', ')
const cp = t('cp')
const partes = [linea1, t('colonia'), cp !== '' ? `CP ${cp}` : '', t('municipio'), t('estado')].filter(
(p) => p !== '',
)
return partes.length > 0 ? partes.join(', ') : undefined
}
/**
* Deriva TODOS los campos mapeables del Contacto de Monday presentes en el
* estado del wizard (idempotente: se manda el estado completo en cada avance).
*/
export function contactoDesdeDatos(datos: Record<string, unknown>): Omit<DatosContacto, 'estadoLabel'> {
const dg = seccionDe(datos, 'datosGenerales')
const empleo = seccionDe(datos, 'empleo')
const vc = seccionDe(datos, 'viviendaCredito')
// El domicilio puede venir suelto ('domicilio') o como domicilioFiscal del paso Vivienda
const domRaiz = seccionDe(datos, 'domicilio')
const dom = Object.keys(domRaiz).length > 0 ? domRaiz : seccionDe(vc, 'domicilioFiscal')
const curp = textoDe(dg['curp'])
const rfc = textoDe(dg['rfc'])
const email = textoDe(dg['email'])
const telefono = textoDe(dg['celular'])
const direccion = textoDireccion(dom) ?? textoDe(dg['direccion'])
const actividadEconomica = actividadEconomicaLabel(textoDe(empleo['tipoEmpleo']))
return {
nombre: textoDe(dg['nombreCompleto']) ?? '',
...(curp !== undefined ? { curp } : {}),
...(rfc !== undefined ? { rfc } : {}),
...(email !== undefined ? { email } : {}),
...(telefono !== undefined ? { telefono } : {}),
...(direccion !== undefined ? { direccion } : {}),
...perfilDemografico({
estadoCivil: textoDe(dg['estadoCivil']),
tipoVivienda: textoDe(dg['tipoVivienda']),
tipoCompra: textoDe(dg['tipoCompra']),
fechaNacimiento: textoDe(dg['fechaNacimiento']),
}),
...(actividadEconomica !== undefined ? { actividadEconomicaLabel: actividadEconomica } : {}),
}
}
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso + Contacto PROSPECTO */
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso */
export async function iniciarProgreso(req: Request, res: Response): Promise<void> {
const body = iniciarProgresoSchema.parse(req.body)
const dg = body.datosGenerales
@@ -528,26 +297,10 @@ export async function iniciarProgreso(req: Request, res: Response): Promise<void
},
})
// Contacto PROSPECTO en Monday; si falla, el progreso local ya quedó y la
// réplica queda encolada (CREAR_CONTACTO) para cuando Monday vuelva
let mondayContactoId: string | null = null
const datosContactoProspecto: DatosContacto = {
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'PROSPECTO',
}
try {
mondayContactoId = await mondayService.crearContacto(datosContactoProspecto)
await prisma.cliente.update({ where: { id: cliente.id }, data: { mondayContactoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear el Contacto PROSPECTO en Monday:', err)
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContactoProspecto }, cliente.id)
}
res.status(201).json({ id: cliente.id, mondayContactoId })
res.status(201).json({ id: cliente.id, mondayContactoId: null })
}
/** PATCH /api/clientes/progreso/:id — guarda el avance y lo replica en el Contacto */
/** PATCH /api/clientes/progreso/:id — guarda el avance del wizard (local) */
export async function actualizarProgreso(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const body = actualizarProgresoSchema.parse(req.body)
@@ -556,21 +309,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise<v
await prisma.cliente.update({ where: { id }, data: { payloadJson: JSON.stringify(body) } })
// Réplica idempotente en Monday; un fallo no rompe el avance del wizard
// (queda encolada como ACTUALIZAR_CONTACTO para reintentar al volver Monday)
let mondaySync = false
if (cliente.mondayContactoId !== null) {
try {
await mondayService.actualizarContacto(cliente.mondayContactoId, contactoDesdeDatos(body))
mondaySync = true
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo actualizar el Contacto ${cliente.mondayContactoId} en Monday:`, err)
await encolarSyncSeguro('ACTUALIZAR_CONTACTO', { datos: contactoDesdeDatos(body) }, id)
}
}
res.json({ id, mondaySync })
res.json({ id, mondaySync: false })
}
/**
@@ -600,66 +339,20 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
},
})
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// El Contacto (creado como PROSPECTO al iniciar) pasa a CLIENTE con los
// datos finales; si no existe (nunca hubo Monday), se crea como en el alta directa
let mondayContactoId: string | null = cliente.mondayContactoId
let mondayApartadoId: string | null = cliente.mondayApartadoId
const datosContactoCliente: DatosContacto = {
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'CLIENTE',
}
try {
if (mondayContactoId !== null) {
await mondayService.actualizarContacto(mondayContactoId, datosContactoCliente)
} else {
mondayContactoId = await mondayService.crearContacto(datosContactoCliente)
}
if (mondayApartadoId === null) {
// Con apartadoMondayId (alta desde un apartado existente) se liga y
// enriquece ese item; si no, se crea. El capturista original del alta
// progresiva es quien queda como Vendedor.
mondayApartadoId = await apartadoDeAlta(dg, vc, cliente.capturadoPorId, body.apartadoMondayId)
}
await prisma.cliente.update({
where: { id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
// Encola la réplica diferida: actualización del Contacto si ya existía
// (es idempotente) o su creación; y el Apartado si no se creó
if (mondayContactoId !== null) {
await encolarSyncSeguro('ACTUALIZAR_CONTACTO', { datos: datosContactoCliente }, id)
}
mondayApartadoId = await encolarReplicasPendientesCliente(
id,
datosContactoCliente,
mondayContactoId,
mondayApartadoId,
dg,
vc,
cliente.capturadoPorId,
body.apartadoMondayId,
)
if (mondayContactoId !== null || mondayApartadoId !== null) {
try {
await prisma.cliente.update({
where: { id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err2) {
// eslint-disable-next-line no-console
console.error('No se pudieron guardar los ids Monday del cliente:', err2)
}
// Apartado del alta (misma lógica que el alta directa)
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, cliente.capturadoPorId)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(id, mondayApartadoId)
}
res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId })
res.status(201).json({ ...actualizado, mondayContactoId: null, mondayApartadoId })
}
/** GET /api/clientes/:id */
@@ -729,29 +422,6 @@ export async function subirDocumentos(req: Request, res: Response): Promise<void
}),
),
)
// Prende el checkbox del documento en el item de Apartado del cliente
// (pipeline Enkontrol). Un fallo de Monday no rompe la subida: queda
// encolado (CHECKBOX_DOC) para que el worker lo aplique al sincronizar.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null && cliente.mondayApartadoId !== null) {
if (esIdLocal(cliente.mondayApartadoId)) {
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId: cliente.mondayApartadoId }, cliente.id)
} else {
try {
await mondayService.cambiarValorColumna(
config.monday.boards.apartado,
cliente.mondayApartadoId,
checkbox,
true,
)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId: cliente.mondayApartadoId }, cliente.id)
}
}
}
res.status(201).json(documentos)
}
@@ -769,81 +439,18 @@ export async function listarDocumentos(req: Request, res: Response): Promise<voi
/**
* GET /api/altas/prefill-apartado/:itemId — todo lo ya recolectado de un
* apartado de Monday para prellenar el alta de cliente (botón "Alta Enkontrol"
* de Mis apartados): ficha del apartado, contacto relacionado
* (perfil completo), oportunidad relacionada (perfil de la afluencia) y la
* cotización más reciente vinculada. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email.
* apartado para prellenar el alta de cliente (botón "Alta Enkontrol" de Mis
* apartados): ficha del apartado (fila LeadCaptura board=APARTADO), contacto
* relacionado (ficha del Cliente), oportunidad relacionada (perfil de la
* afluencia) y la cotización más reciente vinculada. Las relaciones se arman
* por teléfono (últimos 10 dígitos) o email. 404 si no existe el apartado.
*/
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
// Monday caído (circuit breaker): prefill 100% local
if (!saludMonday.estaDisponible()) {
res.json(await prefillApartadoLocal(itemId))
return
}
try {
const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId)
if (!apartado) throw new NotFoundError('Apartado no encontrado')
const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10)
const email = (apartado.email ?? '').trim().toLowerCase()
const coincide = (t: string, e: string): boolean =>
(tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) ||
(email !== '' && e.trim().toLowerCase() === email)
const [contactos, oportunidades] = await Promise.all([
mondayService.getContactos(),
mondayService.getOportunidades(),
])
const contactoBase = contactos.find((c) => coincide(c.telefono, c.email))
const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null
const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null
// Cotización local más reciente: vinculada a la oportunidad o por teléfono
let cotizacion = null
if (oportunidad) {
cotizacion = await prisma.cotizacion.findFirst({
where: { mondayItemId: oportunidad.id },
orderBy: { createdAt: 'desc' },
})
}
if (!cotizacion && tel !== '') {
cotizacion = await prisma.cotizacion.findFirst({
where: { clienteTelefono: { contains: tel } },
orderBy: { createdAt: 'desc' },
})
}
res.json({
apartado,
contacto,
oportunidad,
cotizacion: cotizacion
? {
tipoPropuesta: cotizacion.tipoPropuesta,
acabados: cotizacion.acabados,
apartado: cotizacion.apartado,
precioLista: cotizacion.precioLista,
precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,
mensualidad: cotizacion.mensualidad,
pagoFinal: cotizacion.pagoFinal,
}
: null,
})
} catch (err) {
// Monday se cayó a media lectura: prefill local (el 404 de "apartado no
// encontrado" NO es fallo de Monday y se propaga intacto)
if (!esFalloMonday(err)) throw err
res.json(await prefillApartadoLocal(itemId))
}
res.json(await prefillApartadoLocal(itemId))
}
/** Shape de la cotización en la respuesta del prefill (compartido Monday/local) */
/** Shape de la cotización en la respuesta del prefill */
function cotizacionPrefill(cotizacion: {
tipoPropuesta: string
acabados: boolean
@@ -869,11 +476,10 @@ function cotizacionPrefill(cotizacion: {
}
/**
* Prefill local (Monday caído): el apartado es la fila LeadCaptura board=
* APARTADO (id loc_* o sombra); el contacto sale del Cliente ligado, la
* oportunidad de la captura board=OPORTUNIDADES con el mismo teléfono (su
* perfil va en datosJson) y la cotización de la tabla local. Mismo contrato
* que el camino Monday. 404 si no existe el apartado local.
* Prefill local: el apartado es la fila LeadCaptura board=APARTADO (id loc_* o
* sombra); el contacto sale del Cliente ligado, la oportunidad de la captura
* board=OPORTUNIDADES con el mismo teléfono (su perfil va en datosJson) y la
* cotización de la tabla local.
*/
async function prefillApartadoLocal(itemId: string) {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })

View File

@@ -1,138 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
leadCaptura: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
cotizacion: {
findMany: vi.fn(),
},
documento: {
findMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getContactos: vi.fn(),
getContactoDetalle: vi.fn(),
getUsuariosWorkspace: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const capturaLocal = {
id: 'row-1',
leadId: 'loc_1',
nombreLead: 'ANA DEMO',
telefono: '6649998877',
email: null,
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
motivo: null,
submotivo: null,
datosJson: null,
mondayItemId: null,
pendienteSync: true,
usuarioId: 'u-1',
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-03T10:00:00'),
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
})
describe('listarContactosEndpoint (fallback local)', () => {
it('Monday caído (circuit breaker): lista local sin llamar a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
expect(mondayMock.getContactos).not.toHaveBeenCalled()
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
})
it('Monday lanza 502: fallback local', async () => {
mondayMock.getContactos.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', fuente: 'local' })
})
})
describe('detalleContactoEndpoint (fallback local)', () => {
it('Monday caído: detalle 100% local con la misma forma', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
expect(detalle['propiedad']).toBeNull()
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
expect(Array.isArray(detalle['historial'])).toBe(true)
})
it('Monday caído y el id no existe en local → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
await expect(
detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
it('Monday lanza 502: cae al detalle local; el 404 de Monday se propaga intacto', async () => {
mondayMock.getContactoDetalle.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
expect(json).toHaveBeenCalledOnce()
mondayMock.getContactoDetalle.mockRejectedValue(new NotFoundError('Contacto no encontrado'))
await expect(
detalleContactoEndpoint({ params: { id: 'x' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
})

View File

@@ -1,23 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { listarContactosEndpoint } from './contactos.controller.js'
// ---------- Monday mockeado (los tests no tocan la red) ----------
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const mondayMock = vi.hoisted(() => ({ getContactos: vi.fn() }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
const CONTACTOS = [
{
id: '1',
nombre: 'JUAN PÉREZ',
telefono: '5512345678',
email: 'juan@correo.mx',
estado: 'CLIENTE',
updatedAt: '2026-09-01T10:00:00Z',
const prismaMock = vi.hoisted(() => ({
cliente: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
]
leadCaptura: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
cotizacion: {
findMany: vi.fn(),
},
documento: {
findMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js'
/**
* Contactos 100% locales: la lista y el detalle se arman desde las tablas
* locales (Cliente + LeadCaptura + Documento + Cotizacion) con la misma forma
* de DTO que consumen los fronts.
*/
const capturaLocal = {
id: 'row-1',
leadId: 'loc_1',
nombreLead: 'ANA DEMO',
telefono: '6649998877',
email: null,
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
motivo: null,
submotivo: null,
datosJson: null,
mondayItemId: null,
pendienteSync: true,
usuarioId: 'u-1',
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-03T10:00:00'),
}
function resFake() {
const json = vi.fn()
@@ -27,21 +62,56 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getContactos.mockResolvedValue(CONTACTOS)
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
})
describe('listarContactosEndpoint (GET /api/contactos)', () => {
it('devuelve los contactos del tablero de Monday', async () => {
describe('listarContactosEndpoint (GET /api/contactos) — local', () => {
it('lista desde LeadCaptura/Cliente con la misma forma (fuente local)', async () => {
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res)
expect(json).toHaveBeenCalledWith(CONTACTOS)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
})
it('propaga el error si Monday falla (el error handler responde 5xx)', async () => {
mondayMock.getContactos.mockRejectedValue(new Error('Monday caído'))
it('filtra por el usuario del token (sus clientes y sus capturas)', async () => {
const { res } = resFake()
await expect(listarContactosEndpoint({} as Request, res)).rejects.toThrow('Monday caído')
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res)
expect(prismaMock.cliente.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { capturadoPorId: 'u-1' } }),
)
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ usuarioId: 'u-1' }) }),
)
})
})
describe('detalleContactoEndpoint (GET /api/contactos/:id/detalle) — local', () => {
it('detalle 100% local con la misma forma', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
expect(detalle['propiedad']).toBeNull()
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
expect(Array.isArray(detalle['historial'])).toBe(true)
})
it('el id no existe en local → 404', async () => {
await expect(
detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res),
).rejects.toMatchObject({ status: 404 })
})
})

View File

@@ -1,128 +1,28 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js'
import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js'
import { perfilCompleto } from '../services/contactos-detalle.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError } from '../utils/errors.js'
/**
* GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday
* (nombre, teléfono, email, estado), lo más reciente primero, máx 100.
* Monday caído (o 502) → lista local (Cliente + LeadCaptura), mismo shape.
* GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura:
* nombre, teléfono, email, estado), lo más reciente primero, máx 100. Filtra
* por usuario actual: clientes capturados por él + leads asignados a él.
*/
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.usuarioToken?.sub
if (!saludMonday.estaDisponible()) {
res.json(await listarContactosLocal(usuarioId))
return
}
try {
res.json(await mondayService.getContactos())
} catch (err) {
if (!esFalloMonday(err)) throw err
res.json(await listarContactosLocal(usuarioId))
}
res.json(await listarContactosLocal(req.usuarioToken?.sub))
}
/**
* GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto:
* todas sus columnas, la propiedad comprada/en proceso (apartado relacionado),
* quién atiende cada item relacionado (leads, oportunidades, apartados) y el
* historial completo de atención (activity log de Monday + capturas locales).
* GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto armada
* 100% local: sus datos, la propiedad comprada/en proceso (apartado
* relacionado), quién atiende cada item relacionado (leads, oportunidades,
* apartados) y el historial de atención (capturas, altas, documentos y
* cotizaciones locales).
*/
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
const contactoId = String(req.params['id'])
// Monday caído (circuit breaker): detalle 100% local
if (!saludMonday.estaDisponible()) {
const detalle = await detalleContactoLocal(contactoId)
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
return
}
try {
const contacto = await mondayService.getContactoDetalle(contactoId)
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
// Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
}
// Registro local: capturas con el mismo teléfono (Monday no guarda quién
// capturó) y la ficha de cliente ligada a este contacto (o por celular).
// LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano.
const clave = claveTelefono(contacto['Teléfono'] ?? '')
const capturasTodas = await prisma.leadCaptura.findMany({
where: { telefono: { not: null } },
orderBy: { createdAt: 'desc' },
})
const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave)
const clienteCrudo = await prisma.cliente.findFirst({
where: {
OR: [
{ mondayContactoId: contactoId },
...(clave !== '' ? [{ celular: { contains: clave } }] : []),
],
},
include: { _count: { select: { documentos: true } } },
orderBy: { createdAt: 'desc' },
})
// Nombres de capturistas (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...capturasCrudas.map((c) => c.usuarioId),
...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []),
]),
]
const usuariosLocal = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre]))
const capturas = capturasCrudas.map((c) => ({
leadId: c.leadId,
nombreLead: c.nombreLead,
createdAt: c.createdAt,
usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null,
}))
const clienteLocal = clienteCrudo
? {
...clienteCrudo,
capturadoPor:
clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId)
? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! }
: null,
}
: null
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
} catch (err) {
// Monday se cayó a media lectura: detalle local (el 404 de "no existe en
// Monday" NO es fallo de Monday y se propaga intacto)
if (!esFalloMonday(err)) throw err
const detalle = await detalleContactoLocal(contactoId)
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
}
const detalle = await detalleContactoLocal(String(req.params['id']))
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
}
/**

View File

@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findUnique: vi.fn(),
},
cotizacion: {
create: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidad: vi.fn(),
crearOportunidad: vi.fn(),
actualizarOportunidadCotizacion: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
vi.mock('../services/auditoria.service.js', () => ({
registrar: vi.fn(),
buscar: vi.fn(),
}))
import { crearCotizacion } from './cotizaciones.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* Fallback del getUnidad obligatorio al guardar cotización: con Monday caído
* (o 502) la unidad sale del espejo local; si no existe en ninguno → 404.
*/
const filaUnidad = {
codigo: 'U-101',
desarrollo: 'DIAGONAL',
modelo: '2RA',
supConstruccion: 60,
supExcedente: 0,
supEstacionamiento: 0,
precioLista: 1_000_000,
precioAcabados: 1_100_000,
status: 'DISPONIBLE',
etapa: '',
}
const bodyBase = {
unidadCodigo: 'U-101',
acabados: false,
clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567',
tipoPropuesta: 'BANCARIO' as const,
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
prismaMock.cotizacion.update.mockResolvedValue({})
prismaMock.syncPendiente.create.mockResolvedValue({})
mondayMock.crearOportunidad.mockResolvedValue('monday-op-1')
mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined)
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
})
describe('crearCotizacion (getUnidad con fallback local)', () => {
it('Monday caído: cotiza con la unidad del espejo local (sin llamar a getUnidad)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, status, json } = resFake()
await crearCotizacion({ body: bodyBase } as Request, res)
expect(mondayMock.getUnidad).not.toHaveBeenCalled()
expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } })
expect(status).toHaveBeenCalledWith(201)
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL' })
expect(respuesta['propuesta']).toBeDefined()
})
it('Monday lanza 502 en getUnidad: mismo fallback local', async () => {
mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, status } = resFake()
await crearCotizacion({ body: bodyBase } as Request, res)
expect(status).toHaveBeenCalledWith(201)
})
it('la unidad no existe ni en Monday ni en local → 404 como hoy', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake()
await expect(crearCotizacion({ body: bodyBase } as Request, res)).rejects.toThrow(NotFoundError)
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
})

View File

@@ -1,48 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findUnique: vi.fn(),
},
leadCaptura: {
findFirst: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
leadOportunidad: {
findUnique: vi.fn(),
},
cotizacion: {
create: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
syncPendiente: {
actividadLocal: {
create: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidad: vi.fn(),
crearOportunidad: vi.fn(),
actualizarOportunidadCotizacion: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: vi.fn(),
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
import { crearCotizacion } from './cotizaciones.controller.js'
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
const unidadFake = {
/**
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
* cotización se persiste en SQLite y se liga a la oportunidad local (que pasa
* a etapa 'Cotizador').
*/
const filaUnidad = {
codigo: 'U-101',
modelo: 'M1',
fechaEntrega: null,
supEstacionamiento: 0,
desarrollo: 'DIAGONAL',
modelo: '2RA',
supConstruccion: 60,
supExcedente: 0,
precioSinAcabados: 1_000_000,
precioConAcabados: 1_100_000,
supEstacionamiento: 0,
precioLista: 1_000_000,
precioAcabados: 1_100_000,
status: 'DISPONIBLE',
desarrollo: 'DIAGONAL',
etapa: '',
}
const bodyBase = {
@@ -54,7 +65,7 @@ const bodyBase = {
}
function reqFake(body: unknown): Request {
return { body } as Request
return { body, usuarioToken: { sub: 'u-1' } } as unknown as Request
}
function resFake() {
@@ -65,111 +76,164 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getUnidad.mockResolvedValue(unidadFake)
mondayMock.crearOportunidad.mockResolvedValue('op-nueva')
mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined)
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
prismaMock.cotizacion.update.mockResolvedValue({})
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', () => {
it('sin oportunidad mapeada para el teléfono: crea una nueva (comportamiento actual)', async () => {
describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
it('cotiza con la unidad del espejo local (tabla Unidad)', async () => {
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } })
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
expect(mondayMock.actualizarOportunidadCotizacion).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-nueva' })
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
expect(respuesta['propuesta']).toBeDefined()
expect(respuesta['folio']).toBeDefined()
})
it('con teléfono de lead mapeado: ACTUALIZA esa oportunidad (no duplica) y la pasa a Cotizador', async () => {
funnelMock.oportunidadPorTelefono.mockResolvedValue('op-del-lead')
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567')
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
expect(opId).toBe('op-del-lead')
expect(datos).toMatchObject({ unidadCodigo: 'U-101', tipoPropuestaLabel: 'CREDITO BANCARIO', estadoLabel: 'Cotizador' })
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-del-lead' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'op-del-lead' },
})
})
it('sin teléfono en el body no consulta el funnel', async () => {
it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake()
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
await crearCotizacion(reqFake(sinTelefono), res)
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ status: 404 })
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('si Monday falla, la cotización igual se crea (201 con mondayItemId null) y la vinculación queda encolada', async () => {
mondayMock.crearOportunidad.mockRejectedValue(new Error('Monday caído'))
vi.spyOn(console, 'error').mockImplementation(() => {})
prismaMock.syncPendiente.create.mockResolvedValue({})
it('sin oportunidad mapeada para el teléfono: mondayItemId null (no crea oportunidades)', async () => {
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
// COTIZACION_OPORTUNIDAD con los datos completos + teléfono para el match del worker
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'COTIZACION_OPORTUNIDAD',
referenciaLocal: 'cot-1',
}),
})
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({
clienteTelefono: '4771234567',
datos: { unidadCodigo: 'U-101', estadoLabel: 'Cotizador' },
})
})
it('si falla la actualización con oportunidadId explícito, se encola con ESE id', async () => {
mondayMock.actualizarOportunidadCotizacion.mockRejectedValue(new Error('Monday caído'))
vi.spyOn(console, 'error').mockImplementation(() => {})
prismaMock.syncPendiente.create.mockResolvedValue({})
const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
expect(status).toHaveBeenCalledWith(201)
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ oportunidadId: 'opp-midia-1' })
})
it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => {
it('con teléfono de una afluencia local: LIGA esa oportunidad y la pasa a Cotizador', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({
leadId: 'loc_op_1',
board: 'OPORTUNIDADES',
telefono: '4771234567',
})
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_1', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
expect(opId).toBe('opp-midia-1')
expect(datos).toMatchObject({ estadoLabel: 'Cotizador' })
// La cotización queda vinculada al item del cliente
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_1' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'loc_op_1' },
})
// La oportunidad pasa a etapa 'Cotizador' localmente
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op_1' },
data: { etapa: 'Cotizador', pendienteSync: true },
})
})
it('con teléfono de un lead histórico: usa la oportunidad del mapping lead→oportunidad', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({
leadId: 'loc_lead_1',
board: 'LEADS',
telefono: '4771234567',
})
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_mapping' })
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_mapping', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_mapping' })
})
it('con oportunidadId explícito (botón "Cotizar" de Mi día): liga ESE id sin buscar por teléfono', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'opp-midia-1', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'opp-midia-1' },
})
})
})
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
it('los 5 códigos del enum mapean a los labels sembrados en la columna "Tipo propuesta"', async () => {
const { LABEL_PROPUESTA_MONDAY } = await import('./cotizaciones.controller.js')
// Labels reales verificados en Monday (difieren en guiones/espacios)
expect(LABEL_PROPUESTA_MONDAY['BANCARIO']).toBe('CREDITO BANCARIO')
expect(LABEL_PROPUESTA_MONDAY['INFONAVIT']).toBe('INFONAVIT-COFINAVIT')
expect(LABEL_PROPUESTA_MONDAY['COFINAVIT']).toBe('INFONAVIT-COFINAVIT')
expect(LABEL_PROPUESTA_MONDAY['CONTADO']).toBe('CONTADO')
expect(LABEL_PROPUESTA_MONDAY['SEMI_CONTADO']).toBe('SEMI CONTADO')
it('sin teléfono en el body no busca oportunidades', async () => {
const { res } = resFake()
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
await crearCotizacion(reqFake(sinTelefono), res)
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
})
})
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 })
expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({
data: expect.objectContaining({
titulo: 'La unidad U-101 que cotizaste pasó a APARTADA',
usuarioId: 'usr-1',
}),
})
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { clave: 'alerta-inv:U-101:cot-9:APARTADA' },
}),
)
})
it('sin vendedor en la cotización: omitida (sin actividad)', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: null }])
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'VENDIDA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
it('ya alertada (dedup persistente): omitida', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'x', valor: 'y' })
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
})

View File

@@ -1,15 +1,14 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService, type DatosOportunidad } from '../services/monday.service.js'
import { obtenerUnidadConFallback } from '../services/unidades-local.service.js'
import { funnelService } from '../services/funnel.service.js'
import { getUnidadLocal } from '../services/unidades-local.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
procesarAlertasInventario,
type ResultadoAlertas,
} from '../services/alertas-inventario.service.js'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro } from '../services/crm-local.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { actividadLocalService } from '../services/actividad-local.service.js'
import { NotFoundError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
@@ -26,35 +25,36 @@ const crearCotizacionSchema = z.object({
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
// esquemas con crédito (bancario/Infonavit)
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
// Item del tablero Oportunidades al que queda vinculada la cotización
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
// Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi
// día manda el id; sin él se busca por teléfono)
oportunidadId: z.string().trim().min(1).optional(),
})
const hoyISO = (): string => {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
/**
* Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo
* criterio que el funnel: primero el mapping histórico (lead → su
* oportunidad); si no hay lead con ese número, la captura directa en
* Oportunidades (modelo nuevo: la afluencia ES la oportunidad).
*/
async function oportunidadLocalPorTelefono(telefono: string): Promise<string | null> {
const tel = telefono.replace(/\D/g, '').slice(-10)
if (tel.length < 7) return null
const captura = await prisma.leadCaptura.findFirst({
where: { telefono: { contains: tel } },
orderBy: { updatedAt: 'desc' },
})
if (!captura) return null
const mapeada = await prisma.leadOportunidad.findUnique({ where: { leadId: captura.leadId } })
if (mapeada) return mapeada.oportunidadId
return captura.board === 'OPORTUNIDADES' ? captura.leadId : null
}
// Labels reales de la columna status "Tipo de Propuesta" del tablero
// Oportunidades (verificados en Monday: difieren de los códigos del cotizador
// en guiones/espacios — el resolver case-insensitive solo no alcanza)
export const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
BANCARIO: 'CREDITO BANCARIO',
INFONAVIT: 'INFONAVIT-COFINAVIT',
COFINAVIT: 'INFONAVIT-COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI CONTADO',
}
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
/** POST /api/cotizaciones — calcula, persiste en SQLite y liga la oportunidad local */
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
const body = crearCotizacionSchema.parse(req.body)
// Monday caído (o 502) → espejo local (tabla Unidad); 404 solo si no existe en ninguno
const unidad = await obtenerUnidadConFallback(body.unidadCodigo)
// Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe
const unidad = await getUnidadLocal(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
@@ -82,52 +82,27 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
},
})
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó
// persistida y la vinculación queda encolada (COTIZACION_OPORTUNIDAD) para
// que el worker la aplique cuando Monday vuelva
// Vinculación con la oportunidad local: explícita (botón "Cotizar" de Mi
// día) o por teléfono; la oportunidad ligada pasa a etapa 'Cotizador'.
let mondayItemId: string | null = null
const datosOportunidad: DatosOportunidad = {
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
}
try {
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
// oportunidad (etapa 'Cotizador') en vez de crear una segunda. Con
// oportunidadId explícito (botón "Cotizar" de Mi día) se actualiza ESE
// item directamente y la cotización queda vinculada al cliente.
const opExistente =
body.oportunidadId !== undefined
? body.oportunidadId
: body.clienteTelefono !== undefined
? await funnelService.oportunidadPorTelefono(body.clienteTelefono)
: null
if (opExistente !== null) {
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
mondayItemId = opExistente
} else {
mondayItemId = await mondayService.crearOportunidad(datosOportunidad)
}
const opExistente =
body.oportunidadId !== undefined
? body.oportunidadId
: body.clienteTelefono !== undefined
? await oportunidadLocalPorTelefono(body.clienteTelefono)
: null
if (opExistente !== null) {
mondayItemId = opExistente
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear la oportunidad en Monday:', err)
await encolarSyncSeguro(
'COTIZACION_OPORTUNIDAD',
{
datos: datosOportunidad,
...(body.oportunidadId !== undefined ? { oportunidadId: body.oportunidadId } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
},
cotizacion.id,
)
try {
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
sombra: !esIdLocal(opExistente),
usuarioId: req.usuarioToken?.sub,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo pasar a 'Cotizador' la oportunidad ${opExistente}:`, err)
}
}
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
@@ -210,9 +185,9 @@ const alertasInventarioSchema = z.object({
/**
* POST /api/cotizaciones/alertas-inventario — lo invoca el sync de estatus al
* detectar cambios (ver README). Crea una Actividad en Monday al vendedor de
* cada cotización reciente (≤30 días) de la unidad afectada, con dedup
* persistente por unidad+cotización+estatus.
* detectar cambios (ver README). Crea una Actividad local al vendedor de cada
* cotización reciente (≤30 días) de la unidad afectada, con dedup persistente
* por unidad+cotización+estatus.
*/
export async function alertasInventarioEndpoint(req: Request, res: Response): Promise<void> {
const { cambios } = alertasInventarioSchema.parse(req.body)
@@ -224,14 +199,14 @@ export async function alertasInventarioEndpoint(req: Request, res: Response): Pr
where: { unidadCodigo, createdAt: { gte: desde } },
select: { id: true, vendedorId: true },
}),
mondayUserIdDeVendedor: async (vendedorId) => {
const usuario = await prisma.usuario.findUnique({
where: { id: vendedorId },
select: { mondayUserId: true },
crearActividad: async (datos) => {
const actividad = await actividadLocalService.crearActividad({
titulo: datos.nombre,
usuarioId: datos.vendedorId,
tipo: 'RECORDATORIO',
})
return usuario?.mondayUserId ?? null
return actividad.id
},
crearActividad: (datos) => mondayService.crearActividad(datos),
yaAlertada: async (clave) => {
const estado = await prisma.automatizacionState.findUnique({ where: { clave } })
return estado !== null

View File

@@ -1,8 +1,6 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
/** GET /api/health — ping barato a Monday */
/** GET /api/health — verificación de vida del API */
export async function health(_req: Request, res: Response): Promise<void> {
const mondayUp = await mondayService.ping()
res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' })
res.json({ status: 'ok' })
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,32 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { VOCABULARIO } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
import { NotFoundError } from '../utils/errors.js'
import { AppError, NotFoundError } from '../utils/errors.js'
/**
* Captura y acciones del CRM ligero, sobre DOS tableros:
* - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual
* desde la UI de Monday. Item ligero (nombre, teléfono, email, origen,
* desarrollo, notas). Los leads NO crean oportunidad.
* - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma
* completa de /client) — la afluencia ES la oportunidad en etapa
* 'Afluencia', con todo el perfil.
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
* LeadCaptura de Prisma/SQLite):
* - Leads (POST /api/leads): captura rápida de /hoy y captura manual desde la
* UI. Item ligero (nombre, teléfono, email, origen, desarrollo, notas). Los
* leads NO crean oportunidad.
* - Oportunidades (POST /api/afluencias): el Alta Afluencia (forma completa de
* /client) — la afluencia ES la oportunidad en etapa 'Afluencia', con todo
* el perfil.
* Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del
* alta conviven con ambos tableros vía el parámetro `fuente`.
* Los labels se validan/resuelven contra los reales de cada board (los
* dropdown y status de Monday no auto-crean labels al escribir).
* Los labels de origen/desarrollo se validan contra el vocabulario canónico
* del CRM (config/vocabulario.ts).
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' de cada board
// Traducción de la whitelist (valores que mandan los frontends) a las etapas
// locales de cada board
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
@@ -42,7 +37,7 @@ const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], strin
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo
INTERESADO: 'Interesado', // no existe en Oportunidades: se rechaza abajo
'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado',
}
@@ -72,8 +67,7 @@ const crearAfluenciaSchema = z.object({
// Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se
// ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva
oportunidadId: z.string().trim().min(1).optional(),
// Campos de calificación de la afluencia (opcionales; los labels fijos del
// front coinciden con los sembrados en Monday, no se validan contra el board)
// Campos de calificación de la afluencia (opcionales)
modelo: z.string().trim().min(1).optional(),
numDependientes: z.string().optional(),
edad: z.coerce.number().int().min(15).max(120).optional(),
@@ -100,7 +94,7 @@ const cambiarEstadoSchema = z.object({
const descartarSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
// Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida)
// Subcategoría del catálogo (debe pertenecer a la categoría elegida)
subcategoria: z.string().trim().min(1).optional(),
comentario: z.string().trim().min(1).optional(),
fuente: fuenteSchema,
@@ -109,377 +103,57 @@ const descartarSchema = z.object({
{ message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] },
)
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
const LIMITE_BUSQUEDA_LEADS = 15
/** Minúsculas y sin diacríticos, para comparar nombres/teléfonos con acentos */
const sinAcentos = (s: string): string =>
s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
/** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */
export interface ResultadoBusquedaLead {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: FuenteLead
/** Desarrollo de interés ('' si no tiene); prellena afluencia/cotizador */
desarrollo: string
/** Modelo de interés ('' si no tiene) */
modeloInteres: string
}
/**
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
* AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente
* y limita a 15 resultados combinados.
*/
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
const needle = sinAcentos(q)
const candidatos = [
...leads.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
desarrollo: l.desarrollo ?? '',
modeloInteres: l.modeloInteres ?? '',
updatedAt: l.updatedAt,
fuente: 'lead' as const,
})),
...oportunidades.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
desarrollo: o.desarrollo ?? '',
modeloInteres: o.modeloInteres ?? '',
updatedAt: o.updatedAt,
fuente: 'oportunidad' as const,
})),
]
return candidatos
.filter((c) => [c.nombre, c.telefono, c.email].some((campo) => sinAcentos(campo).includes(needle)))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS)
.map(({ updatedAt: _updatedAt, ...resto }) => resto)
}
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
| 'crearLead'
| 'crearOportunidadAfluencia'
| 'actualizarOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'getLeads'
| 'getOportunidades'
| 'labelsDeColumna'
| 'obtenerNombreItem'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
>
export type ResultadoCrearLead =
| { id: string; oportunidadId: string | null; actualizada?: boolean }
| { duplicado: { id: string; nombre: string } }
/**
* Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura
* rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con
* ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* Origen y desarrollo se validan contra los labels reales del board Leads.
* Validación de origen/desarrollo contra el vocabulario canónico del CRM
* (config/vocabulario.ts). Modo estricto (captura rápida): 400 con los labels
* válidos. Modo tolerante (alta afluencia): el label desconocido se omite en
* vez de fallar.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
// Dedup por email en ambos tableros
if (datos.email !== undefined && datos.email.trim() !== '') {
const [leads, oportunidades] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
])
const emailLower = datos.email.trim().toLowerCase()
const existenteLead = leads.find((l) => l.email?.toLowerCase() === emailLower)
if (existenteLead) return { duplicado: { id: existenteLead.id, nombre: existenteLead.nombre } }
const existenteOp = oportunidades.find((o) => o.email?.toLowerCase() === emailLower)
if (existenteOp) return { duplicado: { id: existenteOp.id, nombre: existenteOp.nombre } }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
function validarOrigenDesarrollo(
datos: { origen?: string | undefined; desarrollo?: string | undefined },
modo: 'estricto' | 'tolerante',
): { origen?: string | undefined; desarrollo?: string | undefined } {
let { origen, desarrollo } = datos
if (origen !== undefined && !(VOCABULARIO.ORIGENES as readonly string[]).includes(origen)) {
if (modo === 'estricto') {
throw new AppError(
400,
`Origen inválido: "${origen}". Labels válidos: ${VOCABULARIO.ORIGENES.join(', ')}`,
)
}
}
if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearLead({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
})
return { id, oportunidadId: null }
}
/**
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
* 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una
* oportunidad con ese número, no crea nada y devuelve { duplicado } (el
* endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status
* 'Desarrollo') se validan contra los labels reales del board.
* Tras crearla se mueve al grupo que corresponde a su etapa (Monday crea el
* item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no
* dispara en la creación); un fallo ahí solo se loguea.
*/
export async function crearAfluencia(
monday: MondayLeads,
datos: z.infer<typeof crearAfluenciaSchema>,
): Promise<ResultadoCrearLead> {
const boardOportunidades = config.monday.boards.oportunidades
// Con oportunidadId: el formulario venía prellenado desde Mis oportunidades
// (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en
// etapa 'Afluencia'; no se crea duplicado ni se valida teléfono.
if (datos.oportunidadId !== undefined) {
await monday.actualizarOportunidadAfluencia(datos.oportunidadId, {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
numDependientes: datos.numDependientes,
edad: datos.edad,
estadoCivilLabel: datos.estadoCivil,
tipoEmpleoLabel: datos.tipoEmpleo,
ocupacion: datos.ocupacion,
rangoSalarial: datos.rangoSalarial,
creditoLabel: datos.credito,
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err)
}
return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined) {
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
// Validación tolerante: si el label no existe en el board, se omite en vez
// de fallar. Esto evita 400 cuando Monday aún no propagó un label nuevo.
let origenSafe = datos.origen
if (origenSafe !== undefined) {
const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead')
if (!labels.includes(origenSafe)) {
console.warn(`Origen "${origenSafe}" no es label del board; se omite`)
origenSafe = undefined
}
}
let desarrolloSafe = datos.desarrollo
if (desarrolloSafe !== undefined) {
const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
if (!labels.includes(desarrolloSafe)) {
console.warn(`Desarrollo "${desarrolloSafe}" no es label del board; se omite`)
desarrolloSafe = undefined
}
}
const id = await monday.crearOportunidadAfluencia({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: desarrolloSafe,
origenLabel: origenSafe,
modeloLabel: datos.modelo,
numDependientes: datos.numDependientes,
edad: datos.edad,
estadoCivilLabel: datos.estadoCivil,
tipoEmpleoLabel: datos.tipoEmpleo,
ocupacion: datos.ocupacion,
rangoSalarial: datos.rangoSalarial,
creditoLabel: datos.credito,
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, id)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err)
console.warn(`Origen "${origen}" no está en el vocabulario; se omite`)
origen = undefined
}
return { id, oportunidadId: id }
}
/**
* Cambia el estado del item (whitelist → label real del board correspondiente
* según `fuente`); en AGENDA CITA puede setear la columna Fecha.
*/
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
fuente: FuenteLead = 'oportunidad',
): Promise<void> {
const esLead = fuente === 'lead'
// 'Interesado' solo existe en el tablero Leads (no es etapa del funnel)
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha)
}
}
/**
* Descarte del item según `fuente`:
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
* sembrado; si no existiera se omite) + comentario en COMENTARIOS.
* - 'lead': Estado 'Descartado' + comentario en Notas.
* Ambos registran el motivo (y la subcategoría si viene) en el tablero
* catálogo Motivos No Compra.
*/
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
fuente: FuenteLead = 'oportunidad',
subcategoria?: string,
): Promise<string> {
const nombreItem = await monday.obtenerNombreItem(itemId)
if (fuente === 'lead') {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
if (desarrollo !== undefined && !(VOCABULARIO.DESARROLLOS as readonly string[]).includes(desarrollo)) {
if (modo === 'estricto') {
throw new AppError(
400,
`Desarrollo inválido: "${desarrollo}". Labels válidos: ${VOCABULARIO.DESARROLLOS.join(', ')}`,
)
}
} else {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
}
return monday.crearMotivoNoCompra({
nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/**
* Self-assign del capturista con bandera anti-carrera rr:skip (para que el
* webhook create_item no lo reparta por round-robin mientras Monday propaga la
* escritura; si la asignación falla, se apaga y cae al reparto — y queda
* encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva).
* Devuelve cómo quedó la asignación.
*/
async function asignarCapturistaSiAplica(
usuarioId: string | undefined,
itemId: string,
nombre: string,
board: 'leads' | 'oportunidades',
): Promise<'capturista' | 'round-robin'> {
if (usuarioId === undefined) return 'round-robin'
try {
const capturista = await prisma.usuario.findUnique({
where: { id: usuarioId },
select: { mondayUserId: true },
})
if (capturista?.mondayUserId != null) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista')
try {
await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board)
return 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
// Best-effort: el worker la reintenta al drenar la cola
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId)
throw err
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el item ${itemId} al capturista:`, err)
console.warn(`Desarrollo "${desarrollo}" no está en el vocabulario; se omite`)
desarrollo = undefined
}
return 'round-robin'
return { origen, desarrollo }
}
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const { origen, desarrollo } = validarOrigenDesarrollo(body, 'estricto')
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo
// actual con fallback a local si la llamada lanza 502. La respuesta mantiene
// la misma forma en ambos caminos (el front no se entera).
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearLead(body, usuarioSub)
} else {
try {
resultado = await crearLead(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearLead(body, usuarioSub)
}
}
// SIEMPRE local: fila LeadCaptura board=LEADS etapa NUEVO + CREAR_LEAD
// encolado. Sin usuario el capturista queda para el reparto (round-robin).
const resultado: ResultadoCrearLead = await crmLocal.crearLead(
{ ...body, origen, desarrollo },
usuarioSub,
)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -488,60 +162,26 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
})
return
}
if (modoLocal) {
// Sin round-robin en modo local: el capturista queda asignado (su
// ASIGNAR_PERSONA viaja encolada tras el CREATE)
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',
oportunidadId: null,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: null, // los leads ya no generan oportunidad
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',
oportunidadId: null,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_LEAD', 'lead', resultado.id)
}
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
const body = crearAfluenciaSchema.parse(req.body)
const { origen, desarrollo } = validarOrigenDesarrollo(body, 'tolerante')
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback
// a local tras 502. Misma forma de respuesta en ambos caminos.
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
} else {
try {
resultado = await crearAfluencia(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
}
}
// SIEMPRE local: con oportunidadId se ACTUALIZA esa fila; sin él se crea la
// afluencia (que ES la oportunidad) con auto-mapping LeadOportunidad.
const resultado: ResultadoCrearLead = await crmLocal.crearAfluencia(
{ ...body, origen, desarrollo },
usuarioSub,
)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -551,58 +191,24 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
return
}
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
// hay captura nueva que registrar ni round-robin que correr
// hay captura nueva que registrar
if (resultado.actualizada === true) {
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
if (modoLocal) {
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',
oportunidadId: resultado.oportunidadId,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día.
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: resultado.oportunidadId,
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err)
}
}
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
// clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
try {
await prisma.leadOportunidad.create({
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId ?? resultado.id },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades')
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',
oportunidadId: resultado.oportunidadId,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id)
}
/**
* Cambio de estado en modo local: guarda la etapa (label real del board) en la
* fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502
* sobre items viejos) crea la fila "sombra" para que Mi día lo refleje.
* Cambio de estado en modo local: guarda la etapa (label del board) en la fila
* local. Con id heredado sin fila local (items viejos del CRM anterior)
* crea la fila "sombra" para que Mi día lo refleje.
*/
async function cambiarEstadoLocal(
itemId: string,
@@ -627,19 +233,7 @@ async function cambiarEstadoLocal(
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const fuente = body.fuente ?? 'oportunidad'
// Los ids loc_* siempre van al path local (aún no existen en Monday); con el
// flag caído todo va a local; con Monday vivo, fallback a local tras 502.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
} else {
try {
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
} catch (err) {
if (!esFalloMonday(err)) throw err
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
}
}
await cambiarEstadoLocal(itemId, body.estado, body.fecha, body.fuente ?? 'oportunidad', req.usuarioToken?.sub)
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado)
}
@@ -648,31 +242,19 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const fuente = body.fuente ?? 'oportunidad'
let motivoItemId: string | null = null
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
}
}
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
await crmLocal.descartar(itemId, body.motivo, {
fuente: body.fuente ?? 'oportunidad',
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
res.json({
itemId,
estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido',
motivo: body.motivo,
motivoItemId: null,
})
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo)
}
@@ -686,117 +268,19 @@ const convertirSchema = z.object({
})
/**
* Convierte un lead del tablero Leads en oportunidad (botón "Agendo" de Mis
* leads): crea el item en Oportunidades con etapa 'Agendo Cita' y los datos
* del lead (los labels se omiten si no existen en el board destino), asigna
* como Asesor al vendedor del lead (o a quien convierte), marca el lead como
* 'Calificado' (sale de Mis leads) y guarda el mapping LeadOportunidad para
* que el alta de clientes la encuentre. Si el lead ya fue convertido → 409.
* Convierte un lead en oportunidad (botón "Agendo" de Mis leads): el lead
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
* los datos y se guarda el mapping LeadOportunidad. Si el lead ya fue
* convertido → 409. La fecha de la cita viaja en el CAMBIO_ETAPA posterior del
* front.
*/
export async function convertirLead(
monday: MondayLeads,
itemId: string,
usuarioId?: string,
fecha?: string,
): Promise<{ oportunidadId: string }> {
const existente = await prisma.leadOportunidad.findUnique({ where: { leadId: itemId } })
if (existente) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
const lead = (await monday.getLeads()).find((l) => l.id === itemId)
if (!lead) throw new NotFoundError('Lead no encontrado en el tablero Leads')
const boardOportunidades = config.monday.boards.oportunidades
// Los labels solo se copian si existen en el board destino (dropdown/status
// no auto-crean labels al escribir)
const [labelsOrigen, labelsDesarrollo, labelsModelo] = await Promise.all([
monday.labelsDeColumna(boardOportunidades, 'Origen del Lead'),
monday.labelsDeColumna(boardOportunidades, 'Desarrollo'),
monday.labelsDeColumna(boardOportunidades, 'Modelo de Interés'),
])
const oportunidadId = await monday.crearOportunidadAfluencia({
nombre: lead.nombre,
estadoLabel: 'Agendo Cita',
telefono: lead.telefono || undefined,
email: lead.email || undefined,
origenLabel: lead.origen && labelsOrigen.includes(lead.origen) ? lead.origen : undefined,
desarrolloLabel: lead.desarrollo && labelsDesarrollo.includes(lead.desarrollo) ? lead.desarrollo : undefined,
modeloLabel: lead.modeloInteres && labelsModelo.includes(lead.modeloInteres) ? lead.modeloInteres : undefined,
})
if (fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Fecha', fecha)
}
// Asesor de la oportunidad: el vendedor del lead, o quien la convierte
let asesorId = lead.vendedorIds[0]
if (asesorId === undefined && usuarioId !== undefined) {
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioId }, select: { mondayUserId: true } })
asesorId = usuario?.mondayUserId ?? undefined
}
if (asesorId !== undefined) {
// Bandera anti-carrera: el webhook create_item no debe round-robinnearla
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'capturista')
try {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Asesor', asesorId)
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'round-robin')
throw err
}
}
// El lead queda Calificado (convertido: sale de Mis leads) y ambos items a su grupo
await monday.cambiarValorColumna(config.monday.boards.leads, itemId, 'Estado', 'Calificado')
await moverAGrupoSegunEstado(monday, config.monday.boards.leads, itemId)
await moverAGrupoSegunEstado(monday, boardOportunidades, oportunidadId)
await prisma.leadOportunidad.create({ data: { leadId: itemId, oportunidadId } })
return { oportunidadId }
}
/** POST /api/leads/:itemId/convertir { fecha? } */
export async function convertirLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = convertirSchema.parse(req.body ?? {})
convertirSchema.parse(req.body ?? {})
const itemId = String(req.params['itemId'])
const usuarioSub = req.usuarioToken?.sub
// Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la
// oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el
// fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front,
// como en el flujo normal.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
return
}
try {
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
// Crear LeadCaptura local para la oportunidad (Mi día las necesita)
try {
const leadLocal = await prisma.leadCaptura.findFirst({ where: { leadId: itemId } })
await prisma.leadCaptura.upsert({
where: { leadId: oportunidadId },
update: { board: 'OPORTUNIDADES', etapa: 'Agendo Cita' },
create: {
leadId: oportunidadId,
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
nombreLead: leadLocal?.nombreLead ?? '',
telefono: leadLocal?.telefono ?? null,
email: leadLocal?.email ?? null,
usuarioId: usuarioSub ?? 'sistema',
mondayItemId: oportunidadId,
pendienteSync: false,
},
})
} catch { /* best effort */ }
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
} catch (err) {
if (!esFalloMonday(err)) throw err
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
}
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
}
// ── Admin CRUD de leads ────────────────────────────────────────────────
@@ -804,19 +288,8 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis
/** DELETE /api/leads/:itemId — eliminar lead (solo admin) */
export async function eliminarLeadEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
// Eliminar de Monday si es item remoto y Monday está disponible
if (row && row.mondayItemId && !esIdLocal(itemId) && saludMonday.estaDisponible()) {
try {
await mondayService.eliminarItem(row.mondayItemId)
} catch (err) {
if (!esFalloMonday(err)) throw err
}
}
// Eliminar la fila local (si existe)
if (row) {
await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {})
}
await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {})
res.json({ eliminado: true, itemId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ELIMINAR_LEAD', 'lead', itemId)
}
@@ -831,27 +304,14 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise<
const { usuarioId } = asignarLeadSchema.parse(req.body)
const usuario = await prisma.usuario.findUnique({
where: { id: usuarioId },
select: { mondayUserId: true, nombre: true },
select: { nombre: true },
})
if (!usuario) throw new NotFoundError('Usuario no encontrado')
// Actualizar LeadCaptura
// La asignación local es la verdad: se actualiza la fila del lead
await prisma.leadCaptura.updateMany({
where: { leadId: itemId },
data: { usuarioId },
})
// En Monday: determinar si es lead u oportunidad
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
const board: 'leads' | 'oportunidades' = row?.board === 'OPORTUNIDADES' ? 'oportunidades' : 'leads'
if (usuario.mondayUserId != null && saludMonday.estaDisponible() && !esIdLocal(itemId)) {
try {
await automatizacionesService.asignarACapturista(itemId, row?.nombreLead ?? '', usuario.mondayUserId, board)
} catch (err) {
if (!esFalloMonday(err)) throw err
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
}
} else if (usuario.mondayUserId != null) {
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
}
res.json({ itemId, usuarioId, nombre: usuario.nombre })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
}
@@ -876,17 +336,11 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise<vo
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
* clientes. Busca en las tablas locales (LeadCaptura + Cliente); q mínimo 2
* caracteres; devuelve máximo 15, cada uno con su `fuente`.
* Con Monday caído busca en lo local (LeadCaptura + Cliente), misma forma.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
if (!saludMonday.estaDisponible()) {
res.json(await crmLocal.buscarLocales(q))
return
}
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
res.json(await crmLocal.buscarLocales(q))
}

View File

@@ -10,7 +10,7 @@ import {
verificarLigaToken,
} from './liga.controller.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan BD) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
@@ -23,12 +23,8 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
cambiarValorColumna: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
const SECRET = 'test-jwt-secret' // JWT_SECRET fijado en vitest.config.ts
@@ -220,10 +216,9 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', ()
expect(status).toHaveBeenCalledWith(201)
})
it('prende el checkbox en Apartado cuando el cliente tiene mondayApartadoId', async () => {
it('persiste el documento aunque el cliente tenga mondayApartadoId (sin efectos Monday)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake({ mondayApartadoId: 'ap-9' }))
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
mondayMock.cambiarValorColumna.mockResolvedValue(undefined)
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
@@ -231,10 +226,10 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', ()
resFake().res,
)
expect(mondayMock.cambiarValorColumna).toHaveBeenCalledWith(expect.any(String), 'ap-9', 'CURP', true)
expect(prismaMock.documento.create).toHaveBeenCalled()
})
it('no llama a Monday sin mondayApartadoId', async () => {
it('acepta tipos con cliente sin apartado ligado', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
const token = generarLigaToken('c-1')
@@ -244,6 +239,5 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', ()
resFake().res,
)
expect(mondayMock.cambiarValorColumna).not.toHaveBeenCalled()
})
})

View File

@@ -6,10 +6,8 @@ import jwt from 'jsonwebtoken'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
// ---------- Liga remota de firma/documentos (modo público con token) ----------
@@ -214,21 +212,5 @@ export async function subirDocumentosPublico(req: Request, res: Response): Promi
),
)
// Mismo efecto que la subida interna: checkbox del documento en Apartado.
// Un fallo de Monday no rompe la subida.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null && cliente.mondayApartadoId !== null) {
try {
await mondayService.cambiarValorColumna(
config.monday.boards.apartado,
cliente.mondayApartadoId,
checkbox,
true,
)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
}
}
res.status(201).json(documentos)
}

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
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(() => ({
usuario: {
@@ -20,17 +20,7 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
getUsuariosWorkspace: vi.fn(),
getLeads: vi.fn(),
getOportunidades: vi.fn(),
getActividades: vi.fn(),
getApartados: vi.fn(),
getUltimosCambios: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
@@ -81,16 +71,10 @@ beforeEach(() => {
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.actividadLocal.findMany.mockResolvedValue([])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
mondayMock.getLeads.mockResolvedValue([])
mondayMock.getOportunidades.mockResolvedValue([])
mondayMock.getActividades.mockResolvedValue([])
mondayMock.getApartados.mockResolvedValue([])
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
})
describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => {
describe('resumenMiDiaEndpoint (100% local)', () => {
it('responde 100% desde LeadCaptura', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
@@ -99,10 +83,6 @@ describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
await resumenMiDiaEndpoint(reqFake, res)
expect(mondayMock.getLeads).not.toHaveBeenCalled()
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
expect(mondayMock.getApartados).not.toHaveBeenCalled()
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)

View File

@@ -59,7 +59,7 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol }
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
}

View File

@@ -1,15 +1,10 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
cambiarEtapaOportunidad,
cambiarEtapaOportunidadEndpoint,
ETAPAS_OPORTUNIDAD_PERMITIDAS,
perderOportunidad,
perderOportunidadEndpoint,
} from './oportunidades.controller.js'
import { mondayService } from '../services/monday.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { AppError } from '../utils/errors.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -19,8 +14,8 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
unidad: {
updateMany: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
@@ -34,110 +29,81 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra los cambios de columna y los motivos creados */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async obtenerNombreItem(_itemId: string) {
return 'Juan Pérez'
},
async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) {
this.motivos.push(datos)
return 'motivo-item-1'
},
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => {
it('pone etapa Perdido + dropdown motivo y registra en el tablero Motivos No Compra', async () => {
const monday = mondayMock()
const motivoItemId = await perderOportunidad(monday, 'opp-123', 'Precio', 'Se le hizo caro')
const reqCon = (overrides: Record<string, unknown>): Request =>
({
body: {},
params: {},
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
}) as unknown as Request
expect(motivoItemId).toBe('motivo-item-1')
// Etapa y motivo sobre la oportunidad (board 555 = MONDAY_BOARD_OPORTUNIDADES de test)
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-123', columna: 'Estado', valor: 'Perdido' },
{ boardId: '555', itemId: 'opp-123', columna: 'Motivo No Compra', valor: 'Precio' },
])
// Item en el catálogo: nombre = motivo + item de origen
expect(monday.motivos).toEqual([
{ nombre: 'Precio — Juan Pérez', categoria: 'Precio', descripcion: 'Se le hizo caro' },
])
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('perderOportunidadEndpoint (POST /api/oportunidades/:id/perder) — local', () => {
it('etapa Perdido + motivo/submotivo en la fila local', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' })
const { res, json } = resFake()
await perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_9' },
data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }),
})
expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null })
})
it('el comentario es opcional', async () => {
const monday = mondayMock()
await perderOportunidad(monday, 'opp-9', 'Competencia')
expect(monday.motivos[0]).toEqual({ nombre: 'Competencia — Juan Pérez', categoria: 'Competencia' })
it('el motivo debe ser del vocabulario (400 si no)', async () => {
const { res } = resFake()
await expect(
perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'No le gustó' } }),
res,
),
).rejects.toThrow()
expect(prismaMock.leadCaptura.update).not.toHaveBeenCalled()
})
it('propaga el error si Monday falla (el endpoint responde 5xx)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
await expect(perderOportunidad(monday, 'opp-1', 'Timing')).rejects.toThrow('Monday caído')
// No se registró motivo si falló el cambio de etapa
expect(monday.motivos).toEqual([])
it('id heredado sin fila local: crea la sombra y guarda el descarte', async () => {
const { res, json } = resFake()
await perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'opp-77' }, body: { motivo: 'Timing' } }),
res,
)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: 'opp-77', board: 'OPORTUNIDADES', mondayItemId: 'opp-77' }),
})
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-77', etapa: 'Perdido', motivoItemId: null })
})
})
/** Monday mockeado para cambio de etapa: registra cambios y movimientos de grupo */
function mondayEtapaMock(grupoActual = 'topics') {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientos: [] as { itemId: string; grupo: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async columnaIdPorTitulo(_boardId: string, titulo: string) {
return titulo === 'Estado' ? 'status' : null
},
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Cliente Demo',
groupId: grupoActual,
column_values: [{ id: 'status', text: this.cambios.find((c) => c.columna === 'Estado')?.valor as string ?? '', value: '{}' }],
}
},
async moverItemAGrupo(itemId: string, grupo: string) {
this.movimientos.push({ itemId, grupo })
},
}
}
describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
it('cambia la etapa y mueve al grupo destino (Abandono → Perdidos)', async () => {
const monday = mondayEtapaMock()
await cambiarEtapaOportunidad(monday, 'opp-1', 'Abandono')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-1', columna: 'Estado', valor: 'Abandono' },
])
expect(monday.movimientos).toEqual([{ itemId: 'opp-1', grupo: 'group_mm6s4cgq' }])
})
it('Agendo Cita setea también la columna Fecha', async () => {
const monday = mondayEtapaMock()
await cambiarEtapaOportunidad(monday, 'opp-2', 'Agendo Cita', '2026-09-10')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-2', columna: 'Estado', valor: 'Agendo Cita' },
{ boardId: '555', itemId: 'opp-2', columna: 'Fecha', valor: '2026-09-10' },
])
// 'Agendo Cita' mapea al grupo topics: ya estaba ahí → no se mueve
expect(monday.movimientos).toEqual([])
})
it('sin fecha no toca la columna Fecha', async () => {
const monday = mondayEtapaMock()
await cambiarEtapaOportunidad(monday, 'opp-3', 'Cotizador')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-3', columna: 'Estado', valor: 'Cotizador' },
])
})
describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) — local', () => {
it('las etapas permitidas son las fases del funnel definidas por el cliente', () => {
expect(ETAPAS_OPORTUNIDAD_PERMITIDAS).toEqual([
'Agendo Cita',
@@ -148,64 +114,39 @@ describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
'Abandono',
])
})
})
// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ----------
describe('ruteo modo local (endpoints de oportunidades)', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const reqCon = (overrides: Record<string, unknown>): Request =>
({
body: {},
params: {},
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
}) as unknown as Request
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('cambiarEtapaOportunidadEndpoint con id loc_*: etapa local + CAMBIO_ETAPA encolado, sin tocar Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // Monday vivo: el id loc_* manda
const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna')
it('cambia la etapa en la fila local y encola CAMBIO_ETAPA', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' })
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Cotizador' } }), res)
expect(spyCambiar).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { etapa: 'Cotizador', pendienteSync: true },
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', etapa: 'Cotizador' })
})
it("cambiarEtapaOportunidadEndpoint 'Aparto' con Monday caído: crea el apartado local (crmLocal.apartar)", async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it("'Agendo Cita' con fecha: la fecha queda en datosJson", async () => {
prismaMock.leadCaptura.findUnique
.mockResolvedValueOnce({ leadId: 'loc_2', board: 'OPORTUNIDADES' }) // filaLocal
.mockResolvedValueOnce({ leadId: 'loc_2', datosJson: null }) // guardar fechaCita
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(
reqCon({ params: { itemId: 'loc_2' }, body: { etapa: 'Agendo Cita', fecha: '2026-09-10' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_2' },
data: expect.objectContaining({ etapa: 'Agendo Cita', datosJson: JSON.stringify({ fechaCita: '2026-09-10' }) }),
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' })
})
it("'Aparto': crea el apartado local (crmLocal.apartar) y bloquea la unidad del inventario", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
@@ -214,7 +155,7 @@ describe('ruteo modo local (endpoints de oportunidades)', () => {
email: null,
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: null,
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
})
const { res, json } = resFake()
@@ -228,39 +169,37 @@ describe('ruteo modo local (endpoints de oportunidades)', () => {
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO'])
// La unidad asignada queda APARTADA en el espejo local
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101' },
data: { status: 'APARTADA' },
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
})
it('cambiarEtapaOportunidadEndpoint con id Monday y 502: fallback local con fila sombra', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'opp-88' }, body: { etapa: 'No Asistio' } }), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: 'opp-88', board: 'OPORTUNIDADES', mondayItemId: 'opp-88' }),
it("'Aparto' sin unidad asignada no toca el inventario", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: null,
email: null,
desarrollo: null,
modelo: null,
datosJson: null,
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: 'opp-88', etapa: 'No Asistio' })
const { res } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled()
})
it('perderOportunidadEndpoint con Monday caído: descarte local (motivoItemId null) + DESCARTE encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' })
const { res, json } = resFake()
it('etapa fuera del vocabulario → 400 (zod)', async () => {
const { res } = resFake()
await perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_9' },
data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }),
})
expect(tiposEncolados()).toEqual(['DESCARTE'])
expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null })
await expect(
cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Vendido' } }), res),
).rejects.toThrow()
})
})

View File

@@ -1,103 +1,39 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { VOCABULARIO, type MotivoNoCompra } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
] as const
export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA
/**
* Subcategorías por categoría, tomadas del tablero de referencia "Motivos No
* Compra" del workspace CRM FRASA (los items de cada grupo son el catálogo).
* Sembradas como labels de la columna status 'Subcategoria' del tablero
* Motivos No Compra de Frasa-CAS. NOTA: 'Mesualidad' y 'Cambo de Planes'
* llevan las faltas de ortografía originales del tablero del cliente.
* Subcategorías por categoría, tomadas del catálogo del tablero de referencia
* "Motivos No Compra" del workspace CRM FRASA. NOTA: 'Mesualidad' y 'Cambo de
* Planes' llevan las faltas de ortografía originales del catálogo del cliente.
*/
export const SUBCATEGORIAS_NO_COMPRA: Record<(typeof MOTIVOS_NO_COMPRA)[number], string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
const perderSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().optional(),
})
type MondayOportunidades = Pick<
MondayService,
'cambiarValorColumna' | 'crearMotivoNoCompra' | 'obtenerNombreItem'
>
/**
* Marca una oportunidad como perdida: etapa 'Perdido' + dropdown motivo, y
* registra el motivo en el tablero catálogo Motivos No Compra.
* Devuelve el ID del item creado en Motivos No Compra.
* Marca una oportunidad como perdida (etapa 'Perdido' + motivo/submotivo en la
* fila local) y encola el DESCARTE. SIEMPRE local.
*/
export async function perderOportunidad(
monday: MondayOportunidades,
mondayItemId: string,
motivo: string,
comentario?: string,
): Promise<string> {
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(mondayItemId)
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Estado', 'Perdido')
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Motivo No Compra', motivo)
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/** POST /api/oportunidades/:mondayItemId/perder { motivo, comentario? } */
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = perderSchema.parse(req.body)
const mondayItemId = String(req.params['mondayItemId'])
let motivoItemId: string | null = null
// Modo local (id loc_* o Monday caído / fallback 502): descarte local +
// DESCARTE encolado; el motivo del tablero catálogo lo crea el worker
if (esIdLocal(mondayItemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
} else {
try {
motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
}
}
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId: null })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo)
}
@@ -105,53 +41,20 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
/**
* Etapas del funnel que el asesor puede mover desde los botones de
* "Mis oportunidades" (labels REALES de la columna status 'Estado' del board
* Oportunidades — el frontend los manda tal cual).
* "Mis oportunidades" (vocabulario canónico del CRM — el frontend las manda
* tal cual).
*/
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = [
'Agendo Cita',
'Afluencia',
'Cotizador',
'Aparto',
'No Asistio',
'Abandono',
] as const
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = VOCABULARIO.FASES_OPORTUNIDAD
const cambiarEtapaSchema = z.object({
etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS),
// En 'Agendo Cita' puede setear la columna date 'Fecha'
// En 'Agendo Cita' puede setear la fecha de la cita
fecha: z.string().optional(),
})
type MondayEtapa = Pick<
MondayService,
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
>
/**
* Cambia la etapa de la oportunidad (columna 'Estado'), setea la fecha de la
* cita si aplica y mueve el item al grupo que corresponde a la etapa nueva
* (idempotente; el webhook de change haría lo mismo, pero así la UI responde
* al instante).
*/
export async function cambiarEtapaOportunidad(
monday: MondayEtapa,
itemId: string,
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
fecha?: string,
): Promise<void> {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', etapa)
if (etapa === 'Agendo Cita' && fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
}
await moverAGrupoSegunEstado(monday, boardOportunidades, itemId)
}
/**
* Cambio de etapa en modo local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
* el equivalente al asegurarApartado del flujo Monday.
* Cambio de etapa local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar).
* Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad).
*/
async function cambiarEtapaLocal(
@@ -185,67 +88,16 @@ async function cambiarEtapaLocal(
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const usuarioSub = req.usuarioToken?.sub
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
// Bloquear la unidad del inventario si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)
}
}
} else {
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub)
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } })
} catch (err) {
if (!esFalloMonday(err)) throw err
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
// Bloquear la unidad del inventario si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
} catch (err2) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err2)
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
return
}
// En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver):
// asegurarApartado reintenta unos segundos (Monday tarda en propagar la
// etapa) y es idempotente (dedup); el webhook queda como red de seguridad
// para cambios hechos directo en Monday.
if (body.etapa === 'Aparto') {
try {
await automatizacionesService.asegurarApartado(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
}
// Bloquear la unidad del inventario (colocar en APARTADA)
try {
const boardOportunidades = config.monday.boards.oportunidades
const svc = mondayService as MondayService
const item = await svc.getItemConGrupo(itemId)
if (item) {
const colUnidad = await svc.columnaIdPorTitulo(boardOportunidades, 'Unidad')
const unidadCodigo = colUnidad
? (item.column_values.find((cv) => cv.id === colUnidad)?.text ?? '').trim()
: ''
if (unidadCodigo !== '') {
await svc.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad de la oportunidad ${itemId}:`, err)
}
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(usuarioSub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
}

View File

@@ -1,68 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { notificarEscrituracionSiLiquidado, resolverFiltroMios } from './planes.controller.js'
// ---------- Prisma mockeado: el encolado de la réplica no toca la BD ----------
const prismaMock = vi.hoisted(() => ({
syncPendiente: {
create: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra cambios de columna */
function mondayMock() {
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 })
},
}
}
describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.syncPendiente.create.mockResolvedValue({})
})
it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => {
const monday = mondayMock()
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
expect(ok).toBe(true)
// board 666 = MONDAY_BOARD_APARTADO de test
expect(monday.cambios).toEqual([
{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' },
])
})
it('no hace nada si el plan no está liquidado o no tiene item ligado', async () => {
const monday = mondayMock()
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, false, monday)).toBe(false)
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: null }, true, monday)).toBe(false)
expect(await notificarEscrituracionSiLiquidado({}, true, monday)).toBe(false)
expect(monday.cambios).toEqual([])
})
it('un fallo de Monday no rompe el abono: loguea, encola LIQUIDADO_ESCRITURAR y devuelve false', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
expect(ok).toBe(false)
expect(spyError).toHaveBeenCalled()
// La escrituración queda encolada para que el worker la aplique al volver Monday
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'LIQUIDADO_ESCRITURAR',
payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }),
referenciaLocal: 'apt-55',
}),
})
spyError.mockRestore()
})
})
import { describe, expect, it } from 'vitest'
import { resolverFiltroMios } from './planes.controller.js'
describe('resolverFiltroMios (?mios=1)', () => {
it('sin ?mios=1 no filtra nada', () => {

View File

@@ -1,7 +1,6 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro } from '../services/crm-local.service.js'
import {
alCorriente,
avancePlan,
@@ -20,8 +19,6 @@ import {
type PlanPago as PlanPagoDominio,
} from '../services/cobranza.service.js'
import { BadRequestError, NotFoundError } from '../utils/errors.js'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
@@ -117,7 +114,7 @@ const crearPlanSchema = z.object({
engancheTotal: z.number().positive(),
numPagos: z.number().int().min(1),
tasaMoraMensual: z.number().min(0).optional(),
// Item del tablero Apartado (Monday) ligado al plan; también ligable después con PATCH
// Item del tablero Apartado ligado al plan (compatibilidad de datos)
mondayItemId: z.string().min(1).optional(),
})
@@ -213,31 +210,6 @@ const abonoSchema = z.object({
referencia: z.string().min(1),
})
type MondayApartado = Pick<MondayService, 'cambiarValorColumna'>
/**
* Puente cobranza → postventa: cuando el plan queda LIQUIDADO y está ligado a
* un item del tablero Apartado, ese item pasa a 'X Escriturar'.
* Un fallo de Monday NUNCA rompe el abono: se loguea y devuelve false.
*/
export async function notificarEscrituracionSiLiquidado(
plan: { mondayItemId?: string | null },
liquidado: boolean,
monday: MondayApartado = mondayService,
): Promise<boolean> {
if (!liquidado || plan.mondayItemId == null || plan.mondayItemId === '') return false
try {
await monday.cambiarValorColumna(config.monday.boards.apartado, plan.mondayItemId, 'Estado', 'X Escriturar')
return true
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err)
// La escrituración queda encolada (LIQUIDADO_ESCRITURAR) para el worker
await encolarSyncSeguro('LIQUIDADO_ESCRITURAR', { mondayItemId: plan.mondayItemId }, plan.mondayItemId)
return false
}
}
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
const body = abonoSchema.parse(req.body)
@@ -270,24 +242,7 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
])
const actualizado = await cargarPlan(plan.id)
const dominioActualizado = aDominio(actualizado)
const calculado = conCalculados(dominioActualizado)
// Si el abono liquidó el plan, avisar a postventa (no bloquea la respuesta)
await notificarEscrituracionSiLiquidado(dominioActualizado, calculado.status === 'LIQUIDADO')
res.status(201).json(calculado)
}
const ligarMondaySchema = z.object({ mondayItemId: z.string().min(1).nullable() })
/** PATCH /api/planes/:id/monday — liga (o desliga con null) el item de Apartado en Monday */
export async function ligarMondayItem(req: Request, res: Response): Promise<void> {
const body = ligarMondaySchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
await prisma.planPago.update({ where: { id: plan.id }, data: { mondayItemId: body.mondayItemId } })
const actualizado = await cargarPlan(plan.id)
res.json(conCalculados(aDominio(actualizado)))
res.status(201).json(conCalculados(aDominio(actualizado)))
}
const notaSchema = z.object({ texto: z.string() })

View File

@@ -1,8 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
@@ -17,23 +16,14 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
getApartados: vi.fn(),
getContactos: vi.fn(),
getContactoDetalle: vi.fn(),
getOportunidades: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { prefillApartadoEndpoint } from './clientes.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* Fallback del prefill del alta (GET /api/altas/prefill-apartado/:itemId):
* Monday caído/502 → el prefill se arma desde LeadCaptura (apartado local) +
* Cliente + cotización local, con los campos que el front ya consume.
* Prefill del alta (GET /api/altas/prefill-apartado/:itemId) 100% local:
* el prefill se arma desde LeadCaptura (apartado local) + Cliente + cotización
* local, con los campos que el front ya consume.
*/
const apartadoLocal = {
@@ -101,22 +91,18 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.leadCaptura.findUnique.mockResolvedValue(apartadoLocal)
prismaMock.leadCaptura.findFirst.mockResolvedValue(oportunidadLocal)
prismaMock.cliente.findFirst.mockResolvedValue(clienteLocal)
prismaMock.cotizacion.findFirst.mockResolvedValue(cotizacionLocal)
})
describe('prefillApartadoEndpoint (fallback local)', () => {
it('Monday caído: prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
describe('prefillApartadoEndpoint (GET /api/altas/prefill-apartado/:itemId) — local', () => {
it('prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => {
const { res, json } = resFake()
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
expect(mondayMock.getApartados).not.toHaveBeenCalled()
const prefill = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(prefill['apartado']).toMatchObject({
id: 'loc_a1',
@@ -142,30 +128,23 @@ describe('prefillApartadoEndpoint (fallback local)', () => {
expect(prefill['cotizacion']).toMatchObject({ tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 4_400_000 })
})
it('Monday caído y el apartado no existe en local → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('el apartado no existe en local → 404', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
await expect(
prefillApartadoEndpoint({ params: { itemId: 'nope' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
).rejects.toMatchObject({ status: 404 })
})
it('Monday lanza 502: cae al prefill local; el 404 de Monday se propaga intacto', async () => {
mondayMock.getApartados.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
expect((json.mock.calls[0]?.[0] as Record<string, unknown>)['apartado']).toMatchObject({ id: 'loc_a1' })
it('un id que no es del board APARTADO → 404 (el prefill solo aplica a apartados)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...apartadoLocal, board: 'LEADS' })
// Apartado inexistente en Monday (404): NO es fallo de Monday, se propaga
mondayMock.getApartados.mockResolvedValue([])
await expect(
prefillApartadoEndpoint({ params: { itemId: 'x' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
prefillApartadoEndpoint({ params: { itemId: 'loc_lead' } } as unknown as Request, resFake().res),
).rejects.toMatchObject({ status: 404 })
})
it('sin Cliente ni oportunidad local: contacto/oportunidad van en null (el front los tolera)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.cotizacion.findFirst.mockResolvedValue(null)

View File

@@ -47,6 +47,7 @@ describe('recordatorio-vendedor.controller', () => {
titulo: 'Test',
descripcion: null,
leadId: null,
clienteClave: null,
fechaRecordar: '2026-09-10T10:00:00',
})
expect(res.status).toHaveBeenCalledWith(201)
@@ -66,6 +67,7 @@ describe('recordatorio-vendedor.controller', () => {
completados: true,
desde: '2026-09-01',
hasta: null,
clienteClave: null,
})
expect(res.json).toHaveBeenCalledWith([])
})

View File

@@ -1,12 +1,11 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { ReportesService } from '../services/reportes.service.js'
import { cargarPlanesDominio } from './planes.controller.js'
import { obtenerScores } from '../services/lead-scoring.service.js'
import { resumenMora } from '../services/mora.service.js'
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
export const reportesService = new ReportesService(cargarPlanesDominio)
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta
* ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */

View File

@@ -1,15 +1,9 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* GET /api/sistema/estado — salud del modo local para el banner del front:
* `mondayOk` (false = operar en modo local) y `pendientesSync` (registros de
* la cola SyncPendiente esperando réplica a Monday, incluidos los en ERROR).
* GET /api/sistema/estado — estado del modo local para el banner del front.
* Monday ya no participa: la API siempre opera con datos locales.
*/
export async function estadoSistema(_req: Request, res: Response): Promise<void> {
const pendientesSync = await prisma.syncPendiente.count({
where: { estado: { in: ['PENDIENTE', 'ERROR'] } },
})
res.json({ mondayOk: saludMonday.estaDisponible(), pendientesSync })
res.json({ mondayOk: false, pendientesSync: 0 })
}

View File

@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
import { NotFoundError } 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: {
@@ -11,16 +11,9 @@ 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 { listarUnidades, obtenerUnidad } from './unidades.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
const filaLocal = {
codigo: 'U-101',
@@ -42,30 +35,16 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findMany.mockResolvedValue([filaLocal])
prismaMock.unidad.findUnique.mockResolvedValue(filaLocal)
})
describe('unidades.controller (fallback local)', () => {
it('Monday vivo: devuelve Monday y no toca la tabla local', async () => {
mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }])
describe('unidades.controller (local)', () => {
it('lista desde la tabla Unidad con la forma de DTO completa', async () => {
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect(json).toHaveBeenCalledWith([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }])
expect(prismaMock.unidad.findMany).not.toHaveBeenCalled()
})
it('Monday caído (circuit breaker): lista desde la tabla Unidad con la misma forma de DTO', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect(mondayMock.getUnidades).not.toHaveBeenCalled()
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista).toHaveLength(1)
expect(lista[0]).toMatchObject({
@@ -79,8 +58,7 @@ describe('unidades.controller (fallback local)', () => {
})
})
it('Monday caído: los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
filaLocal,
{ ...filaLocal, codigo: 'U-102', status: 'APARTADO' },
@@ -94,18 +72,7 @@ describe('unidades.controller (fallback local)', () => {
expect(lista.map((u) => u['codigo'])).toEqual(['S-1'])
})
it('Monday lanza 502: fallback local; otro error se propaga', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect((json.mock.calls[0]?.[0] as unknown[]).length).toBe(1)
mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug'))
await expect(listarUnidades({ query: {} } as unknown as Request, resFake().res)).rejects.toThrow('bug')
})
it('obtenerUnidad: Monday caído → local; inexistente en ambos → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('obtenerUnidad: devuelve el DTO; inexistente → 404', async () => {
const { res, json } = resFake()
await obtenerUnidad({ params: { codigo: 'U-101' } } as unknown as Request, res)

View File

@@ -1,13 +1,13 @@
import type { Request, Response } from 'express'
import { listarUnidadesConFallback, obtenerUnidadConFallback } from '../services/unidades-local.service.js'
import { getUnidadesLocal, getUnidadLocal } from '../services/unidades-local.service.js'
import { NotFoundError } from '../utils/errors.js'
/**
* GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo.
* Monday caído (o 502) → espejo local (tabla Unidad), mismo contrato de respuesta.
* Lee el espejo local (tabla Unidad), mismo contrato de respuesta.
*/
export async function listarUnidades(req: Request, res: Response): Promise<void> {
const unidades = await listarUnidadesConFallback()
const unidades = await getUnidadesLocal()
const disponibles = req.query['disponibles'] === 'true'
const desarrollo = req.query['desarrollo']?.toString().toUpperCase()
let lista = disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades
@@ -17,9 +17,9 @@ export async function listarUnidades(req: Request, res: Response): Promise<void>
res.json(lista)
}
/** GET /api/unidades/:codigo — 404 si no existe ni en Monday ni en el espejo local */
/** GET /api/unidades/:codigo — 404 si no existe en el espejo local */
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
const unidad = await obtenerUnidadConFallback(String(req.params['codigo']))
const unidad = await getUnidadLocal(String(req.params['codigo']))
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
res.json(unidad)
}

View File

@@ -1,26 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import type { Usuario } from '@prisma/client'
import {
actualizarRolEndpoint,
actualizarVinculoMondayEndpoint,
listarUsuariosMondayEndpoint,
} from './usuarios.controller.js'
import { AppError } from '../utils/errors.js'
import { actualizarRolEndpoint } from './usuarios.controller.js'
// ---------- Monday y Prisma mockeados (los tests no tocan red ni BD) ----------
// ---------- Prisma mockeado (los tests no tocan BD) ----------
const mondayMock = vi.hoisted(() => ({ getUsuariosWorkspace: vi.fn() }))
const prismaMock = vi.hoisted(() => ({ usuario: { update: vi.fn() } }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
const USUARIOS_WORKSPACE = [
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
]
function usuarioFake(overrides: Partial<Usuario> = {}): Usuario {
return {
id: 'u-1',
@@ -54,91 +42,6 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getUsuariosWorkspace.mockResolvedValue(USUARIOS_WORKSPACE)
})
describe('listarUsuariosMondayEndpoint (GET /api/monday/usuarios)', () => {
it('devuelve los usuarios del workspace con id numérico', async () => {
const { res, json } = resFake()
await listarUsuariosMondayEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith([
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
])
})
it('propaga el error si Monday falla (el error handler responde 5xx)', async () => {
mondayMock.getUsuariosWorkspace.mockRejectedValue(new Error('Monday caído'))
const { res } = resFake()
await expect(listarUsuariosMondayEndpoint(reqFake(), res)).rejects.toThrow('Monday caído')
})
})
describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () => {
it('con id válido guarda el vínculo y devuelve el perfil actualizado', async () => {
prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: 13676 }))
const { res, json } = resFake()
await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: 13676 } }), res)
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u-1' },
data: { mondayUserId: 13676 },
})
expect(json).toHaveBeenCalledWith({
nombre: 'ERICK ISRAEL PEÑA RAMOS',
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: 13676,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
acciones: [],
})
})
it('con id inexistente en el workspace responde 400 y no toca la BD', async () => {
const { res } = resFake()
const error = await actualizarVinculoMondayEndpoint(
reqFake({ body: { mondayUserId: 99999 } }),
res,
).catch((e: unknown) => e)
expect(error).toBeInstanceOf(AppError)
expect(error).toMatchObject({ status: 400 })
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('con null desvincula sin consultar Monday', async () => {
prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: null }))
const { res, json } = resFake()
await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: null } }), res)
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u-1' },
data: { mondayUserId: null },
})
expect(json).toHaveBeenCalledWith(expect.objectContaining({ mondayUserId: null }))
})
it('sin token verificado: 400', async () => {
const { res } = resFake()
await expect(
actualizarVinculoMondayEndpoint(reqFake({ usuarioToken: undefined }), res),
).rejects.toThrow('Falta el token verificado')
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('body inválido (string): ZodError (el error handler lo traduce a 400)', async () => {
const { res } = resFake()
await expect(
actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: '13676' } }), res),
).rejects.toThrow()
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
})
describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => {
@@ -193,4 +96,3 @@ describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => {
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
})

View File

@@ -1,49 +1,10 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { aPublico } from '../services/auth.service.js'
import { esRolValido } from '../config/roles.js'
import { AppError, BadRequestError } from '../utils/errors.js'
/**
* Vínculo usuario del ecosistema ↔ usuario de Monday (people). Es el mapeo
* que alimenta round-robin, mis-leads, actividades y alertas; el usuario lo
* elige él mismo desde la landing (Configuración de cuenta).
*/
/** GET /api/monday/usuarios (Bearer) → usuarios activos del workspace */
export async function listarUsuariosMondayEndpoint(_req: Request, res: Response): Promise<void> {
const usuarios = await mondayService.getUsuariosWorkspace()
res.json(usuarios.map((u) => ({ id: u.id, nombre: u.nombre, email: u.email })))
}
const vinculoMondaySchema = z.object({
mondayUserId: z.number().int().positive().nullable(),
})
/**
* PATCH /api/usuarios/me/monday (Bearer) { mondayUserId: number | null }
* null desvincula. Un id que no existe en el workspace → 400.
*/
export async function actualizarVinculoMondayEndpoint(req: Request, res: Response): Promise<void> {
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
const { mondayUserId } = vinculoMondaySchema.parse(req.body)
if (mondayUserId !== null) {
const usuarios = await mondayService.getUsuariosWorkspace()
if (!usuarios.some((u) => u.id === mondayUserId)) {
throw new BadRequestError(`El usuario de Monday ${mondayUserId} no existe en el workspace`)
}
}
const usuario = await prisma.usuario.update({
where: { id: req.usuarioToken.sub },
data: { mondayUserId },
})
res.json(await aPublico(usuario))
}
const cambiarRolSchema = z.object({
rol: z.string().min(1),
})

View File

@@ -1,151 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
const mocks = vi.hoisted(() => ({
runRoundRobin: vi.fn(),
escaneoOportunidades: vi.fn(),
moverAGrupo: vi.fn(),
}))
vi.mock('../services/automatizaciones.service.js', () => ({
automatizacionesService: {
runRoundRobin: mocks.runRoundRobin,
escaneoOportunidades: mocks.escaneoOportunidades,
},
}))
vi.mock('../services/grupos-estado.service.js', () => ({
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
GRUPOS_POR_ESTADO: {
'111': { columna: 'Estado', destinos: { NUEVO: 'topics' } },
'222': { columna: 'Estado', destinos: { Ganado: 'group_mm6scqz3' } },
},
moverAGrupoSegunEstado: mocks.moverAGrupo,
}))
vi.mock('../config/index.js', () => ({
config: {
monday: {
boards: { leads: '111', oportunidades: '222' },
},
},
}))
import { webhookMonday } from './webhooks.controller.js'
function resFake(): { res: Response; json: ReturnType<typeof vi.fn>; sendStatus: ReturnType<typeof vi.fn> } {
const json = vi.fn()
const sendStatus = vi.fn()
return { res: { json, sendStatus } as unknown as Response, json, sendStatus }
}
const reqCon = (body: unknown): Request => ({ body } as Request)
beforeEach(() => {
vi.clearAllMocks()
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
})
describe('webhookMonday', () => {
it('responde el challenge del handshake tal cual', () => {
const { res, json, sendStatus } = resFake()
webhookMonday(reqCon({ challenge: 'abc-123' }), res)
expect(json).toHaveBeenCalledWith({ challenge: 'abc-123' })
expect(sendStatus).not.toHaveBeenCalled()
})
it('ignora payloads sin estructura de evento (200 sin disparar nada)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ foo: 'bar' }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
})
it('create_item en Oportunidades dispara el round-robin del item (tablero oportunidades)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'oportunidades')
})
it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777', 'oportunidades')
})
it('create_item en Leads dispara el round-robin del tablero Leads (SIN crear oportunidad)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'leads')
})
it('create_item en otro board no dispara round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
})
it('change_status_column_value en Oportunidades dispara el escaneo', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: '222', itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
})
it('change_column_value en Oportunidades también dispara el escaneo', () => {
const { res } = resFake()
webhookMonday(reqCon({ event: { type: 'change_column_value', boardId: 222, itemId: 1 } }), res)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
})
it('update_column_value (nombre real del evento) en Oportunidades dispara el escaneo', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, pulseId: 5 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
})
it('cambio de status en otro board no dispara el escaneo de oportunidades', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: 333, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
})
it('update_column_value en Leads dispara el mover a grupo según estado', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 111, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '111', '999')
})
it('update_column_value en Oportunidades dispara escaneo Y mover a grupo', () => {
const { res } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, itemId: 5 } }), res)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '222', '5')
})
it('update_column_value en un board sin mapeo no dispara el mover a grupo', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 333, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.moverAGrupo).not.toHaveBeenCalled()
})
it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
// Deja que el rechazo se procese para no ensuciar otros tests
await new Promise((r) => setImmediate(r))
})
})

View File

@@ -1,124 +0,0 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { mondayService } from '../services/monday.service.js'
const eventoSchema = z
.object({
type: z.string(),
boardId: z.union([z.string(), z.number()]),
// Monday manda pulseId en los eventos reales (create_pulse) y itemId en
// las pruebas del panel de webhooks; se aceptan ambos.
itemId: z.union([z.string(), z.number()]).optional(),
pulseId: z.union([z.string(), z.number()]).optional(),
})
.passthrough()
const payloadSchema = z.object({ event: eventoSchema }).passthrough()
/**
* POST /api/webhooks/monday
* Handshake: Monday envía { challenge } al registrar el webhook y exige que
* se responda tal cual. Eventos reales: se responde 200 de inmediato y se
* procesan en segundo plano.
*
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Por eso el
* evento solo se usa como DISPARADOR: el round-robin re-consulta el tablero
* vía API antes de escribir nada.
*/
export function webhookMonday(req: Request, res: Response): void {
const body = req.body as Record<string, unknown>
if (typeof body?.['challenge'] === 'string') {
res.json({ challenge: body['challenge'] })
return
}
const parsed = payloadSchema.safeParse(body)
if (!parsed.success) {
// eslint-disable-next-line no-console
console.log('[webhook monday] payload sin estructura de evento reconocida; se ignora')
res.sendStatus(200)
return
}
const { type, boardId, itemId, pulseId } = parsed.data.event
const esBoardLeads = String(boardId) === config.monday.boards.leads
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
const idEvento = itemId ?? pulseId
// eslint-disable-next-line no-console
console.log(`[webhook monday] tipo=${type} board=${boardId} item=${idEvento ?? '?'}`)
// Monday registra el webhook como 'create_item' pero el payload real llega
// como 'create_pulse'; se aceptan ambos.
// Item nuevo en Leads u Oportunidades → solo round-robin del tablero
// correspondiente (los leads NO crean oportunidad; el Alta Afluencia entra
// directo a Oportunidades vía POST /api/afluencias). El grupo NO se arregla
// aquí: recién creado el item aún no tiene Estado (moverAGrupoSegunEstado no
// haría nada) y el create con column_values no dispara el webhook de change —
// para items creados a mano en la UI, el grupo lo corrige la próxima vez que
// cambie una columna.
if ((type === 'create_item' || type === 'create_pulse') && (esBoardLeads || esBoardOportunidades) && idEvento !== undefined) {
const id = String(idEvento)
const board = esBoardLeads ? 'leads' : 'oportunidades'
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
void automatizacionesService
.runRoundRobin(id, board)
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] round-robin ${board} item=${id}: ${r.asignados} asignado(s)`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en round-robin ${board} item=${id}:`, err)
})
}
// Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica.
// El payload de Monday no es confiable (no va firmado), así que solo se usa
// como disparador: el escaneo re-consulta el tablero y es idempotente (dedup
// en AutomatizacionState). El cron de polling queda como red de seguridad.
// OJO: el webhook se registra como 'change_column_value' pero el payload
// real llega como 'update_column_value'; se aceptan los tres nombres.
const esEventoColumna =
type === 'change_status_column_value' || type === 'change_column_value' || type === 'update_column_value'
if (esEventoColumna && esBoardOportunidades) {
void automatizacionesService
.escaneoOportunidades()
.then((r) => {
// eslint-disable-next-line no-console
console.log(
`[webhook monday] oportunidades: ${r.apartadosCreados.length} apartado(s), ` +
`${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`,
)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error('[webhook monday] error en escaneo de oportunidades:', err)
})
}
// Cambio de status en cualquier tablero con mapeo estado → grupo: el item se
// mueve al grupo que corresponde a su nuevo estado (regla de negocio: los
// grupos reflejan la etapa del pipeline). Mismo criterio que arriba: el
// payload solo dispara y el servicio re-consulta el item antes de mover.
// En Oportunidades convive con el escaneo (ambos disparan).
if (esEventoColumna && idEvento !== undefined && GRUPOS_POR_ESTADO[String(boardId)]) {
const board = String(boardId)
const id = String(idEvento)
void moverAGrupoSegunEstado(mondayService, board, id)
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] grupo-estado board=${board} item=${id}: ${r.movido ? `movido a ${r.grupo}` : 'sin cambio'}`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en grupo-estado board=${board} item=${id}:`, err)
})
}
res.sendStatus(200)
}

View File

@@ -3,137 +3,51 @@ import { crearApp } from './app.js'
import { config } from './config/index.js'
import { automatizacionesService } from './services/automatizaciones.service.js'
import * as configService from './services/config.service.js'
import { mondayService } from './services/monday.service.js'
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
import { saludMonday } from './services/salud-monday.service.js'
import { syncMondayService } from './services/sync-monday.service.js'
// Registra los handlers de cada tipo de SyncPendiente sobre el worker (efecto lateral)
import './services/sync-handlers.js'
const app = crearApp()
// Wiring del circuit breaker: el ping vive en monday.service; se inyecta aquí
// para que salud-monday.service no lo importe (ciclo de imports).
saludMonday.registrarPing(() => mondayService.ping())
// Sondeo de salud de Monday (cada 60 s): tras FALLOS_PARA_CAER fallos GraphQL
// seguidos el flag cae y la app opera en modo local; cuando el ping vuelve a
// responder se recupera el flag y se drena la cola de sincronización.
// Corre siempre (aunque MONDAY_AUTOMATIONS_ENABLED=false): es la infra del modo local.
cron.schedule('* * * * *', () => {
void (async () => {
if (process.env.MODO_LOCAL_FORZADO === 'true') return // modo local forzado: no sondear
if (saludMonday.estaDisponible()) return // Monday sano: nada que hacer
const recuperado = await saludMonday.sondear()
if (recuperado && saludMonday.estaDisponible()) {
// eslint-disable-next-line no-console
console.log('[salud] Monday recuperado; drenando cola de sincronización')
void syncMondayService.procesarPendientes() // fire-and-forget (mutex interno)
}
})().catch((err: unknown) => console.error('[cron salud] error en sondeo:', err))
})
// Sync worker del modo local (cada 2 min): drena SyncPendiente FIFO hacia
// Monday. No-op mientras Monday esté caído. Secuencial por diseño (SQLite).
cron.schedule('*/2 * * * *', () => {
syncMondayService
.procesarPendientes()
.catch((err: unknown) => console.error('[cron sync] error en la pasada:', err))
})
// Crons de respaldo: los webhooks de Monday (registrados sobre
// https://frasa.consultoria-as.com/api/webhooks/monday) disparan estas mismas
// reglas en tiempo real; estos barridos quedan como red de seguridad por si un
// evento se pierde. Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false.
// Crons de automatizaciones sobre tablas locales (round-robin, escalado,
// inactividad). Cada regla tiene su propio catch: un fallo no rompe las demás.
// SECUENCIAL a propósito: SQLite serializa las escrituras y lanzar los escaneos
// en paralelo provoca timeouts P1008 en AutomatizacionState.
if (config.automatizaciones.enabled) {
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada,
// escala leads sin contactar y crea Apartados/Ventas al moverse Oportunidades.
// Cada regla tiene su propio catch: un fallo de Monday no rompe las demás.
// SECUENCIAL a propósito: SQLite serializa las escrituras y lanzar los tres
// escaneos en paralelo provoca timeouts P1008 en AutomatizacionState.
cron.schedule(config.automatizaciones.pollCron, () => {
void (async () => {
// Modo local: Monday caído → ejecutar versiones locales de las automatizaciones
if (!saludMonday.estaDisponible()) {
try {
const r = await automatizacionesService.runRoundRobinLocal()
if (r.asignados > 0) {
// eslint-disable-next-line no-console
console.log(`[cron poll-local] round-robin local: ${r.asignados} lead(s) asignado(s)`)
}
} catch (err: unknown) {
console.error('[cron poll-local] error en round-robin local:', err)
}
try {
const r = await automatizacionesService.escaneoEscaladoLocal()
if (r.escalados > 0) {
// eslint-disable-next-line no-console
console.log(`[cron poll-local] escalado local: ${r.escalados} lead(s) escalado(s)`)
}
} catch (err: unknown) {
console.error('[cron poll-local] error en escalado local:', err)
}
return
}
try {
const r = await automatizacionesService.runRoundRobin()
const r = await automatizacionesService.runRoundRobinLocal()
if (r.asignados > 0) {
// eslint-disable-next-line no-console
console.log(`[cron poll] round-robin: ${r.asignados} lead(s) asignado(s)`)
console.log(`[cron poll-local] round-robin local: ${r.asignados} lead(s) asignado(s)`)
}
} catch (err: unknown) {
console.error('[cron poll] error en round-robin:', err)
console.error('[cron poll-local] error en round-robin local:', err)
}
try {
const r = await automatizacionesService.escaneoEscalado()
const r = await automatizacionesService.escaneoEscaladoLocal()
if (r.escalados > 0) {
// eslint-disable-next-line no-console
console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`)
console.log(`[cron poll-local] escalado local: ${r.escalados} lead(s) escalado(s)`)
}
} catch (err: unknown) {
console.error('[cron poll] error en escalado:', err)
}
try {
const r = await automatizacionesService.escaneoOportunidades()
if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) {
// eslint-disable-next-line no-console
console.log(
`[cron poll] oportunidades: ${r.apartadosCreados.length} apartado(s), ` +
`${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`,
)
}
} catch (err: unknown) {
console.error('[cron poll] error en oportunidades:', err)
console.error('[cron poll-local] error en escalado local:', err)
}
})()
})
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
// Escaneo diario de leads inactivos (recordatorio como actividad local)
cron.schedule(config.automatizaciones.recordatorioCron, () => {
if (!saludMonday.estaDisponible()) {
// Modo local: escaneo de inactividad sobre tablas locales
automatizacionesService
.escaneoInactividadLocal({ preview: false })
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[cron inactividad-local] ${r.recordatoriosEnviados} recordatorio(s) local(es)`)
})
.catch((err: unknown) => console.error('[cron inactividad-local] error en escaneo local:', err))
return
}
automatizacionesService
.escaneoInactividad({ preview: false })
.escaneoInactividadLocal({ preview: false })
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[cron inactividad] ${r.recordatoriosEnviados} recordatorio(s) enviado(s)`)
console.log(`[cron inactividad-local] ${r.recordatoriosEnviados} recordatorio(s) local(es)`)
})
.catch((err: unknown) => console.error('[cron inactividad] error en escaneo:', err))
.catch((err: unknown) => console.error('[cron inactividad-local] error en escaneo local:', err))
})
// eslint-disable-next-line no-console
console.log(`Automatizaciones Monday activas (poll: "${config.automatizaciones.pollCron}", inactividad: "${config.automatizaciones.recordatorioCron}")`)
console.log(`Automatizaciones locales activas (poll: "${config.automatizaciones.pollCron}", inactividad: "${config.automatizaciones.recordatorioCron}")`)
// Escaneo diario de recordatorios de cobranza (pagos por vencer / vencidos)
cron.schedule(config.recordatoriosCobranza.cron, () => {
@@ -153,11 +67,7 @@ if (config.automatizaciones.enabled) {
}
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
void configService.refrescarCache().then(() => {
const modoLocal = configService.obtenerConfigSync('modo_local_forzado') === 'true'
saludMonday.setModoLocalDb(modoLocal)
if (modoLocal) console.log('[startup] MODO LOCAL FORZADO activo (DB)')
}).catch((err: unknown) =>
void configService.refrescarCache().catch((err: unknown) =>
console.error('[startup] Error precargando config cache:', err),
)

View File

@@ -58,11 +58,10 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
expect(req.usuarioToken?.username).toBe('admin')
})
it('deja pasar las rutas públicas sin token (health, login, webhook monday)', () => {
it('deja pasar las rutas públicas sin token (health, login)', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/health' },
{ method: 'POST', path: '/auth/login' },
{ method: 'POST', path: '/webhooks/monday' },
]
for (const c of casos) {
vi.clearAllMocks()
@@ -125,12 +124,6 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
}
})
it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => {
const { res, status } = resFake()
authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next)
expect(status).toHaveBeenCalledWith(401)
})
it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => {
authMiddleware(
reqFake({

View File

@@ -4,12 +4,9 @@ import { verificarToken, type TokenPayload } from '../services/auth.service.js'
// Rutas públicas: no requieren token aunque AUTH_ENABLED=true. Se comparan
// contra req.path, que aquí ya está relativo al mount /api.
// Monday no puede mandar JWT, así que su webhook queda abierto (se valida por
// firma/challenge dentro del controlador).
const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [
{ metodo: 'GET', ruta: '/health' },
{ metodo: 'POST', ruta: '/auth/login' },
{ metodo: 'POST', ruta: '/webhooks/monday' },
{ metodo: 'GET', ruta: '/push/vapid-key' },
]

View File

@@ -39,7 +39,6 @@ import {
cancelarPlan,
cobranzaHoy,
crearPlan,
ligarMondayItem,
listarPlanes,
obtenerPlan,
registrarAbonoEndpoint,
@@ -64,8 +63,6 @@ import {
} from '../controllers/leads.controller.js'
import {
actualizarRolEndpoint,
actualizarVinculoMondayEndpoint,
listarUsuariosMondayEndpoint,
} from '../controllers/usuarios.controller.js'
import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js'
import {
@@ -74,7 +71,6 @@ import {
perfilCompletoEndpoint,
exportarContactosEndpoint,
} from '../controllers/contactos.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js'
import {
correrRoundRobin,
estadoAutomatizaciones,
@@ -159,7 +155,7 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente)
// Prefill del alta desde un apartado de Monday (botón "Alta Enkontrol"
// Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
// Alta progresiva: guardado etapa por etapa del wizard (Contacto PROSPECTO → CLIENTE).
@@ -195,7 +191,6 @@ apiRouter.get('/planes/:id/timeline', timelinePlan)
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
apiRouter.patch('/planes/:id/monday', ligarMondayItem)
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
@@ -241,8 +236,6 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
apiRouter.get('/monday/usuarios', listarUsuariosMondayEndpoint)
apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)
@@ -253,11 +246,9 @@ apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades
apiRouter.get('/reportes/metricas-uso', requireAdminOrCoordinador, metricasUsoEndpoint)
// Estado del modo local (banner del front): salud de Monday + cola de sync
// Estado del modo local (banner del front)
apiRouter.get('/sistema/estado', estadoSistema)
apiRouter.post('/webhooks/monday', webhookMonday)
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)

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

View File

@@ -3,19 +3,8 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
env: {
// config/index.ts exige estas vars al importarse; los tests nunca llaman a Monday real
MONDAY_API_KEY: 'test-key',
MONDAY_BOARD_CONTACTOS: '111',
MONDAY_BOARD_DESARROLLOS: '222',
MONDAY_BOARD_UNIDADES: '333',
MONDAY_BOARD_LEADS: '444',
MONDAY_BOARD_OPORTUNIDADES: '555',
MONDAY_BOARD_APARTADO: '666',
MONDAY_BOARD_VENTAS: '777',
MONDAY_BOARD_MOTIVOS: '888',
MONDAY_BOARD_ACTIVIDADES: '999',
// Las automatizaciones no deben crear actividades salvo que el test lo pida
ACTIVIDADES_ENABLED: 'false',
// Las automatizaciones no deben correr crons salvo que el test lo pida
AUTOMATIONS_ENABLED: 'false',
// Auth: desactivada por defecto en tests (los tests de auth la activan
// explícitamente); JWT_SECRET fijo para poder firmar/verificar tokens
AUTH_ENABLED: 'false',