Files
MP-Manager/docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
T
2026-05-30 14:31:19 -06:00

19 KiB

Playbook — Enlace y regularización de oportunidades Sucursal ↔ Marca

Metodología para completar el campo de enlace "ID Oportunidad Sucursal" (opportunity.id_oportunidad_sucursal) en oportunidades de Marca (Monte Providencia) y para regularizar opps que existen de un solo lado del puente Sucursal↔Marca.

Documenta la operación del 2026-05-29 (10 opps de Marca sin enlace → 9 backfilled + 1 regularizada de extremo a extremo) como caso de estudio, y destila el método para casos futuros. Cubre tres niveles: programación, business intelligence y análisis de perspectivas (el porqué de las decisiones y el loop con el usuario).

Es el complemento "a nivel oportunidad" de PLAYBOOK_DESCUADRE.md (que cubre el descuadre de conteo y los fantasmas de contacto). Léelo junto con AGENTS.md y las memorias enlazadas al final.


0. TL;DR — el reflejo correcto

Cuando una opp de Marca tiene "ID Oportunidad Sucursal" vacío:

  1. No inventes el valor ni borres la opp de entrada. El campo guarda el id nativo de la opp de la sucursal de origen; es la clave de idempotencia que usa n8n para decidir UPDATE vs CREATE. Llenarlo mal duplica; borrar a ciegas orfana.
  2. El puente es el contacto. La opp de Marca casi nunca trae el nombre de la sucursal, pero su contacto sí trae id_contacto_sucursal poblado. Ese campo te lleva al contacto de la sucursal, y de ahí a su opp → cuyo id es el valor que buscas.
  3. Verifica por dos métodos independientes antes de escribir, y contra la API en vivo (no solo el cache), leyendo la clave correcta (fieldValue, ver §3.4).
  4. Si no existe la opp espejo en la sucursal, el problema no es de enlace: es una opp que vive de un solo lado. Deja que la automatización (n8n) la cree antes de hacerlo a mano (§4.2).

Y siempre: dry-run → confirmación del usuario → piloto de 1 → batch, con run_id y snapshot para rollback (memoria feedback_dry_run_protocol).


1. El campo de enlace y por qué importa

Campo fieldKey Dónde Qué guarda
ID Oportunidad Sucursal opportunity.id_oportunidad_sucursal En Marca: el id de la opp de la sucursal de origen. En sucursal: su propio id (self-link). El enlace 1-a-1 opp Marca ↔ opp sucursal.
ID Contacto Sucursal contact.id_contacto_sucursal En Marca: el id del contacto de la sucursal. En sucursal: su propio id. El puente a nivel contacto (memorias opp_self_id_in_branch_field, contact_self_id_in_branch_field).

Los IDs de custom field varían por sucursal — nunca los hardcodees. En esta corrida, en Marca, resultaron ser j029pu3OU02ATNccJR6l (opp link) y E6lI9ykWhqpj7Pmi7Qd3 (contact bridge), pero eso es incidental a Marca. Resuélvelos por nombre (§3.1).

Por qué importa el enlace: el workflow n8n de sync de oportunidades (Cfgwp0bOtDW8zuKW, memoria n8n_opp_sync_match) hace match 100% por este campo (con fallback por nombre) para decidir si hace UPDATE o CREATE en Marca. Una opp de Marca sin el campo es una opp "huérfana de enlace": el próximo webhook puede duplicarla en vez de actualizarla.


2. Nivel Business Intelligence

2.1 El modelo del puente (contacto → opp)

opp Marca  ──contact_id──►  contacto Marca  ──[id_contacto_sucursal]──►  contacto Sucursal
                                                                               │
                                                                          (sus opps)
                                                                               ▼
                                                            opp Sucursal  ── su id == valor a escribir
                                                                               en "ID Oportunidad Sucursal" de la opp Marca

La opp de Marca rara vez sabe de qué sucursal viene (el CF de ciudad es la localidad del lead, no la cuenta). Pero el contacto sí: id_contacto_sucursal es el ancla. De ahí, las opps de ese contacto en su sucursal dan el id que falta.

2.2 Dos sub-casos, dos acciones distintas

Tras resolver el contacto sucursal, agrupa por cardinalidad M (opps Marca del contacto) vs N (opps sucursal del contacto):

Caso Qué significa Acción
M=1, N=1 (match_unique) Enlace 1-a-1 limpio Backfill del CF (§3.3)
M=N>1 Multi-empeño; empareja por (nombre,monto)→nombre Backfill si el emparejamiento es inequívoco; si no, review_ambiguous
M≠N Cardinalidades no calzan review_count_mismatch — no tocar, revisar a mano
N=0 (branch_contact_no_opps) El contacto existe en sucursal pero sin opp No es problema de enlace → regularizar (§4.2)

