Files
FRASA-CAS/api-gateway/scripts/mover-a-grupos.py
Consult AS Technology 89fcd1c579 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.
2026-09-03 00:14:18 +00:00

185 lines
6.7 KiB
Python

#!/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()