- bucketSalario: el texto libre del rango salarial ('$80k', '4,500 DLLS',
'50-80 MIL') se agrupa en rangos por moneda para que la grafica sea
legible; valores sin monto se omiten.
- scripts/importar-afluencia-agosto.py: importa el historial de
afluencia al tablero Leads con normalizacion de catalogos, dedup por
telefono y desregistro temporal del webhook create_item (para no
disparar round-robin ni oportunidades con datos historicos).
449 lines
17 KiB
Python
449 lines
17 KiB
Python
#!/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 <ruta.xlsx> # dry-run: solo reporta
|
|
python3 importar-afluencia-agosto.py <ruta.xlsx> --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()
|