diff --git a/api-gateway/scripts/importar-afluencia-agosto.py b/api-gateway/scripts/importar-afluencia-agosto.py new file mode 100644 index 0000000..3263041 --- /dev/null +++ b/api-gateway/scripts/importar-afluencia-agosto.py @@ -0,0 +1,448 @@ +#!/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() diff --git a/api-gateway/src/services/reportes.service.test.ts b/api-gateway/src/services/reportes.service.test.ts index 60cb166..a006cda 100644 --- a/api-gateway/src/services/reportes.service.test.ts +++ b/api-gateway/src/services/reportes.service.test.ts @@ -230,7 +230,7 @@ describe('construirResumen', () => { expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 }) expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 }) expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 }) - expect(r.leads.perfil.porRangoSalarial).toEqual({ '20-30 mil': 1 }) + expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k // Dimensiones sin ningún dato van como objeto vacío expect(r.leads.perfil.porModelo).toEqual({}) expect(r.leads.perfil.porTipoEmpleo).toEqual({}) @@ -239,6 +239,35 @@ describe('construirResumen', () => { expect(r.leads.perfil.porLoQuierePara).toEqual({}) }) + it('perfil: rango salarial de texto libre se agrupa en buckets por moneda', () => { + const r = construirResumen( + { + ...DATOS_BASE, + leads: [ + lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN + lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN + lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN + lead('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k) + lead('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500) + lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD + lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000) + lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta + lead('9', { rangoSalarial: '' }), // vacío: se salta + ], + }, + AHORA, + ) + expect(r.leads.perfil.porRangoSalarial).toEqual({ + 'Hasta $30k MXN': 1, + '$30k–$60k MXN': 1, + '$60k–$100k MXN': 1, + 'Más de $100k MXN': 1, + 'Hasta $3k USD': 1, + '$3k–$5k USD': 1, + 'Más de $5k USD': 1, + }) + }) + it('perfil: buckets de edad y salto de edad null', () => { const r = construirResumen( { diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index 8895ab3..830a21c 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -111,6 +111,44 @@ function bucketEdad(edad: number): string { return '55+' } +/** + * Convierte el rango salarial de texto libre ('$80k', '4,500 DLLS', '50-80 + * MIL', '100,000.00-120,000.00') a un bucket graficable por moneda; los rangos + * se promedian y K/MIL multiplica miles. null = sin monto interpretable + * ('NO QUISO DECIR', '?', vacío) → se salta de la gráfica. + */ +export function bucketSalario(texto: string): string | null { + const t = texto.toUpperCase() + const tokens = [...t.matchAll(/\d[\d.,]*/g)].map((m) => m[0]) + if (tokens.length === 0) return null + const montos = tokens + .map((tok) => { + // separador seguido de 3 dígitos = miles; el resto se trata como decimal + let s = tok + let prev = '' + while (prev !== s) { + prev = s + s = s.replace(/(\d),(\d{3})(?!\d)/g, '$1$2') + } + const n = Number.parseFloat(s.replace(',', '.')) + return Number.isFinite(n) ? n : null + }) + .filter((n): n is number => n !== null) + if (montos.length === 0) return null + const enMiles = /\d\s*(K|MIL)\b/.test(t) && montos.every((n) => n < 1000) + const ajustados = enMiles ? montos.map((n) => n * 1000) : montos + const monto = ajustados.reduce((a, b) => a + b, 0) / ajustados.length + if (/DLLS|DLS|USD/.test(t)) { + if (monto < 3000) return 'Hasta $3k USD' + if (monto < 5000) return '$3k–$5k USD' + return 'Más de $5k USD' + } + if (monto < 30000) return 'Hasta $30k MXN' + if (monto < 60000) return '$30k–$60k MXN' + if (monto < 100000) return '$60k–$100k MXN' + return 'Más de $100k MXN' +} + /** Inicio del día local de `ahora` (para "nuevos hoy") */ function inicioDelDia(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()) @@ -195,7 +233,11 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')), porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')), porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')), - porRangoSalarial: contarPorNoVacios(datos.leads.map((l) => l.rangoSalarial ?? '')), + porRangoSalarial: contarPorNoVacios( + datos.leads + .map((l) => bucketSalario(l.rangoSalarial ?? '')) + .filter((b): b is string => b !== null), + ), porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')), porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')), porEdad: contarPorNoVacios(