From 89fcd1c5796466773d12328404fb6fcffd4a0b5c Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Thu, 3 Sep 2026 00:14:18 +0000 Subject: [PATCH] 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. --- api-gateway/scripts/mover-a-grupos.py | 184 ++++++++++++++++++ .../controllers/webhooks.controller.test.ts | 32 +++ .../src/controllers/webhooks.controller.ts | 30 ++- .../src/services/funnel.service.test.ts | 20 +- api-gateway/src/services/funnel.service.ts | 19 +- .../services/grupos-estado.service.test.ts | 114 +++++++++++ .../src/services/grupos-estado.service.ts | 140 +++++++++++++ api-gateway/src/services/monday.service.ts | 37 ++++ 8 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 api-gateway/scripts/mover-a-grupos.py create mode 100644 api-gateway/src/services/grupos-estado.service.test.ts create mode 100644 api-gateway/src/services/grupos-estado.service.ts diff --git a/api-gateway/scripts/mover-a-grupos.py b/api-gateway/scripts/mover-a-grupos.py new file mode 100644 index 0000000..d56a297 --- /dev/null +++ b/api-gateway/scripts/mover-a-grupos.py @@ -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() diff --git a/api-gateway/src/controllers/webhooks.controller.test.ts b/api-gateway/src/controllers/webhooks.controller.test.ts index 86203ec..9747ab2 100644 --- a/api-gateway/src/controllers/webhooks.controller.test.ts +++ b/api-gateway/src/controllers/webhooks.controller.test.ts @@ -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() diff --git a/api-gateway/src/controllers/webhooks.controller.ts b/api-gateway/src/controllers/webhooks.controller.ts index 8e537ca..3c5d3cc 100644 --- a/api-gateway/src/controllers/webhooks.controller.ts +++ b/api-gateway/src/controllers/webhooks.controller.ts @@ -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) } diff --git a/api-gateway/src/services/funnel.service.test.ts b/api-gateway/src/services/funnel.service.test.ts index 2ea531e..eb3a418 100644 --- a/api-gateway/src/services/funnel.service.test.ts +++ b/api-gateway/src/services/funnel.service.test.ts @@ -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[], cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], + movimientosGrupo: [] as { itemId: string; groupId: string }[], async crearOportunidadAfluencia(datos: Record) { 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 () => { diff --git a/api-gateway/src/services/funnel.service.ts b/api-gateway/src/services/funnel.service.ts index 9e66d6a..68f3c36 100644 --- a/api-gateway/src/services/funnel.service.ts +++ b/api-gateway/src/services/funnel.service.ts @@ -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 } diff --git a/api-gateway/src/services/grupos-estado.service.test.ts b/api-gateway/src/services/grupos-estado.service.test.ts new file mode 100644 index 0000000..5753ee9 --- /dev/null +++ b/api-gateway/src/services/grupos-estado.service.test.ts @@ -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([]) + }) +}) diff --git a/api-gateway/src/services/grupos-estado.service.ts b/api-gateway/src/services/grupos-estado.service.ts new file mode 100644 index 0000000..337ce6f --- /dev/null +++ b/api-gateway/src/services/grupos-estado.service.ts @@ -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 }> = { + // 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 + +/** + * 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 } +} diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 796b7b6..14eb495 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -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 { + 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 { + 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 { + 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 { const data = await this.graphql<{ items: { name: string }[] }>(