#!/usr/bin/env python3 """Importa el historial de afluencia de agosto (AFLUENCIA AGOSTO.xlsx, hoja 'AGOSTO') al tablero Leads de Monday. Uso: python3 importar-afluencia-agosto.py # dry-run: solo reporta python3 importar-afluencia-agosto.py --apply # importa de verdad Decisiones: - Solo la hoja AGOSTO (las demás son subconjuntos de la misma). - Dedup por teléfono (últimos 10 dígitos) contra el board y dentro del archivo; se conserva el registro MÁS TEMPRANO (primer contacto). - Se importa directo vía GraphQL (no por el API del ecosistema) para NO disparar round-robin, LeadCaptura ni oportunidades: es historial, no captura nueva. Aun así el webhook create_item de Monday SÍ dispararía automatizaciones, por eso con --apply el script lo DESREGISTRA antes y lo VUELVE A CREAR al final (mismo evento y URL, leídos del propio webhook). - La fecha original va en la columna 'Fecha' (Monday no permite escribir created_at). Vendedor y estatus originales van prefijados en 'Notas'. - Valores fuera de catálogo se normalizan a los labels sembrados; los informativos-pero-no-categóricos ('NO QUISO DECIR', '?', etc.) se omiten. """ import json import re import sys import time import urllib.request from pathlib import Path import openpyxl ENV_PATH = Path(__file__).resolve().parents[2] / '.env' BOARD_LEADS = '18427678493' API = 'https://api.monday.com/v2' # ---------------- Monday ---------------- def token_desde_env() -> str: m = re.search(r'^MONDAY_API_KEY="?([^"\n]+)"?', ENV_PATH.read_text(), re.M) if not m: sys.exit('No se encontró MONDAY_API_KEY en .env') return m.group(1) TOKEN = token_desde_env() def gql(query: str, variables: dict | None = None) -> dict: req = urllib.request.Request( API, 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'] def columnas_board() -> dict[str, dict]: data = gql('query { boards(ids: [%s]) { columns { id title type settings_str } } }' % BOARD_LEADS) return {c['title']: c for c in data['boards'][0]['columns']} def labels_status(col: dict) -> list[str]: if col['type'] != 'status': return [] settings = json.loads(col.get('settings_str') or '{}') return [v for v in settings.get('labels', {}).values() if v] def sembrar_label(col: dict, label: str) -> None: """Crea un label faltante en una columna status vía item temporal.""" item = gql('mutation { create_item(board_id: %s, item_name: "TEMP_SEED") { id } }' % BOARD_LEADS) item_id = item['create_item']['id'] try: gql( 'mutation ($b: ID!, $i: ID!, $c: String!, $v: JSON!) { change_column_value(board_id: $b, item_id: $i, column_id: $c, value: $v, create_labels_if_missing: true) { id } }', {'b': BOARD_LEADS, 'i': item_id, 'c': col['id'], 'v': json.dumps({'label': label})}, ) finally: gql('mutation { delete_item(item_id: %s) { id } }' % item_id) time.sleep(0.4) def telefonos_existentes(col_tel_id: str) -> set[str]: """Últimos 10 dígitos de todos los teléfonos ya cargados en el board.""" phones: set[str] = set() cursor = None while True: if cursor is None: data = gql( 'query { boards(ids: [%s]) { items_page(limit: 200) { cursor items { column_values(ids: ["%s"]) { text } } } } }' % (BOARD_LEADS, col_tel_id) ) else: data = gql( 'query ($c: String!) { boards(ids: [%s]) { items_page(limit: 200, cursor: $c) { cursor items { column_values(ids: ["%s"]) { text } } } } }' % (BOARD_LEADS, col_tel_id), {'c': cursor}, ) page = data['boards'][0]['items_page'] for item in page['items']: tel = item['column_values'][0]['text'] if item['column_values'] else '' clave = clave_telefono(tel) if clave: phones.add(clave) cursor = page['cursor'] if not cursor: return phones def webhook_create_item() -> dict | None: # Los webhooks se consultan en raíz (no es campo de Board en esta versión) data = gql('query { webhooks(board_id: %s) { id event config } }' % BOARD_LEADS) for wh in data['webhooks']: if wh['event'] == 'create_item': return wh return None def borrar_webhook(wh_id: str) -> None: gql('mutation { delete_webhook(id: %s) { id board_id } }' % wh_id) def crear_webhook(url: str) -> str: data = gql( 'mutation ($b: ID!, $u: String!) { create_webhook(board_id: $b, url: $u, event: create_item) { id board_id } }', {'b': BOARD_LEADS, 'u': url}, ) return data['create_webhook']['id'] # ---------------- Normalización ---------------- def clave_telefono(tel: str) -> str | None: d = re.sub(r'\D', '', tel or '') return d[-10:] if len(d) >= 10 else None def telefono_limpio(tel: str) -> str: return re.sub(r'\D', '', tel or '') def norm_desarrollo(v: str) -> str: s = v.strip().upper() return {'SOLE': 'SOLÉ', 'SOLÉ': 'SOLÉ'}.get(s, s) def norm_estado(v: str) -> str: s = v.strip().upper() if s in ('NO CALIFICA', 'NO INTERESA', 'NO LE GUSTO TANTO', 'PERDIDA') or s.startswith('NO PERFILADO'): return 'DESCARTADO' if s in ('APARTADO', 'APARTÓ', 'APARTO', 'NEGOCIACION'): return 'CALIFICADO' if s in ('VINO A CITA', 'ASISTIÓ', 'VISITO', 'VISITÓ'): return 'AGENDA CITA' return 'CONTACTADO' # SEGUIMIENTO, DEFINICION, ABIERTO, PERFILADO, LE GUSTO... ORIGEN_MAP = [ (r'FACE|FAN PAGE', 'Facebook'), (r'INSTAGRAM', 'Instagram'), (r'BROKER|EXTERNO', 'Broker'), (r'MARATON|LISTADO|RE ?CONTACTO|BASE DE DATOS', 'Base de datos'), (r'PROP|CAMBACEO', 'Prospección'), (r'WHATS ?APP|WZP', 'WhatsApp'), (r'INTERNET|WEB', 'Web'), ] def norm_origen(v: str) -> str | None: s = v.strip().upper() if s in ('', '-'): return None for patron, label in ORIGEN_MAP: if re.search(patron, s): return label return 'Otro' # MIS REDES, REDES SOCIALES, PASE... MEDIO_MAP = [ (r'^MESA( DE)? CONTROL$', 'Mesa de control'), (r'WZP|WHATS ?APP', 'WhatsApp'), (r'^LLAMADA$', 'Llamada'), (r'BROKER', 'Broker'), (r'PASE DE GUARDIA|SEGUIMIENTO DE UN PASE|^PASE$', 'Pase de guardia'), (r'PROSPECCI[ÓO]N', 'Prospección asesor'), (r'MIS REDES', 'Mis redes'), (r'FAN PAGE', 'Fan page'), (r'CAMBACEO', 'Cambaceo'), ] def norm_medio(v: str) -> str | None: s = re.sub(r'\s+', ' ', v.strip()) if s == '': return None for patron, label in MEDIO_MAP: if re.search(patron, s.upper()): return label return s.capitalize() def norm_edad(v: str) -> int | None: s = str(v).strip() if s == '': return None m = re.match(r'^(\d{2})\s*[-aA]\s*(\d{2})', s) # rangos '30-40', '40-30' → punto medio if m: return (int(m.group(1)) + int(m.group(2))) // 2 m = re.match(r'^(\d{2})', s) # '28 años' if m: edad = int(m.group(1)) return edad if 15 <= edad <= 120 else None return None def norm_estado_civil(v: str) -> str | None: s = v.strip().upper() if s.startswith('SOLTER') or s in ('SLTERA',): return 'Soltero(a)' if s.startswith('CASADO'): return 'Casado(a)' if s in ('UL', 'EN PAREJA', 'UNION LIBRE', 'UNIÓN LIBRE'): return 'Unión libre' if s.startswith('DIVORCIAD'): return 'Divorciado(a)' if s.startswith('VIUD'): return 'Viudo(a)' return None # NO DIJO, vacío def norm_tipo_empleo(v: str) -> str | None: s = v.strip().upper() if s in ('EMPLEADO', 'ASALARIADO'): return 'Asalariado' if s == 'INDEPENDIENTE': return 'Independiente' return None # N/A, edades coladas, vacío def norm_credito(v: str) -> str | None: s = v.strip().upper() if 'COFINAVIT' in s: return 'Cofinavit' if 'APOYO INFONAVIT' in s: return 'Apoyo Infonavit' if 'INFO' in s: return 'Infonavit' if 'FOVISS' in s: return 'Fovissste' if s in ('CONTADO', 'RECURSO PROPIO'): return 'Contado' if 'BANC' in s or 'HIPOTEC' in s or 'FIDEICOMISO' in s or 'EFECTIVO' in s: return 'Bancario' return None # NO, SI, BAJO, ?, EMPLEADO, vacío def norm_economia(v: str) -> str | None: s = v.strip().upper() if s in ('MIXTA', 'MEXICANA/MEXICANA'): return 'Mixta' if 'AMERICANA' in s or 'AMARICANA' in s: return 'Economía americana' if s in ('MEXICANA', 'MEXICANO', 'NACIONAL', 'INDEXADA'): return 'Economía mexicana' return None def norm_califica(v: str) -> str | None: s = v.strip().upper() if s.startswith('SI') or s.startswith('SÍ') or 'INFORMÓ QUE SI' in s: return 'Sí' if s.startswith('NO') or 'BURO' in s or 'BURÓ' in s: return 'No' if s in ('PENDIENTE', 'POR DEFINIR', '?', '??'): return 'Por confirmar' return None RANGO_SALARIAL_OMITIR = {'NO QUISO DECIR', '?', 'JUBIALDO'} def norm_rango(v: str) -> str | None: s = re.sub(r'\s+', ' ', str(v).strip()) if s == '' or s.upper().startswith('NO QUISO') or s.upper() in RANGO_SALARIAL_OMITIR: return None return s def norm_lo_quiere(v: str) -> str | None: s = v.strip().upper() if s.startswith('INVERSION') or s.startswith('INVERSIÓN'): return 'Inversión' if s.startswith('VIVIR'): return 'Vivir' return None # ---------------- Parseo ---------------- def parsear(path: str) -> list[dict]: ws = openpyxl.load_workbook(path, data_only=True)['AGOSTO'] registros = [] for row in ws.iter_rows(min_row=2, values_only=True): if row[3] is None or str(row[3]).strip() == '': continue fecha = row[1] observaciones = str(row[7] or '').strip() email = str(row[11] or '').strip().lower() if email and not re.match(r'^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$', email): # Monday rechaza correos no ASCII o texto libre ('SIN AFLUENCIA'): # se preserva en Notas y el item queda sin Email observaciones = f'Correo original: {email}. {observaciones}'.strip() email = '' registros.append({ 'desarrollo': norm_desarrollo(str(row[0] or '')), 'fecha': fecha.strftime('%Y-%m-%d') if hasattr(fecha, 'strftime') else None, 'nombre': re.sub(r'\s+', ' ', str(row[3]).strip()), 'telefono': telefono_limpio(str(row[4] or '')), 'vendedor': re.sub(r'\s+', ' ', str(row[5] or '').strip()), 'estatusOriginal': re.sub(r'\s+', ' ', str(row[6] or '').strip()), 'observaciones': observaciones, 'medioContacto': norm_medio(str(row[9] or '')), 'origen': norm_origen(str(row[10] or '')), 'email': email or None, 'edad': norm_edad(str(row[12] or '')), 'estadoCivil': norm_estado_civil(str(row[13] or '')), 'tipoEmpleo': norm_tipo_empleo(str(row[14] or '')), 'ocupacion': str(row[15] or '').strip() or None, 'rangoSalarial': norm_rango(str(row[16] or '')), 'credito': norm_credito(str(row[17] or '')), 'tipoEconomia': norm_economia(str(row[18] or '')), 'califica': norm_califica(str(row[19] or '')), 'loQuierePara': norm_lo_quiere(str(row[20] or '')), 'estado': norm_estado(str(row[6] or '')), }) registros.sort(key=lambda r: r['fecha'] or '9999') return registros # ---------------- Importación ---------------- def valor_por_tipo(col: dict, crudo) -> object: t = col['type'] if t == 'status': return {'label': str(crudo)} if t == 'numbers': return float(crudo) if not isinstance(crudo, int) else crudo if t == 'date': return {'date': str(crudo)} if t == 'email': return {'email': str(crudo), 'text': str(crudo)} if t == 'phone': return {'phone': str(crudo), 'countryShortName': 'MX'} return str(crudo) def crear_lead(cols: dict[str, dict], r: dict) -> str: notas_partes = [] if r['vendedor']: notas_partes.append(f"Vendedor: {r['vendedor']}") if r['estatusOriginal']: notas_partes.append(f"Estatus ago-2026: {r['estatusOriginal']}") if r['observaciones']: notas_partes.append(r['observaciones']) campos = { 'Estado': r['estado'], 'Teléfono': r['telefono'] or None, 'Email': r['email'], 'Origen': r['origen'], 'Desarrollo': r['desarrollo'] or None, 'Fecha': r['fecha'], 'Medio de Contacto': r['medioContacto'], 'Edad': r['edad'], 'Estado Civil': r['estadoCivil'], 'Tipo de Empleo': r['tipoEmpleo'], 'Ocupación': r['ocupacion'], 'Rango Salarial': r['rangoSalarial'], 'Crédito': r['credito'], 'Tipo de Economía': r['tipoEconomia'], 'Califica': r['califica'], 'Lo Quiere Para': r['loQuierePara'], 'Notas': ' · '.join(notas_partes)[:1800] or None, } values = {} for titulo, crudo in campos.items(): if crudo is None or crudo == '' or titulo not in cols: continue values[cols[titulo]['id']] = valor_por_tipo(cols[titulo], crudo) data = gql( 'mutation ($b: ID!, $n: String!, $v: JSON!) { create_item(board_id: $b, item_name: $n, column_values: $v) { id } }', {'b': BOARD_LEADS, 'n': r['nombre'], 'v': json.dumps(values)}, ) return data['create_item']['id'] def main() -> None: apply = '--apply' in sys.argv path = next(a for a in sys.argv[1:] if not a.startswith('--')) cols = columnas_board() print(f"Columnas del board: {len(cols)}") # Labels que el historial necesita y aún no existen faltantes = {'Desarrollo': ['SOLÉ'], 'Origen': ['Instagram', 'Broker', 'Base de datos', 'Prospección']} for titulo, labels in faltantes.items(): actuales = labels_status(cols[titulo]) for label in labels: if label not in actuales: if apply: sembrar_label(cols[titulo], label) print(f"Label sembrado en '{titulo}': {label}") else: print(f"[dry-run] Faltaría label en '{titulo}': {label}") registros = parsear(path) existentes = telefonos_existentes(cols['Teléfono']['id']) print(f"Teléfonos ya en el board: {len(existentes)}") vistos: set[str] = set() a_importar, duplicados = [], [] for r in registros: clave = clave_telefono(r['telefono']) if clave and (clave in existentes or clave in vistos): duplicados.append(r) continue if clave: vistos.add(clave) a_importar.append(r) print(f"\nRegistros en archivo: {len(registros)}") print(f"A importar: {len(a_importar)} · duplicados omitidos: {len(duplicados)}") for d in duplicados: print(f" dup: {d['nombre'][:40]} ({d['telefono']})") from collections import Counter for campo in ['desarrollo', 'estado', 'origen', 'estadoCivil', 'tipoEmpleo', 'credito', 'tipoEconomia', 'califica', 'medioContacto', 'loQuierePara']: c = Counter(r[campo] or '(vacío)' for r in a_importar) print(f"\n{campo}: {dict(c.most_common())}") if not apply: print('\nDRY-RUN: nada se escribió. Corre con --apply para importar.') return # Desregistrar el webhook create_item para que el historial NO dispare # round-robin ni oportunidades; se recrea al final con la misma URL. wh = webhook_create_item() wh_url = None if wh: wh_url = json.loads(wh['config']).get('url') if wh.get('config') else None # Monday devuelve la URL en config solo a veces; si no, la sabemos: wh_url = wh_url or 'https://frasa.consultoria-as.com/api/webhooks/monday' borrar_webhook(wh['id']) print(f"\nWebhook create_item desregistrado (id {wh['id']}); se recreará al final → {wh_url}") importados, errores = 0, 0 try: for r in a_importar: try: item_id = crear_lead(cols, r) importados += 1 print(f"OK {importados}/{len(a_importar)} {r['nombre'][:45]} → {item_id}") except Exception as e: errores += 1 print(f"ERROR {r['nombre'][:45]}: {e}") time.sleep(0.4) finally: if wh_url: nuevo_id = crear_webhook(wh_url) print(f"\nWebhook create_item recreado (id {nuevo_id})") print(f"\nImportados: {importados} · errores: {errores}") if __name__ == '__main__': main()