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