El caso branch_contact_no_opps es la trampa: parece "falta el enlace" pero en realidad falta la opp de origen. Llenar el campo es imposible (no hay id que apuntar). Ver §4.2.

2.3 ¿La opp de Marca es real o un artefacto?

Heurística (memoria automation_artifact_opportunities):

  • Artefacto de automatización: status=open, $0, y createdAt ≈ updatedAt ≈ lastStatusChangeAt (creada segundos después del contacto, nunca tocada). La crea la automatización al replicar un contacto, aunque la sucursal no tenga opp.
  • Opp real: tiene movimiento, valor monetario, o status won/lost.

Esto decide la regularización: un artefacto sin espejo en sucursal se borra; una opp real se conserva y se le crea/empareja el espejo.

2.4 Origen del lead: attributionSource

El source del contacto suele venir null. La verdad del origen está en attributionSource:

  • {"medium": "manual", "sessionSource": "CRM UI"}dado de alta a mano por un agente en la sucursal (no formulario, no Facebook). Por eso source: null.
  • Ausencia de attribution + source: "Formulario - Sitio Web" ⇒ lead digital.

En el caso real, esto explicó por qué el contacto existía sin opp: el agente capturó el contacto en la UI pero no le creó oportunidad.

Regla de negocio (AGENTS.md): contactos son bidireccionales Marca↔Sucursal; opps son unidireccionales Sucursal→Marca (la sucursal es la fuente de verdad). Por eso la regularización crea la opp en la sucursal, no en Marca, y deja que cascade.


3. Nivel programación — el toolkit

3.1 Resolver nombres de custom field (id ↔ nombre)

Los nombres viven en la tabla object_schemas del cache (poblada por sync), con columnas location_id, object_key, field_id, field_name, field_key, field_type:

import sqlite3; from paths import DB_PATH
c = sqlite3.connect(DB_PATH); c.row_factory = sqlite3.Row
r = c.execute("SELECT object_key, field_name, field_key, field_type "
              "FROM object_schemas WHERE location_id=? AND field_id=?",
              (loc, field_id)).fetchone()

Para scripts nuevos, prefiere scripts/common.py::SchemaResolver + FIELD_ALIASES (resuelve por alias estable, no por id). Verifica también el field_type: los SINGLE_OPTIONS exigen que el valor sea una label válida del picklist (memoria custom_fields_picklist_alignment); los TEXT aceptan texto libre.

3.2 El script de backfill (doble método de match)

scripts/backfill_opp_sucursal_link.py (memoria backfill_opp_sucursal_link_script):

  • Solo escribe en Marca, idempotente (salta CF ya válido de 20 chars), snapshot en generated/migrations/, audita en script_audit por run_id (reversible).
  • Importante: matchea contacto Marca↔Sucursal con common.match_contacts (phone+nombre, memoria matching_rules), independiente del campo id_contacto_sucursal. Esto lo hace un verificador cruzado: si tu investigación manual (vía id_contacto_sucursal) y el script (vía phone+nombre) coinciden, tienes dos métodos independientes de acuerdo → confianza alta.
  • Uso programático (permite acotar a un set de opps y correr piloto→batch):
from scripts import backfill_opp_sucursal_link as bf
bf.run_match(opp_ids=[...], dry_run=True)                       # dry-run
bf.run_match(opp_ids=[UNA], dry_run=False, run_id="...")        # piloto de 1
bf.run_match(opp_ids=[LAS_OTRAS], dry_run=False, run_id="...")  # batch (mismo run_id)

Estados del plan: match_unique (aplicable), review_ambiguous, review_count_mismatch, branch_contact_no_opps, no_branch_contact, phone_collision, no_data.

3.3 Crear / borrar / actualizar opps (firmas y payloads)

ghl_client.py (vía sync_engine.ghl_client):

gc.get_opportunity(token, opp_id)                       # GET /opportunities/{id}
gc.create_opportunity(token, opp_data)                  # POST /opportunities/
gc.update_opportunity(token, opp_id, opp_data)          # PUT  /opportunities/{id}
gc.delete_opportunity(token, opp_id, location_id)       # DELETE (params: locationId)
gc._request("GET", "/opportunities/search", token, params={...})  # ver §3.4

Payload de creación (probado; formato de create_opportunities_for_contacts_without_any.py):

{
  "locationId": LOC, "name": "...", "status": "open", "monetaryValue": 0,
  "pipelineId": "...", "pipelineStageId": "...", "contactId": "...",
  "customFields": [ {"id": cf_id, "value": "..."} ]      # CREATE usa {id, value}
}

Payload de update de CF: el backfill usa {"id", "key", "field_value"}; el formato {"id", "value"} también funciona. Para opps nuevas en sucursal, setea su id_oportunidad_sucursal = su propio id tras crearla (self-link), aunque n8n tiene fallback de autoenlace (memoria n8n_workflows_v2_hardened).

