# 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](PLAYBOOK_DESCUADRE.md) > (que cubre el descuadre de conteo y los fantasmas de contacto). Léelo junto con > [AGENTS.md](../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`: ```python 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): ```python 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`): ```python 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`): ```python { "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](PLAYBOOK_DESCUADRE.md) · [AGENTS.md](../AGENTS.md) · [CLAUDE.md](../CLAUDE.md) · [docs/GUIA_AGENTICA.md](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`.