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:
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 * * *'),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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".
|
||||
*/
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
])
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user