3.4 Gotchas de la API GHL (los que costaron tiempo)

  • get_opportunity devuelve los CF bajo la clave fieldValue — NO fieldValueString (que sí usa el endpoint de búsqueda) ni value. Leer la clave equivocada da un falso negativo al verificar post-write. En la corrida real, el piloto reportó applied=1 pero la primera verificación mostró None solo porque buscaba fieldValueString; el dato sí estaba bajo fieldValue.
  • GET /opportunities/search usa snake_case: location_id, contact_id (memoria ghl_opportunity_search_quirks). La respuesta trae customFields pero no tags.
  • No uses paginación por offset ni GET /opportunities/ (AGENTS.md); para buscar por contacto, GET /opportunities/search?location_id=..&contact_id=...
  • Tokens en scripts ad-hoc: sync_engine.parse_accounts_csv(), no main.TOKENS_CACHE (vacío fuera del startup de FastAPI — ver PLAYBOOK_DESCUADRE §3.4).

3.5 Reversibilidad (obligatoria antes de mutar)

  • script_audit.create_run(run_id, script_name, arguments, locations)record_change(run_id, loc, "opportunity", obj_id, field_id, field_name, old, new)mark_change(change_id, "applied")update_run_status(run_id, "completed").
  • Para DELETE: snapshot del objeto completo a generated/migrations/ ANTES de borrar (permite recrearlo), y registra el old_value con la opp entera.
  • Todo lo aplicado con run_id es reversible desde el dashboard.

4. Caso de estudio — la corrida del 2026-05-29

Estado inicial: 10 opps de Marca con "ID Oportunidad Sucursal" vacío (CSV comparativa_opps_missing_id_field).

4.1 Las 9 con espejo — backfill

  1. Para cada opp Marca → su contacto → id_contacto_sucursal → contacto sucursal (phone coincidió 100% en las 10) → su única opp → ese id.
  2. Verificación cruzada: el dry-run de backfill_opp_sucursal_link.py (que matchea por phone+nombre, sin usar id_contacto_sucursal) devolvió exactamente los mismos 9 ids → dos métodos independientes de acuerdo.
  3. Piloto de 1 (Cosme, la única won) → aplicar → verificar en vivo (tropezón del fieldValue, §3.4, resuelto) → batch de 8 bajo el mismo run_id backfill-opp-link-20260529. 9/9 verificadas en vivo.

4.2 La 1 sin espejo — Jorge Rosas (regularización extremo a extremo)

Síntoma: branch_contact_no_opps. El contacto existía en Tampico, pero con 0 opps.

Forense:

  • attributionSource: {medium: manual, sessionSource: CRM UI} ⇒ el contacto se creó a mano en la UI de Tampico (20:49:57Z), sin opp.
  • n8n [1604] lo replicó a Marca 4 s después (20:50:01Z).
  • Una opp se auto-creó en Marca a las 20:50:05Z (open, $0, createdAt == updatedAt == lastStatusChangeAt) ⇒ artefacto de automatización sin espejo en la sucursal.
  • Dato curioso registrado: "Jorge Rosas" es también el nombre de un agente de Tampico (aparece en "Persona que atendió"); el contacto-cliente es entidad distinta (su teléfono).

Acción (instruida por el usuario, paso a paso):

  1. Borrar el artefacto de Marca (snapshot + delete_opportunity, audit run_id=jorge-rosas-regularize-20260529).
  2. Crear la opp en Tampico (la fuente de verdad), pipeline Standar (ep1d4VpzRezVqWayFbBf), etapa PROSPECTO NUEVO, mapeando del contacto: Modalidad de Empeño = "Sin Dejarlo (GPS)", Vehículo = "hyundai creta 2017", Fuente de Prospecto = "SUCURSAL"; luego self-link de su id_oportunidad_sucursal.
  3. Esperar 1 minuto a que n8n (Sucursal→Marca) replique solo.
  4. n8n creó la opp en Marca (~35 s después). Verificar homologación 100%: name, status, $, Modalidad, Vehículo, Fuente y el enlace (ID Oportunidad Sucursal de Marca = id de la opp de Tampico) — todo coincidió, sin creación manual.

Estado final: 10/10 resueltas (9 backfill + 1 regularizada), y de paso se validó en producción que el workflow n8n Sucursal→Marca funciona end-to-end.


