feat(monday): mover items al grupo correcto al cambiar de estado

Webhook change_column_value en los 5 tableros (Leads, Oportunidades,
Unidades, Apartado, Contactos) + mapeo central estado->grupo: el API
re-consulta el item y lo mueve con move_item_to_group (idempotente).
El funnel mueve la oportunidad de Afluencia al crearla (Monday la crea
en el grupo top del board). Incluye script de backfill one-off
(249 items reubicados) y tests del mapeo y del webhook.
This commit is contained in:
2026-09-03 00:14:18 +00:00
parent 1ab29d765e
commit 89fcd1c579
8 changed files with 568 additions and 8 deletions

View File

@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
runRoundRobin: vi.fn(),
escaneoOportunidades: vi.fn(),
asegurarAfluencia: vi.fn(),
moverAGrupo: vi.fn(),
}))
vi.mock('../services/automatizaciones.service.js', () => ({
@@ -20,6 +21,15 @@ vi.mock('../services/funnel.service.js', () => ({
},
}))
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: {
@@ -43,6 +53,7 @@ beforeEach(() => {
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
mocks.asegurarAfluencia.mockResolvedValue('op-1')
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
})
describe('webhookMonday', () => {
@@ -113,6 +124,27 @@ describe('webhookMonday', () => {
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()

View File

@@ -3,6 +3,8 @@ import { z } from 'zod'
import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { funnelService } from '../services/funnel.service.js'
import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { mondayService } from '../services/monday.service.js'
const eventoSchema = z
.object({
@@ -86,12 +88,9 @@ export function webhookMonday(req: Request, res: Response): void {
// 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.
if (
(type === 'change_status_column_value' ||
type === 'change_column_value' ||
type === 'update_column_value') &&
esBoardOportunidades
) {
const esEventoColumna =
type === 'change_status_column_value' || type === 'change_column_value' || type === 'update_column_value'
if (esEventoColumna && esBoardOportunidades) {
void automatizacionesService
.escaneoOportunidades()
.then((r) => {
@@ -107,5 +106,24 @@ export function webhookMonday(req: Request, res: Response): void {
})
}
// Cambio de status en cualquier tablero con mapeo estado → grupo: el item se
// mueve al grupo que corresponde a su nuevo estado (regla de negocio: los
// grupos reflejan la etapa del pipeline). Mismo criterio que arriba: el
// payload solo dispara y el servicio re-consulta el item antes de mover.
// En Oportunidades convive con el escaneo (ambos disparan).
if (esEventoColumna && idEvento !== undefined && GRUPOS_POR_ESTADO[String(boardId)]) {
const board = String(boardId)
const id = String(idEvento)
void moverAGrupoSegunEstado(mondayService, board, id)
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] grupo-estado board=${board} item=${id}: ${r.movido ? `movido a ${r.grupo}` : 'sin cambio'}`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en grupo-estado board=${board} item=${id}:`, err)
})
}
res.sendStatus(200)
}

View File

@@ -2,11 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
import { FunnelService, type LeadOportunidadStore } from './funnel.service.js'
import type { LeadMonday } from './monday.service.js'
/** Monday mockeado: registra oportunidades creadas y cambios de columna */
/** Monday mockeado: registra oportunidades creadas, cambios de columna y movimientos de grupo */
function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: LeadMonday | null } = {}) {
return {
oportunidadesCreadas: [] as Record<string, unknown>[],
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientosGrupo: [] as { itemId: string; groupId: string }[],
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return `op-${this.oportunidadesCreadas.length}`
@@ -20,6 +21,21 @@ function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: Lea
async getLeadPorId(_itemId: string) {
return opts.leadPorId ?? null
},
// La oportunidad recién creada nace en el grupo top del tablero (como en Monday real)
async columnaIdPorTitulo(_boardId: string, _titulo: string) {
return 'status_col'
},
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Op Prueba',
groupId: 'group_top',
column_values: [{ id: 'status_col', text: 'Afluencia', value: null }],
}
},
async moverItemAGrupo(itemId: string, groupId: string) {
this.movimientosGrupo.push({ itemId, groupId })
},
}
}
@@ -63,6 +79,8 @@ describe('FunnelService.asegurarOportunidadAfluencia', () => {
{ nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, desarrolloLabel: 'SENDA' },
])
expect(store.mapping.get('lead-1')).toBe('op-1')
// Y la mueve al grupo que corresponde a 'Afluencia' (nace en el grupo top del tablero)
expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-1', groupId: 'topics' }])
})
it('es idempotente: con mapping existente no crea una segunda oportunidad', async () => {

View File

@@ -1,5 +1,6 @@
import { config } from '../config/index.js'
import { claveApartadoCreado, type StateStore } from './automatizaciones.service.js'
import { moverAGrupoSegunEstado } from './grupos-estado.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
@@ -31,7 +32,13 @@ export interface LeadOportunidadStore {
type MondayFunnel = Pick<
MondayService,
'crearOportunidadAfluencia' | 'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'getLeadPorId'
| 'crearOportunidadAfluencia'
| 'cambiarValorColumna'
| 'buscarLeadPorTelefono'
| 'getLeadPorId'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
>
export class FunnelService {
@@ -55,6 +62,16 @@ export class FunnelService {
desarrolloLabel: lead.desarrolloLabel,
})
await this.store.guardar(lead.id, oportunidadId)
// Monday crea los items en el grupo TOP del tablero (hoy "Perdidos" en
// Oportunidades) y el webhook de change_column_value no dispara en la
// creación: la oportunidad recién creada se mueve al grupo que corresponde
// a su etapa. Best-effort: un fallo no rompe la captura del lead.
try {
await moverAGrupoSegunEstado(this.monday, config.monday.boards.oportunidades, oportunidadId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${oportunidadId}:`, err)
}
return oportunidadId
}

View File

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

View File

@@ -0,0 +1,140 @@
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',
'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',
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',
'Firma Cliente (Venta)': 'group_mm6sx3rn',
'X Escriturar': 'group_mm6sx3rn',
Escriturado: 'group_mm6s40br',
FORMALIZADO: 'group_mm6s40br',
CANCELADO: 'group_mm6s2yyx',
Cancelacion: 'group_mm6s2yyx',
LIBERADO: 'group_mm6s2yyx',
},
},
}
/**
* Grupo destino para un status dado en un tablero. null si el tablero no tiene
* mapeo, si el label no está mapeado o si el status viene vacío (en Unidades
* la etapa vacía significa "sin clasificar": no se mueve).
*/
export function grupoDestino(boardId: string, statusActual: string): string | null {
const mapeo = GRUPOS_POR_ESTADO[boardId]
if (!mapeo) return null
const estado = statusActual.trim()
if (estado === '') return null
return mapeo.destinos[estado] ?? null
}
type MondayGrupos = Pick<MondayService, 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'>
/**
* Mueve un item al grupo que corresponde a su status actual, según
* GRUPOS_POR_ESTADO. Idempotente: si ya está en ese grupo no hace nada, y si el
* item ya no existe (borrado entre el webhook y la consulta) tampoco truena.
*/
export async function moverAGrupoSegunEstado(
monday: MondayGrupos,
boardId: string,
itemId: string,
): Promise<{ movido: boolean; grupo?: string }> {
const mapeo = GRUPOS_POR_ESTADO[boardId]
if (!mapeo) return { movido: false }
const item = await monday.getItemConGrupo(itemId)
if (!item) return { movido: false }
const columnaId = await monday.columnaIdPorTitulo(boardId, mapeo.columna)
if (columnaId === null) return { movido: false }
const estado = item.column_values.find((cv) => cv.id === columnaId)?.text ?? ''
const destino = grupoDestino(boardId, estado)
if (destino === null || item.groupId === destino) return { movido: false }
await monday.moverItemAGrupo(itemId, destino)
return { movido: true, grupo: destino }
}

View File

@@ -28,6 +28,14 @@ interface ItemConFechas extends ItemMonday {
updated_at: string
}
/** Item de cualquier tablero con el grupo donde está (automatización estado → grupo) */
export interface ItemConGrupo {
id: string
name: string
groupId: string
column_values: ColumnValue[]
}
/** Lead del tablero Leads tal como lo consumen las automatizaciones */
export interface LeadMonday {
id: string
@@ -544,6 +552,35 @@ export class MondayService {
)
}
/** ID de una columna por título exacto; null si no existe (lectura tolerante) */
async columnaIdPorTitulo(boardId: string, titulo: string): Promise<string | null> {
const cols = await this.columnasDe(boardId)
return cols.get(titulo)?.id ?? null
}
/** Item de cualquier tablero con su grupo y column_values; null si no existe */
async getItemConGrupo(itemId: string): Promise<ItemConGrupo | null> {
const data = await this.graphql<{ items: (ItemMonday & { group: { id: string } } | null)[] }>(
`query ($ids: [ID!]) {
items(ids: $ids) { id name group { id } column_values { id text value } }
}`,
{ ids: [itemId] },
)
const item = data.items[0]
if (!item) return null
return { id: item.id, name: item.name, groupId: item.group.id, column_values: item.column_values }
}
/** Mueve un item a otro grupo de su mismo tablero */
async moverItemAGrupo(itemId: string, groupId: string): Promise<void> {
await this.graphql<{ move_item_to_group: { id: string } }>(
`mutation ($itemId: ID!, $groupId: String!) {
move_item_to_group(item_id: $itemId, group_id: $groupId) { id }
}`,
{ itemId, groupId },
)
}
/** Nombre de un item por ID (cualquier tablero) */
async obtenerNombreItem(itemId: string): Promise<string> {
const data = await this.graphql<{ items: { name: string }[] }>(