Primer commit
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user