#!/usr/bin/env python3 """Migra las afluencias históricas del tablero Leads al tablero Oportunidades. Las afluencias ahora viven en Oportunidades (etapa Afluencia y afines) con todo el perfil; el tablero Leads queda como archivo (se vacía tras migrar). Uso: python3 migrar-afluencias-a-oportunidades.py # dry-run python3 migrar-afluencias-a-oportunidades.py --apply # migra y BORRA del board Leads python3 migrar-afluencias-a-oportunidades.py --apply --no-delete # migra sin borrar - Estado: CONTACTADO→Contactado, AGENDA CITA→Agendo Cita, CALIFICADO→Calificado, DESCARTADO→Perdido, Abandono→Abandono, NUEVO/vacío→Afluencia. - Grupo destino: Perdidos si Perdido/Abandono, Ganados si Ganado, si no topics. - Vendedor (people) → Asesor (people), si lo tiene. - Crea la fila LeadOportunidad {leadId: opId, oportunidadId: opId} para que el alta de clientes pueda mover la oportunidad migrada a 'Aparto'. """ import json import re import sqlite3 import sys import time import urllib.request from pathlib import Path ENV = Path(__file__).resolve().parents[2] / '.env' DB = Path(__file__).resolve().parents[1] / 'prisma' / 'dev.db' BOARD_LEADS = '18427678493' BOARD_OPS = '18427678497' GRUPO_PERDIDOS = 'group_mm6s4cgq' GRUPO_GANADOS = 'group_mm6scqz3' GRUPO_TOPICS = 'topics' TOKEN = re.search(r'^MONDAY_API_KEY="?([^"\n]+)"?', ENV.read_text(), re.M).group(1) def gql(query, variables=None): req = urllib.request.Request( 'https://api.monday.com/v2', data=json.dumps({'query': query, 'variables': variables or {}}).encode(), headers={'Authorization': TOKEN, 'Content-Type': 'application/json'}, ) with urllib.request.urlopen(req) as r: res = json.loads(r.read()) if 'errors' in res: raise RuntimeError(res['errors']) return res['data'] ESTADO_MAP = { 'CONTACTADO': 'Contactado', 'AGENDA CITA': 'Agendo Cita', 'CALIFICADO': 'Calificado', 'DESCARTADO': 'Perdido', 'ABANDONO': 'Abandono', 'NUEVO': 'Afluencia', '': 'Afluencia', } def todos_los_items(board): items, cursor = [], None while True: if cursor is None: q = 'query { boards(ids: [%s]) { items_page(limit: 200) { cursor items { id name created_at column_values { id text value } } } } }' % board else: q = 'query { boards(ids: [%s]) { items_page(limit: 200, cursor: "%s") { cursor items { id name created_at column_values { id text value } } } } }' % (board, cursor) page = gql(q)['boards'][0]['items_page'] items.extend(page['items']) cursor = page['cursor'] if not cursor: return items def main(): apply = '--apply' in sys.argv borrar = apply and '--no-delete' not in sys.argv cols_leads = {c['title']: c for c in gql('query { boards(ids: [%s]) { columns { id title type } } }' % BOARD_LEADS)['boards'][0]['columns']} cols_ops = {c['title']: c for c in gql('query { boards(ids: [%s]) { columns { id title type } } }' % BOARD_OPS)['boards'][0]['columns']} # Mapeo de columnas Leads → Oportunidades (por título) MAPA = [ ('Teléfono', 'Cliente teléfono'), ('Email', 'Cliente email'), ('Desarrollo', 'Desarrollo'), ('Modelo de Interés', 'Modelo de Interés'), ('Origen', 'Origen del Lead'), ('Medio de Contacto', 'Medio de Contacto'), ('Edad', 'Edad'), ('Estado Civil', 'Estado Civil'), ('Tipo de Empleo', 'Tipo de Empleo'), ('Ocupación', 'Ocupación'), ('Rango Salarial', 'Rango Salarial'), ('Crédito', 'Crédito'), ('Tipo de Economía', 'Tipo de Econimia'), ('Califica', 'Lead Califica'), ('Lo Quiere Para', 'Proposito de Compra'), ('Notas', 'COMENTARIOS'), ('Fecha', 'Fecha'), ] items = todos_los_items(BOARD_LEADS) # existentes en Oportunidades (por teléfono) para no duplicar si se corre 2 veces existentes = todos_los_items(BOARD_OPS) col_tel_ops = cols_ops['Cliente teléfono']['id'] tels_ops = set() for it in existentes: for cv in it['column_values']: if cv['id'] == col_tel_ops and cv['text']: d = re.sub(r'\D', '', cv['text']) if len(d) >= 10: tels_ops.add(d[-10:]) print(f'Leads a migrar: {len(items)} · oportunidades existentes: {len(existentes)}') migrados, saltados, errores = 0, 0, 0 detalles = [] db = sqlite3.connect(DB) if apply else None for it in items: cvs = {cv['id']: cv for cv in it['column_values']} # teléfono para dedup tel_txt = (cvs.get(cols_leads['Teléfono']['id']) or {}).get('text') or '' dig = re.sub(r'\D', '', tel_txt) if dig and dig[-10:] in tels_ops: saltados += 1 detalles.append(f" skip (ya existe): {it['name'][:45]}") continue estado_leads = (cvs.get(cols_leads['Estado']['id']) or {}) estado_txt = (estado_leads.get('text') or '') if estado_leads.get('value') else '' estado_destino = ESTADO_MAP.get(estado_txt.strip().upper(), 'Afluencia') grupo = GRUPO_PERDIDOS if estado_destino in ('Perdido', 'Abandono') else (GRUPO_GANADOS if estado_destino == 'Ganado' else GRUPO_TOPICS) valores = {} for titulo_leads, titulo_ops in MAPA: cl, co = cols_leads.get(titulo_leads), cols_ops.get(titulo_ops) if not cl or not co: continue cv = cvs.get(cl['id']) if not cv or not cv.get('text'): continue txt = cv['text'] if co['type'] == 'status': valores[co['id']] = {'label': txt} elif co['type'] == 'dropdown': valores[co['id']] = {'labels': [txt]} elif co['type'] == 'numbers': try: valores[co['id']] = float(txt) except ValueError: pass elif co['type'] == 'date': if re.match(r'^\d{4}-\d{2}-\d{2}$', txt): valores[co['id']] = {'date': txt} elif co['type'] == 'phone': valores[co['id']] = {'phone': txt, 'countryShortName': 'MX'} elif co['type'] == 'email': if re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', txt): valores[co['id']] = {'email': txt, 'text': txt} elif co['type'] == 'long_text': valores[co['id']] = {'text': txt[:1900]} else: valores[co['id']] = txt valores[cols_ops['Estado']['id']] = {'label': estado_destino} # Asesor si el lead tenía vendedor raw_vend = (cvs.get(cols_leads['Vendedor']['id']) or {}).get('value') if raw_vend: try: persons = [p for p in json.loads(raw_vend).get('personsAndTeams', []) if p.get('kind') == 'person'] if persons: valores[cols_ops['Asesor']['id']] = {'personsAndTeams': persons} except Exception: pass if not apply: migrados += 1 continue try: res = gql( 'mutation ($b: ID!, $g: String!, $n: String!, $v: JSON!) { create_item(board_id: $b, group_id: $g, item_name: $n, column_values: $v) { id } }', {'b': BOARD_OPS, 'g': grupo, 'n': it['name'], 'v': json.dumps(valores)}, ) op_id = res['create_item']['id'] db.execute( 'INSERT OR IGNORE INTO "LeadOportunidad" (id, leadId, oportunidadId, createdAt) VALUES (lower(hex(randomblob(16))), ?, ?, datetime("now"))', (op_id, op_id), ) migrados += 1 print(f"OK {it['name'][:45]} → op {op_id} [{estado_destino}]") if borrar: gql('mutation { delete_item(item_id: %s) { id } }' % it['id']) time.sleep(0.45) except Exception as e: errores += 1 print(f"ERROR {it['name'][:45]}: {e}") time.sleep(0.5) if db: db.commit() db.close() for d in detalles: print(d) print(f"\n{'MIGRADOS' if apply else 'DRY-RUN'}: {migrados} · saltados (dup): {saltados} · errores: {errores} · borrados de Leads: {migrados if borrar else 0}") if __name__ == '__main__': main()