refactor: las afluencias viven en Oportunidades (no en Leads)

- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia)
  con todo el perfil (desarrollo, modelo, estado civil, credito, etc.)
  en las columnas del board; dedup por telefono contra Oportunidades;
  asignacion al capturista via columna Asesor o round-robin del equipo.
- Round-robin, escalado e inactividad operan sobre Oportunidades (solo
  etapa Afluencia sin asesor entra al reparto).
- Acciones del vendedor (contactado/cita/descartar) escriben directo en
  la oportunidad; descarte = Perdido + Motivo No Compra.
- Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en
  Afluencia; misOportunidades.porEtapa ya es real (columna Asesor).
- El picker del alta busca en Leads (historico) Y Oportunidades, con
  insignia de origen; moverAAparto tolera ambos ids.
- Lectura de status con value null = vacio (quirk del default Monday).
- Script de migracion del historial de agosto Leads -> Oportunidades.
This commit is contained in:
2026-09-04 04:46:34 +00:00
parent d4fc9ee892
commit 845f314560
26 changed files with 1496 additions and 985 deletions

View File

@@ -0,0 +1,206 @@
#!/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()