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:
184
api-gateway/scripts/mover-a-grupos.py
Normal file
184
api-gateway/scripts/mover-a-grupos.py
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill one-off: mueve los items existentes de los 5 tableros CRM al grupo
|
||||
que corresponde a su estado actual (los grupos reflejan la etapa del pipeline).
|
||||
|
||||
El mapeo estado -> grupo está DUPLICADO de
|
||||
api-gateway/src/services/grupos-estado.service.ts (GRUPOS_POR_ESTADO):
|
||||
mantener ambos sincronizados. Labels verificados contra Monday el 2026-09-02.
|
||||
|
||||
Idempotente: solo muta items cuyo grupo actual difiera del destino; items con
|
||||
estado vacío o no mapeado no se tocan (en Unidades la columna que manda es
|
||||
'Etapa', no 'Estado', y etapa vacía = sin clasificar: NO mover).
|
||||
|
||||
Uso:
|
||||
python3 mover-a-grupos.py # dry-run (default): solo reporta
|
||||
python3 mover-a-grupos.py --apply # aplica los movimientos
|
||||
|
||||
La API key se toma de MONDAY_API_KEY o de frasa-ecosystem/.env.
|
||||
Rate limit: ~0.4s entre mutaciones, retry con backoff ante 429/5xx.
|
||||
"""
|
||||
import json, os, sys, time, urllib.request, urllib.error
|
||||
|
||||
API = 'https://api.monday.com/v2'
|
||||
DRY = '--apply' not in sys.argv
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# board_id: (columna status que manda, {label -> group_id})
|
||||
MAPEO = {
|
||||
18427678493: ('Estado', { # Leads
|
||||
'NUEVO': 'topics',
|
||||
'Intento de contacto': 'group_mm6s1jct',
|
||||
'CONTACTADO': 'group_mm6s1jct',
|
||||
'AGENDA CITA': 'group_mm6s1jct',
|
||||
'CALIFICADO': 'group_mm6s1jct',
|
||||
'DESCARTADO': 'group_mm6s96mc',
|
||||
'Abandono': 'group_mm6s96mc',
|
||||
}),
|
||||
18427678497: ('Estado', { # Oportunidades
|
||||
'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',
|
||||
}),
|
||||
18427678476: ('Estado', { # Contactos
|
||||
'PROSPECTO': 'topics',
|
||||
'CLIENTE': 'topics',
|
||||
'INACTIVO': 'group_mm6sqqq2',
|
||||
}),
|
||||
18427678487: ('Etapa', { # Unidades (la columna que manda es 'Etapa')
|
||||
'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',
|
||||
}),
|
||||
18427678515: ('Estado', { # Apartado (pipeline Enkontrol)
|
||||
'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',
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
def load_key():
|
||||
key = os.environ.get('MONDAY_API_KEY')
|
||||
if key:
|
||||
return key
|
||||
env = os.path.join(ROOT, '../../.env')
|
||||
for line in open(env):
|
||||
if line.startswith('MONDAY_API_KEY='):
|
||||
return line.strip().split('=', 1)[1].strip().strip('"').strip("'")
|
||||
sys.exit('MONDAY_API_KEY no encontrada')
|
||||
|
||||
|
||||
KEY = load_key()
|
||||
|
||||
|
||||
def gql(query, variables=None, reintentos=3):
|
||||
body = json.dumps({'query': query, 'variables': variables or {}}).encode()
|
||||
for intento in range(reintentos + 1):
|
||||
if intento:
|
||||
time.sleep(0.5 * 2 ** (intento - 1))
|
||||
req = urllib.request.Request(API, data=body, headers={
|
||||
'Content-Type': 'application/json', 'Authorization': KEY, 'API-Version': '2024-10'})
|
||||
try:
|
||||
res = json.load(urllib.request.urlopen(req))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (429, 500, 502, 503) and intento < reintentos:
|
||||
continue
|
||||
raise
|
||||
if res.get('errors'):
|
||||
if intento < reintentos:
|
||||
continue
|
||||
raise RuntimeError(res['errors'])
|
||||
return res['data']
|
||||
raise RuntimeError('Monday no respondió')
|
||||
|
||||
|
||||
def columna_id(board_id, titulo):
|
||||
"""ID de la columna status por título exacto; sale con error si no existe."""
|
||||
data = gql('query { boards(ids: %d) { columns { id title } } }' % board_id)
|
||||
for col in data['boards'][0]['columns']:
|
||||
if col['title'] == titulo:
|
||||
return col['id']
|
||||
raise RuntimeError(f'Columna "{titulo}" no existe en el tablero {board_id}')
|
||||
|
||||
|
||||
def fetch_items(board_id, col_id):
|
||||
"""Todos los items del tablero con su grupo y el texto de la columna mapeada."""
|
||||
items, cursor = [], None
|
||||
while True:
|
||||
q = ('query($cursor: String) { boards(ids: %d) { items_page(limit: 200, cursor: $cursor) '
|
||||
'{ cursor items { id name group { id } column_values(ids: ["%s"]) { text } } } } }'
|
||||
% (board_id, col_id))
|
||||
page = gql(q, {'cursor': cursor})['boards'][0]['items_page']
|
||||
items += page['items']
|
||||
cursor = page['cursor']
|
||||
if not cursor or not page['items']:
|
||||
break
|
||||
time.sleep(0.4)
|
||||
return items
|
||||
|
||||
|
||||
def mover(item_id, group_id):
|
||||
gql('mutation($i: ID!, $g: String!) { move_item_to_group(item_id: $i, group_id: $g) { id } }',
|
||||
{'i': str(item_id), 'g': group_id})
|
||||
|
||||
|
||||
def main():
|
||||
total_movidos = 0
|
||||
for board_id, (titulo, destinos) in MAPEO.items():
|
||||
col_id = columna_id(board_id, titulo)
|
||||
items = fetch_items(board_id, col_id)
|
||||
pendientes, omitidos = [], 0
|
||||
for item in items:
|
||||
estado = (item['column_values'][0]['text'] if item['column_values'] else '') or ''
|
||||
destino = destinos.get(estado.strip()) if estado.strip() else None
|
||||
if destino is None:
|
||||
omitidos += 1 # estado vacío o no mapeado: no se toca
|
||||
continue
|
||||
if item['group']['id'] != destino:
|
||||
pendientes.append((item, estado, destino))
|
||||
print(f'board {board_id}: {len(items)} items, {len(pendientes)} por mover, '
|
||||
f'{omitidos} sin estado mapeado')
|
||||
movidos = 0
|
||||
for item, estado, destino in pendientes:
|
||||
print(f" {item['name']}: '{estado}' {item['group']['id']} -> {destino}"
|
||||
+ (' [dry-run]' if DRY else ''))
|
||||
if not DRY:
|
||||
mover(item['id'], destino)
|
||||
movidos += 1
|
||||
time.sleep(0.4)
|
||||
total_movidos += movidos
|
||||
if not DRY:
|
||||
print(f'TOTAL: {total_movidos} items movidos')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
114
api-gateway/src/services/grupos-estado.service.test.ts
Normal file
114
api-gateway/src/services/grupos-estado.service.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
140
api-gateway/src/services/grupos-estado.service.ts
Normal file
140
api-gateway/src/services/grupos-estado.service.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 }[] }>(
|
||||
|
||||
Reference in New Issue
Block a user