# 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`.
