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:
206
api-gateway/scripts/migrar-afluencias-a-oportunidades.py
Normal file
206
api-gateway/scripts/migrar-afluencias-a-oportunidades.py
Normal 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()
|
||||
Reference in New Issue
Block a user