5. Análisis de perspectivas — por qué funcionó

  • Verificación cruzada por dos métodos. El enlace se confirmó por id_contacto_sucursal (investigación) y por match_contacts phone+nombre (el script), de forma independiente. Coincidir al 100% por dos caminos distintos es lo que justificó aplicar sin dudar.
  • Vivo, no cache — y la clave correcta. Toda verificación post-write fue contra GET /opportunities/{id} en vivo. El falso negativo del fieldValue enseñó que "verificar" no basta: hay que leer el campo correcto. Un applied=1 no es prueba; el read en vivo sí (cuando lees bien).
  • Probar la automatización antes de hacerlo a mano. En vez de crear la opp de Marca manualmente, se creó la de sucursal y se le dio 1 minuto a n8n. Resultado doble: se evitó una posible duplicación (n8n + manual) y se validó el workflow en producción. La mano era el plan B, no el A.
  • Orden de operaciones pensado. Borrar el artefacto de Marca antes de crear la opp en sucursal garantizó que n8n hiciera un CREATE limpio (no un UPDATE contra una opp basura ni un duplicado).
  • Reversibilidad primero. DELETE + CREATE quedaron auditados con snapshot del objeto completo. Un borrado destructivo se volvió reversible.
  • El usuario como timón; el rol técnico, traducir y advertir. El usuario fijó la estrategia (borrar artefacto, crear en sucursal, probar n8n) con autorización incremental: "adelante" para el dry-run, "sí adelante" para piloto→batch, e instrucciones explícitas paso a paso para la regularización. Cada nivel de mutación esperó su confirmación; la autorización de un paso no se extendió al siguiente más riesgoso.
  • Nombrar cuentas, no ids. Cada location_id se reportó con su nombre (Tampico, Eugenia, …): el usuario opera por nombre (memoria name_account_with_location_id).
  • Defecto de dato ≠ defecto de número. El caso Jorge Rosas "cuadraba" como una opp en Marca, pero era un artefacto sin sustento. Se resolvió por integridad (que la opp exista donde debe y esté enlazada), no por mover un contador.

6. Checklist (pégalo y síguelo)

  1. Resuelve el field_id de "ID Oportunidad Sucursal" por nombre (§3.1), no lo hardcodees.
  2. Para cada opp Marca con el CF vacío: opp → contacto → id_contacto_sucursal → contacto sucursal → sus opps. Anota M vs N (§2.2).
  3. Corre el dry-run de backfill_opp_sucursal_link.py acotado a esas opps y cruza sus match_unique con tu resolución manual. Deben coincidir.
  4. Piloto de 1 → verifica en vivo (get_opportunity, clave fieldValue) → batch bajo el mismo run_id.
  5. Los branch_contact_no_opps: no es enlace, es opp faltante. Decide artefacto vs real (§2.3) con el usuario.
    • Artefacto sin espejo → borrar de Marca (snapshot+audit) + crear la opp en la sucursal + esperar a n8n + verificar homología 100%.
    • Opp real → crear/emparejar el espejo según corresponda.
  6. Re-verificación final en vivo de cada opp tocada (campo == id de opp sucursal esperado).
  7. Si quedaron opps de sucursal nuevas, recuerda el fill_opp_id_oportunidad_sucursal.py (self-link del lado sucursal) para mantener la consistencia que habilita el sync multi-opp.

7. Síntomas → causa probable

Síntoma Causa probable
Opp de Marca con "ID Oportunidad Sucursal" vacío y contacto con id_contacto_sucursal Opp creada antes de que n8n poblara el enlace → backfill.
branch_contact_no_opps (contacto sucursal sin opp) El agente capturó el contacto sin crear opp → regularizar (crear opp en sucursal).
Opp de Marca open/$0 con createdAt == updatedAt Artefacto de automatización (se auto-creó al replicar el contacto).
applied=1 pero el read muestra el CF vacío Estás leyendo fieldValueString/value; get_opportunity usa fieldValue.
Tras crear opp en sucursal, no aparece en Marca Espera ~1 min a n8n; si no, revisa executions del workflow de opps (ver PLAYBOOK_DESCUADRE §3.3).
Contacto con source: null pero datos completos Alta manual en CRM UI (attributionSource.medium = manual).
Dos opps del mismo contacto, una en Marca falta Multi-empeño no replicado (memoria opp_multiplicity_replication_gap).

Referencias

  • PLAYBOOK_DESCUADRE.md · AGENTS.md · CLAUDE.md · docs/GUIA_AGENTICA.md
  • Código: scripts/backfill_opp_sucursal_link.py, scripts/create_opportunities_for_contacts_without_any.py, scripts/common.py (match_contacts, SchemaResolver), script_audit.py, ghl_client.py, paths.py (MIGRATIONS_DIR).
  • Memorias: backfill_opp_sucursal_link_script, opp_self_id_in_branch_field, contact_self_id_in_branch_field, automation_artifact_opportunities, opp_multiplicity_replication_gap, n8n_opp_sync_match, n8n_workflows_v2_hardened, feedback_dry_run_protocol, matching_rules, ghl_opportunity_search_quirks, name_account_with_location_id, create_duplicate_phone_contact_marca.