Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
+339
View File
@@ -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`.