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()
|
||||
Reference in New Issue
Block a user