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
@@ -0,0 +1,78 @@
---
id: CASE-2026-05-29-backfill-cf-vehiculo-temixco-marca
fecha: 2026-05-29
categoria: custom_field | cascada_n8n
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "yjqKxoO02rsdwdJZSPmD (85950 - MP - Temixco)"]
run_ids: ["4b26f163-f87c-48c5-be53-462b2e6f53da"]
snapshots: []
status: parcial
memorias: [[sucursal_to_marca_cf_drop_on_create]], [[positive_descuadre_stale_cache]], [[n8n_realtime_replication]]
playbooks: [docs/PLAYBOOK_DESCUADRE.md]
---
## TRIGGERS
- `contacto en Marca sin datos de vehículo`, `customFields count: 0 en Marca`, `réplica Sucursal→Marca sin custom fields`
- `Sincronización Sucursal` (source) + tag `sincronizado-sucursal` con CFs vacíos
- `Cristhian laura ramirez juarez`, `Hugo Lopez`, `Temixco`, `+527772582548`, `+527772162523`
- `nombres de custom field idénticos entre sucursal y Marca` (descarta name-mismatch)
- `Export_Contacts_leads` CSV con columnas de vehículo vacías
## SÍNTOMA
CSV export de Marca: 2 leads de Temixco (`Cristhian` `U9DWipeW9XhQTZZMzFl9`, `Hugo` `bUNqMZaLI1QCn4yQM6qT`)
llegaron a Marca **sin** Versión/Marca/Año de vehículo, Modalidad, Fuente, Sucursal. Edgar (Tampico) sí llegó completo.
## DIAGNÓSTICO (read-only)
1. `mcp get_contact` (cache) Marca → `custom_fields: []`. Cache SQLite con `synced_at` ANTERIOR a la creación → stale, no concluir aún ([[positive_descuadre_stale_cache]]).
2. `search_contacts` por fragmento de teléfono en sucursales de Morelos (777 = Cuernavaca) → ambos en **Temixco** (`yjqKxoO02rsdwdJZSPmD`) con 6-8 CFs completos.
3. Comparación de nombres de contact CFs Temixco vs Marca vs Tampico (tabla `object_schemas`): **idénticos**. → **DESCARTA** la hipótesis de name-mismatch del workflow [1604] (el análisis estático del jsCode sugería divergencia de nombres; es FALSO).
4. GET en VIVO (no cache) de los 4 contactos vía `ghl_client._request('GET', f'/contacts/{id}', token)`:
- Marca (ambos): `customFields=0`, `dateAdded == dateUpdated` (nunca actualizados).
- Temixco (ambos): 8 CFs; `dateUpdated` ~4h después de creación (= workflow [2004] que añadió CANAL DE ORIGEN + TIENDA).
5. Ejecuciones n8n del workflow Sucursal→Marca (`x4DqZ5FtSc43tdzB`) consultadas, pero el set reciente no cubre la ventana de creación real (2026-05-29 ~22:40/22:48 **UTC**); no se obtuvo el log del momento exacto.
## CAUSA RAÍZ
1. **Confirmada:** la réplica Sucursal→Marca creó el contacto en Marca (39-40 s después del de sucursal) **sin copiar ningún custom field**, y no hubo UPDATE posterior.
2. **NO es** divergencia de nombres de esquema (verificado idénticos).
3. **Probable (confianza media):** carrera de tiempo — el flujo leyó el contacto origen antes de que los CFs del formulario estuvieran disponibles. Es intermitente (Edgar/Tampico sí funcionó por el mismo path). Confirmación pendiente requiere log de ejecución n8n del momento de creación.
## ACCIÓN
Backfill manual de 8 CFs Temixco→Marca (mapeo por nombre → `object_schemas` de Marca), confirmado por el owner.
- `run_id=4b26f163-f87c-48c5-be53-462b2e6f53da` (script_audit, reversible). Script inline (no archivo).
- Patrón: `gc._request('PUT', f'/contacts/{cid}', mtok, json={'customFields':[{'id':marca_field_id,'field_value':val}, ...]})`.
- Orden: piloto Cristhian → verificación live (8 CFs) → Hugo.
## VERIFICACIÓN
Antes: ambos Marca `customFields=0`. Después (GET live): ambos `customFields=8` con valores correctos
(Año 2026/2011, Marca ITALIKA/mercedes benz, Versión DM 200/c280, Modalidad Tradicional (Resguardo),
Fuente SUCURSAL/PROSPECCIÓN, Sucursal "Temixco, Morelos", CANAL DE ORIGEN SUCURSAL, TIENDA TEMIXCO).
## EDGE-CASES / TRAMPAS
- **No confiar en la cache SQLite** para concluir CFs vacíos: `synced_at` puede ser anterior a la creación del contacto. Verificar SIEMPRE en vivo antes de backfillear.
- El análisis estático del jsCode del workflow puede mentir: dio "name-mismatch" como causa, falso. Verificar nombres reales en `object_schemas`.
- PUT contacto usa clave `field_value` (no `value`) y mergea CFs (no reemplaza).
- Edgar (Tampico) se omitió a pedido del owner: ya tenía datos completos (control de que el path SÍ funciona a veces).
## REUTILIZABLE
```python
# Nombre de CF -> field_id de una location (tabla object_schemas)
import sqlite3, paths
conn=sqlite3.connect(paths.DB_PATH); conn.row_factory=sqlite3.Row
nameid={}
for r in conn.execute("SELECT field_id,field_name FROM object_schemas WHERE location_id=? AND object_key='contact'",(loc,)):
nameid.setdefault(r['field_name'], r['field_id'])
# GET live + PUT CFs
import sync_engine; from sync_engine import ghl_client as gc
tok={a['location_id']:a for a in sync_engine.parse_accounts_csv()}[loc]['token']
gc._request('PUT', f'/contacts/{cid}', tok, json={'customFields':[{'id':fid,'field_value':v}]})
```
## PENDIENTES
- **Causa raíz n8n al 100%:** revisar ejecuciones de `x4DqZ5FtSc43tdzB` en la ventana 2026-05-29 ~22:40/22:48 UTC
(paginar más atrás con `/api/v1/executions?workflowId=...&includeData=true`) para confirmar carrera vs gap de lógica.
- **Sistémico:** si es carrera, hay riesgo de más contactos Temixco (u otras sucursales) con CFs vacíos en Marca.
Considerar un audit que liste contactos Marca con `source='Sincronización Sucursal'` y `customFields=0` para barrido masivo.
## ENLACES
- Memorias: [[sucursal_to_marca_cf_drop_on_create]], [[positive_descuadre_stale_cache]], [[n8n_realtime_replication]], [[n8n_2004_canal_origen_complemento]]
- Workflow Sucursal→Marca: `x4DqZ5FtSc43tdzB`; helper `scripts/n8n_workflow_lib.py`
- Caso relacionado: [2026-05-29-n8n-2004-canal-origen-tiempo-real.md](2026-05-29-n8n-2004-canal-origen-tiempo-real.md)
@@ -0,0 +1,63 @@
---
id: CASE-2026-05-29-corrector-baserow-verificador
fecha: 2026-05-29
categoria: config_location
location_ids: ["DB63 Baserow tablas 749/750", "WLPVTRxg7W074dfzBxZL (85956 PLAZA EL SALADO)", "nF1uEaYB3mCK5em9bPn2 (Eugenia, E2E)", "pMPs9M4RaGJvWwfIFVIo (Grand Plaza Toluca, creada)"]
run_ids: ["baserow PATCH/POST 749+750 (8 acciones)"]
snapshots: ["generated/migrations/baserow_mesa_control_fix_749_20260529_224430.json", "generated/migrations/baserow_verificador_fix_750_20260529_224430.json"]
status: resuelto
memorias: ["baserow_api_y_corrector", "n8n_2004_canal_origen_complemento", "super_script_fix_branch_user_origin", "erandi_intermediaria_mp"]
playbooks: []
---
## TRIGGERS
- `Baserow`, `bsrow.consultoriae3.com`, `token-auth JWT`, `tabla 749`, `tabla 750`, `SC BUCEFALO`
- `Verificador desactualizado`, `n8n no encuentra sucursal`, `match webhook 749 750`
- `PLAZA EL SALADO 85932 85956`, `La Viga 58932`, `Eugenia MP -Eugenia espacio`, `REYNOSA 85958 85970`
- `corrector baserow`, `cuentas_oficiales.csv`, `baserow_client.py`, `fix_baserow_verificador.py`
- `pendientes Erandi SUCURSAL`
## SÍNTOMA
El workflow n8n [2004] no llena sucursal/tienda (ni dispara el complemento de Canal de Origen) para varias sucursales porque el Verificador en Baserow está desactualizado. Confirmado en vivo: Eugenia se cortaba antes del PUT.
## DIAGNÓSTICO
- Acceso a la API de Baserow (read-only) con email/password → JWT. DB 63 "Bucefalo": tabla 749 (Mesa de control: Nombre/Location_ID/API_token) y 750 (Verificador: SUCURSAL/TIENDA/SC BUCEFALO/ID LOCATION BUCEFALO/SC TOKEN BUCEFALO).
- El match del workflow es `webhook location.name → 749.Nombre (7235) → 750.SC BUCEFALO (7247)`. Inspección de ejecuciones n8n (includeData) mostró: para Eugenia el Verificador 750 devolvía 0 filas porque `750.SC BUCEFALO="85974 - MP -Eugenia"` (sin espacio) ≠ `749.Nombre="85974 - MP - Eugenia"`.
- Cruce con la **lista oficial** de cuentas (que el owner proveyó como `n8n/cuentas_oficiales.csv`, name↔location_id) reveló: 1 nombre malo en 749 (PLAZA EL SALADO con `85932`, real `85956`), 5 nombres mal en 750, 1 fila ausente (Grand Plaza Toluca), SUCURSAL vacíos. NOTA: usar la lista oficial, NO `parse_accounts_csv` (su `resolve_location_name` metía ruido, p.ej. doble espacio en Marina Nacional que no existía en GHL).
## CAUSA RAÍZ
Baserow 749/750 con nombres desalineados del nombre real de la cuenta (typos de número, espacios, mayúsculas) → el match exacto del workflow falla y corta el flujo. Filas oficiales ausentes en 750.
## ACCIÓN
Corrector `scripts/fix_baserow_verificador.py` (dry-run→apply, backup previo de 749 y 750). Fuente de verdad: `n8n/cuentas_oficiales.csv` para el nombre; Verificador CSV local para SUCURSAL/TIENDA. 8 acciones aplicadas 2026-05-29 (0 errores):
- PATCH 749.Nombre PLAZA EL SALADO `85932``85956`.
- PATCH 750.SC BUCEFALO: La Viga `58932``85932`, PLAZA EL SALADO `85932``85956`, TAPACHULA `85963-``85963 -`, REYNOSA `85958``85970`, Eugenia espacio.
- PATCH 750.SUCURSAL METEPEC ← `Metepec, Estado de México` (CSV).
- POST 750 fila nueva Grand Plaza Toluca.
Filas 750 NO oficiales (Morelia 3 `rET7...`, segundo Grand Plaza `Xqpdy12...`) NO se tocaron. Cliente reusable `scripts/baserow_client.py`; auditoría `scripts/audit_baserow_verificador.py`.
## VERIFICACIÓN
- Re-auditoría: `749.Nombre != oficial = 0`, `750.SC BUCEFALO != oficial = 0`, ausentes = 0.
- E2E Eugenia: ensuciar canal de un WEB_USER → disparar webhook `8d574598` → en t+5s canal=SUCURSAL, sucursal='Narvarte Oriente, Ciudad de México', tienda='EUGENIA'. El [2004] ahora llega al PUT y el complemento corre.
## EDGE-CASES / TRAMPAS
- NO usar `parse_accounts_csv`/`resolve_location_name` como fuente de nombres (ruido: doble espacios). Usar la lista oficial provista.
- El nombre debe coincidir EXACTO en los 3 puntos (webhook, 749, 750); un espacio o dígito rompe el match.
- SUCURSAL/TIENDA sin fuente NO se inventan → lista para Erandi.
## REUTILIZABLE
```python
from scripts.baserow_client import BaserowClient
c = BaserowClient.from_credentials()
rows = c.list_rows(750) # user_field_names
c.update_row(750, row_id, {"SC BUCEFALO": "..."}, dry_run=True)
```
## PENDIENTES
- **Erandi:** completar en Baserow 750 el `SUCURSAL` de SENDERO, Independencia, Isidro Fabela, Grand Plaza Toluca (+`TIENDA` de Grand Plaza Toluca). Lista: `generated/reports/baserow_pendientes_erandi.json`.
- Decidir si las 2 filas 750 no oficiales (Morelia 3, segundo Grand Plaza) deben borrarse o si esas cuentas se dan de alta oficialmente.
## ENLACES
- Scripts: `scripts/baserow_client.py`, `scripts/audit_baserow_verificador.py`, `scripts/fix_baserow_verificador.py`. Fuente: `n8n/cuentas_oficiales.csv`.
- Backups: `generated/migrations/baserow_*_20260529_224430.json`. Lista Erandi: `generated/reports/baserow_pendientes_erandi.json`.
- Memorias: [[baserow_api_y_corrector]], [[n8n_2004_canal_origen_complemento]], [[erandi_intermediaria_mp]].
@@ -0,0 +1,153 @@
---
id: CASE-2026-05-29-descuadre-opp-multiempeno
fecha: 2026-05-29
categoria: descuadre, config_location, duplicado
location_ids:
- GbKkBpCmKu2QmloKFHy3 # Marca (Monte Providencia)
- jE41bVhhnb5T505BFm4F # 85964 - MP - Morelia 1 (Salvador)
- nF1uEaYB3mCK5em9bPn2 # 85974 - MP - Eugenia (ZONYA/LUIS origen)
- yjqKxoO02rsdwdJZSPmD # 85950 - MP - Temixco (Frankenstein Miguel Angel)
run_ids:
- descuadre_opp_del_20260529_190910 # DELETE ZONYA Wo4MXw
- descuadre_opp_del_20260529_191318 # DELETE LUIS Fv4dLJ
- a37d23ffe6574e0eb2ee8433bce2e1f3 # PUT allowDuplicateOpportunity=true en Marca
- descuadre_create_salvador_* # CREATE opp WON Salvador
snapshots:
- generated/migrations/descuadre_opp_20260529_snapshot.json # las 2 opps borradas + las que se conservan
- generated/migrations/enable_dup_opp_GbKkBpCmKu2QmloKFHy3_20260529_193342.json # settings antes del PUT
- generated/migrations/create_missing_branch_opps_20260529_193836.json # create Salvador
status: resuelto (5 mislinks pendientes de re-enlace)
memorias:
- "[[positive_opp_descuadre_double_replica]]"
- "[[opp_multiplicity_replication_gap]]"
- "[[duplicate_resolution_rules]]"
- "[[create_duplicate_phone_contact_marca]]"
playbooks:
- docs/PLAYBOOK_DESCUADRE.md
---
## TRIGGERS
- `descuadre +1 opp` / `diferencia oportunidades` / dashboard "Marca > sucursales" en opps
- `400 "Can not create duplicate opportunity for the contact"` al crear/replicar una opp
- `allowDuplicateOpportunity` (flag de settings de location)
- multi-empeño "no se replica a Marca" / contacto con 2 opps en sucursal y 1 en Marca
- `link muerto` / `ID Oportunidad Sucursal` apunta a opp borrada (GET da `400 "Opportunity doesn't exist or is deleted"`)
- réplica obsoleta que el n8n no borró tras rotar el id nativo de una opp
- token de agencia / `GHL_AGENCY_TOKEN` / 401 en `PUT /locations`
## SÍNTOMA
Dashboard Comparativa: **+1 en oportunidades** (Marca **1340** vs suma sucursales **1339**). Contactos también +1 (benigno, aparte). Signo **positivo** ⇒ sospechar cache viejo, huérfanos en Marca o doble réplica (ver [[positive_descuadre_stale_cache]]).
## DIAGNÓSTICO
**1. Audit base (read-only, vuelca a archivo):**
```powershell
python scripts\audit_brand_vs_branches_totals.py --json | Out-File -Encoding utf8 generated\agent\runs\descuadre_audit.json
```
Reveló: `diff.opportunities=1`, **`opportunities_in_branch_not_in_brand=0`** (¡nada falta según el audit!), `intra_brand_duplicates=0`. → El +1 es una opp de Marca "de más" que el audit no marca, porque su matching por-contacto la da por replicada.
**2. Matching 1:1 estricto por link** (clave para descuadre POSITIVO — el conteo del dashboard es por FILAS, no por matching):
```python
# agrupar opps de Marca por su CF "ID Oportunidad Sucursal" (resolve_opp_link_field_id)
# 1340 opps Marca = 1334 con link a opp de sucursal existente + 6 con link muerto/vacío
# los 1334 apuntan a solo 1333 opps distintas -> 1 link COMPARTIDO por 2 opps de Marca = +1
```
**3. Atribución EQUIVOCADA #1 (descartada):** el link compartido era `kGda02` (opp MIGUEL ANGEL de Eugenia), reclamado por 2 opps de Marca (`1A3P5b` $80,200 y `1l0S9v` $0). Parecía "doble réplica de Eugenia". **Falso:** al verificar en vivo, eran **dos personas distintas** llamadas Miguel Angel (tel …4949 Temixco vs …4950 Eugenia), cada una con 1 opp en su sucursal y 1 en Marca → **balanceado en filas**. El link compartido era un mislink (Frankenstein), NO la causa del +1.
**4. Matching robusto por phone-de-contacto** (porque los links estaban podridos):
```python
# para cada opp de sucursal, buscar opp de Marca con MISMO phone de contacto (consumir 1:1)
# las opps de Marca sin pareja = sobrantes reales; las de sucursal sin pareja = faltantes
```
Con phone real salieron: sobran **ZONYA `Wo4MXw` ($45k)** y **LUIS `Fv4dLJ` ($0)**; falta **Salvador (+524431452883)**.
**5. Atribución EQUIVOCADA #2 (descartada):** "falta Salvador". **Falso:** Salvador SÍ tiene opp en Marca (`NW09og`). El matching falló porque su `id_contacto_sucursal` estaba podrido.
**6. Verificación en vivo (la que cerró el caso)** — contar opps por contacto y estado de cada link:
```python
# por contacto en Marca: cuántas opps y a qué apunta su "ID Oportunidad Sucursal"
# ZONYA contacto wbUhES: 2 opps -> hrZq7j($60k lost, link OK a Eugenia IO969JW) + Wo4MXw($45k open, link P84gFZ MUERTO)
# LUIS contacto dMAc8A: 2 opps -> ezqhFc($40k lost, link OK s1fA9Wt) + Fv4dLJ($0 open, link 0l0ya7 MUERTO)
# Salvador contacto fpVvOAo: 1 opp NW09og(open, link a emsgo1) ; en Morelia tiene 2 (emsgo1 open + OWGU1u won) -> falta la WON
```
Confirmado en vivo que `P84gFZ` y `0l0ya7` dan `400 "Opportunity doesn't exist or is deleted"` en Eugenia → **links muertos**: la opp de Eugenia se borró+recreó (id rotó), el n8n creó la réplica nueva (`hrZq7j`/`ezqhFc`, ambas `createdAt` hoy) pero **no borró la vieja** → 2 opps obsoletas en Marca.
**Aritmética final del +1:** `+2` (ZONYA+LUIS obsoletas) `1` (Salvador WON faltante, multi-empeño) `= +1`.
## CAUSA RAÍZ
1. **2 réplicas obsoletas en Marca** (ZONYA `Wo4MXw`, LUIS `Fv4dLJ`): el n8n de sync de opps no borra la réplica vieja cuando el id nativo de la opp de sucursal **rota** (borrado+recreación). Quedan con `id_oportunidad_sucursal` apuntando a una opp ya inexistente.
2. **Faltante estructural de multi-empeño** (Salvador): Marca tenía `settings.allowDuplicateOpportunity = false` → GHL rechaza la 2ª opp de un contacto con `400 "Can not create duplicate opportunity for the contact"`. Por eso el n8n solo replica la 1ª opp por contacto. Las sucursales ya tenían el flag en `true`.
## ACCIÓN
1. **DELETE obsoletas** (piloto→1→1, con snapshot + audit). `ghl_client.delete_opportunity` + `script_audit.record_change(object_type='opportunity', field='__deleted__', old=<opp completa>)`. Runs `descuadre_opp_del_20260529_190910` (ZONYA), `…_191318` (LUIS). Tras ZONYA el contador ya fue +1→0; tras LUIS →−1 (destapó el faltante).
2. **Activar el flag** con el **token de agencia** (`GHL_AGENCY_TOKEN` en `.env`; el token PIT por-location da 401 en `PUT /locations`):
```bash
python scripts/enable_duplicate_opportunity.py --apply --json
# PUT /locations/{Marca} body {"settings":{"allowDuplicateOpportunity":true}} (solo ese flag; GHL hace merge)
# headers REQUIEREN User-Agent (sin él, 403). run a37d23ffe6574e0eb2ee8433bce2e1f3. false->true verificado.
```
3. **Crear la opp WON de Salvador** (multi-empeño, ya no da 400):
```bash
python scripts/create_missing_branch_opps_in_marca.py --apply --yes \
--location jE41bVhhnb5T505BFm4F --only-opp OWGU1uPoWvITmwOLIyvq --run-id descuadre_create_salvador_<ts>
```
## VERIFICACIÓN
- Conteo opps Marca: 1340 → (ZONYA) 1339 → (LUIS) 1338 → (+Salvador WON) **1339** = sucursales 1339.
- Audit final: **`diff.opportunities == 0`**.
```python
import json; d=json.load(open('generated/agent/runs/descuadre_audit_final.json',encoding='utf-8-sig'))
print(d['totals']['diff']['opportunities']) # 0
```
- Salvador en Marca: `[('won',15000),('open',15000)]` (espejo de Morelia).
- Réplicas buenas intactas: ZONYA `hrZq7j` $60k lost, LUIS `ezqhFc` $40k lost.
## EDGE-CASES / TRAMPAS
- **El conteo del dashboard es por FILAS, no por matching.** `opportunities_in_branch_not_in_brand=0` puede convivir con un descuadre real (el audit considera "replicada" una opp de sucursal si su contacto tiene CUALQUIER opp en Marca). Cazar el positivo con matching 1:1 estricto por link.
- **Link MUERTO ≠ link vacío ≠ opp ausente.** `create_missing_branch_opps_in_marca.py --all-branches` marcó **6 CREATE**, pero **solo Salvador (`multi=True`) era real**. Las otras 5 (Gerardo, Ernesto, Patricia, Lizeth, Temixco) tenían **1 opp en Marca con link muerto** → crearlas habría hecho **5 DUPLICADOS** (lección Maria/`HR99`). **Regla:** antes de aplicar el barrido, por cada candidata verificar *cuántas opps tiene el contacto en Marca* y el *estado del link* (`VACÍO`/`MUERTO`/`a-otra`=Frankenstein). Si ya tiene réplica ⇒ es RELINK, no CREATE.
- **"El contador en 0 puede ocultar basura."** Antes de tocar nada el descuadre era +1, pero escondía 2 opps fantasma (+2) y un faltante real (1). Defecto numérico ≠ defecto de integridad.
- **El flag `allowDuplicateOpportunity` no quedó activo permanentemente** tras una operación previa (volvió a `false`). Si alguien lo apaga, la replicación n8n de multi-empeños vuelve a fallar con 400.
- **`PUT /locations` necesita token de AGENCIA** (`locations.write`) + header `User-Agent`. El PIT por-location da 401.
- **Identidad podrida engaña al matching:** homónimos con tel casi igual (…4949 vs …4950) parecen duplicados pero son personas distintas; `id_contacto_sucursal` puede estar podrido. Verificar SIEMPRE en vivo por id antes de mutar.
## REUTILIZABLE
```python
# --- Aislar el sobrante real en un descuadre POSITIVO de opps (matching 1:1 estricto por link) ---
import sqlite3; from paths import DB_PATH; import scripts.audit_brand_vs_branches_totals as A
conn=sqlite3.connect(str(DB_PATH)); conn.row_factory=sqlite3.Row
BRAND=A.BRAND_LOCATION_ID; blink=A.resolve_opp_link_field_id(conn,BRAND)
brand=A.load_opps(conn,BRAND)
branch_ids=set()
for r in conn.execute("SELECT location_id FROM accounts"):
if r['location_id'] not in (BRAND,'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'):
branch_ids|={o['id'] for o in A.load_opps(conn,r['location_id'])}
used=set()
for o in brand:
lv=A.extract_opp_link_value(o.get('custom_fields_json'),blink)
if lv and lv in branch_ids and lv not in used: used.add(lv)
else: print('SOBRANTE/mislink:',o['id'],o['name'],lv) # link vacío, muerto o duplicado
# --- Verificar/activar el flag (dry-run sin --apply) ---
# python scripts/enable_duplicate_opportunity.py # dry-run
# python scripts/enable_duplicate_opportunity.py --apply # activa (token agencia en .env)
# python scripts/enable_duplicate_opportunity.py --disable --apply # rollback del flag
# python scripts/check_allowDuplicate_settings.py # verificación read-only multi-cuenta
# --- Estado de un link en vivo (muerto?) ---
# GET /opportunities/{id} con Version 2021-07-28 + User-Agent -> 400 "doesn't exist or is deleted" = MUERTO
```
## PENDIENTES
**5 mislinks (calidad de dato, NO afectan el conteo)** — réplicas en Marca con `id_oportunidad_sucursal` muerto/mal; re-enlazar (PUT del CF) sin crear duplicados:
- Gerardo (`Bj2bIN` → debe apuntar a Morelia `x3AXkY`)
- Ernesto (`UNtCRNQ``5kDn6b`)
- Patricia (`OGQtfmjF``zzBzWC`)
- Lizeth (`j0iKZo``LGSPKo`)
- **Miguel Angel Temixco (`1A3P5b`)**: linkea a Eugenia `kGda02` (Frankenstein) y trae valor $80,200 en vez de $56,671 (Temixco `OQBrOQN9`) → re-enlazar **y** corregir valor.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[opp_multiplicity_replication_gap]], [[duplicate_resolution_rules]], [[create_duplicate_phone_contact_marca]], [[positive_descuadre_stale_cache]]
- Playbook: [docs/PLAYBOOK_DESCUADRE.md](../PLAYBOOK_DESCUADRE.md)
- Scripts: [scripts/enable_duplicate_opportunity.py](../../scripts/enable_duplicate_opportunity.py), [scripts/create_missing_branch_opps_in_marca.py](../../scripts/create_missing_branch_opps_in_marca.py) (flag `--all-branches`), [scripts/audit_brand_vs_branches_totals.py](../../scripts/audit_brand_vs_branches_totals.py), [scripts/check_allowDuplicate_settings.py](../../scripts/check_allowDuplicate_settings.py)
- Artefactos: snapshots en `generated/migrations/` (ver frontmatter); audits en `generated/agent/runs/descuadre_audit_*.json`
@@ -0,0 +1,69 @@
---
id: CASE-2026-05-29-n8n-2004-canal-origen-tiempo-real
fecha: 2026-05-29
categoria: cascada_n8n
location_ids: ["todas las sucursales (workflow n8n compartido)", "nF1uEaYB3mCK5em9bPn2 (85974 - MP - Eugenia, gap Baserow)", "uJEn2iuUficuml9zxAnt (85976 - MP - Cancún, E2E ok)"]
run_ids: ["n8n workflow ddUEORBEtZLzsQF2 versionId 6e9a405c→069558e3"]
snapshots: ["n8n/backup_canal_origen_ddUEORBEtZLzsQF2_20260529_215207.json"]
status: resuelto
memorias: ["n8n_2004_canal_origen_complemento", "super_script_fix_branch_user_origin", "createdby_only_in_individual_get", "erandi_intermediaria_mp", "feedback_dry_run_protocol"]
playbooks: []
---
## TRIGGERS
- `[2004]`, `ddUEORBEtZLzsQF2`, `webhook 8d574598`, `actualizar contact.sucursal contact.tienda`
- `Canal de Origen tiempo real`, `complemento n8n canal origen`
- `Verificador 750 devuelve 0`, `Baserow 750 no encuentra sucursal`, `flujo se corta antes del PUT`
- `Eugenia no está en Baserow 750`, `sucursal renombrada Baserow desincronizado`
- `createdBy distinto por ejecución`, `esUsuario IF n8n`
- `n8n executions API includeData`, `verify_post versionId`
## SÍNTOMA
El batch `fix_branch_user_origin.py` corrige el backlog, pero los contactos NUEVOS creados por usuario en sucursal siguen naciendo sin Canal de Origen. Se pide complementar el workflow en tiempo real [2004] (corre al crear contacto) para que ponga Canal de Origen=SUCURSAL solo a los creados por usuario.
## DIAGNÓSTICO
1. Estructura del [2004] (GET read-only vía `n8n_workflow_lib`): flujo lineal Webhook→Datos de Lead→Omitir @ezcorp→Baserow 749 (cuentas)→Datos API→GET /contacts/{id}→GET /locations/{id}/customFields→Code (resuelve sucursal/tienda por fieldKey)→Baserow 750 (Verificador)→PUT /contacts/{id} (sucursal+tienda). Nodo huérfano `...SUCURSAL1` (POST opportunities/search) sin entrada = código muerto.
2. `GET /contacts/{id}` SÍ devuelve `createdBy.source` (ver [[createdby_only_in_individual_get]]).
3. fieldKey de "CANAL DE ORIGEN" = `contact.fuente_de_posible_cliente` (heredado), consistente en 4 sucursales muestreadas, picklist incluye SUCURSAL. Resolver por nombre con fallback a fieldKey.
## CAUSA RAÍZ
1. (gap a cerrar) Ni el [2004] ni el workflow nativo de GHL escribían Canal de Origen del contacto.
2. (hallazgo) **Baserow tabla 750 (Verificador) desincronizado:** el [2004] busca por `location.name` (field 7247); si la sucursal no está con su nombre actual, devuelve 0 filas y **el flujo se corta ANTES del PUT** (sucursal/tienda y canal). Confirmado en Eugenia "85974 - MP - Eugenia" (renombrada, ver [[erandi_intermediaria_mp]]). El batch usa el Verificador CSV LOCAL (sí la tiene) → no se notaba.
## ACCIÓN
Edición del workflow [2004] vía `scripts/n8n_workflow_lib.py` con `n8n/_add_canal_origen_branch.py` (idempotente, backup→dry-run→apply→verify):
- Extiende el Code node: resuelve `canal` (por fieldKey/nombre) y expone `createdBySource`/`esUsuario` leyendo `$('Obtener Contacto Cuenta Origen - SUCURSAL').item.json.contact.createdBy.source`.
- Añade tras el PUT sucursal: IF "Creado por usuario" → [true] PUT Canal de Origen=SUCURSAL → Tag+ sucursal → Tag- formulario → Tag- facebook-ads (DELETEs con `onError: continueRegularOutput`). [false]=fin.
- NO toca Fuente de Prospecto. Workflow desactivado/reactivado para el PUT estructural. versionId 6e9a405c→069558e3, 14→19 nodos.
## VERIFICACIÓN
E2E disparando el webhook de producción (`https://webhookn8.consultoriae3.com/webhook/8d574598-...`) con `{contact_id,email,location:{name}}` + inspección de ejecuciones vía `GET /api/v1/executions/{id}?includeData=true`:
- Eugenia (85974): Verificador 750 devolvió **0 filas** → flujo cortado antes del PUT → Code esUsuario=True correcto pero IF no corre. (Confirma el gap Baserow, no falla del cambio.)
- **Cancún (85976, sí está en 750):** contacto WEB_USER ensuciado a FORMULARIO → webhook → en t+5s canal=**SUCURSAL**, tags=['sucursal']. ✓
- INTEGRATION (solana, Eugenia): Code esUsuario=False → no toca canal (FACEBOOK intacto). ✓
- JUAN CARLOS (ensuciado en Eugenia para la 1ª prueba) restaurado manualmente a SUCURSAL.
## EDGE-CASES / TRAMPAS
- Dos webhooks casi simultáneos → execs consecutivas (52760/52761); confirmar `body.contact_id` por ejecución antes de concluir.
- El mismo contact_id devuelve `createdBy` correcto con el token de la sucursal; el "INTEGRATION" que vi al inicio era de la ejecución del OTRO contacto (confusión de execs), no del token.
- Probar el E2E SOLO en sucursales presentes en Baserow 750 (Cancún sí; Eugenia no).
- DELETE de tags inexistentes: usar `onError: continueRegularOutput` para no romper el flujo.
## REUTILIZABLE
```python
# Inspeccionar ejecuciones n8n por nodo (qué corrió, outputs):
from scripts.n8n_workflow_lib import load_credentials, N8NClient
c=N8NClient(*load_credentials())
st,data=c._request('GET','/api/v1/executions/<EXID>?includeData=true')
rd=data['data']['resultData']['runData'] # {nodeName: [{data:{main:[[items]]}}]}
```
## PENDIENTES
- [HECHO 2026-05-29] **Baserow 749/750 corregido** con el corrector automático — ver [[CASE-2026-05-29-corrector-baserow-verificador]] / [[baserow_api_y_corrector]]. Eugenia y los demás nombres ya alinean; E2E OK. Solo queda que Erandi complete SUCURSAL/TIENDA sin fuente (4 sucursales) — `generated/reports/baserow_pendientes_erandi.json`.
- [HECHO] Agendado: Tarea Windows "MP Origen Check" (diaria 07:00) corre el dry-run y deja alerta en `generated/runtime/origen_check_alert.json`; el owner aplica desde el dashboard. Ver [[origen_check_agendado]].
## ENLACES
- Script mutación: `n8n/_add_canal_origen_branch.py`; lib `scripts/n8n_workflow_lib.py`.
- Batch: `scripts/fix_branch_user_origin.py`.
- Backup/rollback: `n8n/backup_canal_origen_ddUEORBEtZLzsQF2_20260529_215207.json`.
- Memorias: [[n8n_2004_canal_origen_complemento]], [[super_script_fix_branch_user_origin]], [[createdby_only_in_individual_get]], [[erandi_intermediaria_mp]].
@@ -0,0 +1,88 @@
---
id: CASE-2026-05-29-origen-sucursal-contactos-usuario
fecha: 2026-05-29
categoria: custom_field
location_ids: ["nF1uEaYB3mCK5em9bPn2 (85974 - MP - Eugenia, piloto)", "todas las sucursales productivas (47, batch); excluye Marca GbKkBpCmKu2QmloKFHy3 y demos Vf7qQl3L9vakJ8hDtQ8e / Z64WQKORPVwXb5mn68Ef"]
run_ids: ["fbuo-cc20241b7a6f (piloto Eugenia)", "fbuo-batch-8c31110b2d (batch 47 sucursales)"]
snapshots: []
status: resuelto
memorias: ["createdby_only_in_individual_get", "super_script_fix_branch_user_origin", "feedback_dry_run_protocol", "name_account_with_location_id"]
playbooks: []
---
## TRIGGERS
- `createdBy.source`, `WEB_USER`, `MOBILE_USER`, `INTEGRATION`
- `contactos creados por usuario`, `canal de origen sucursal`, `origen sucursal`
- `createdBy no viene en el listado`, `GET /contacts/ omite createdBy`
- `fix_web_user_branch_contacts roto`, `siempre detecta 0`
- `fix_branch_user_origin.py`, `super script origen sucursal`
- `Fuente de Prospecto ALIANZA`, `PROSPECCIÓN`, `no sobrescribir Fuente de Prospecto`
- `tag formulario -> sucursal`, `etiqueta de origen única`
- `Canal de Origen de la Oportunidad = Sucursal`
## SÍNTOMA
Los contactos creados a mano en una sucursal (por un empleado) no traen canal de origen confiable: el campo nativo `source` llega vacío/None y no indica "sucursal". Esto ensucia el CF `Canal de Origen` (contacto y opp) y las etiquetas de origen. Objetivo: identificar los contactos creados 100% por usuario en sucursal y dejarlos con origen = Sucursal (CF + tag), propagando a sus oportunidades. Solo sucursales (no Marca, no demos).
## DIAGNÓSTICO
Pasos read-only (todos con el helper `tag_canal_origen_workflow`):
1. Primer dry-run del super script leyendo `createdBy` del **listado**`WEB_USER a corregir: 0`. Distribución: `(vacío): 129`. Sospecha: el listado no trae `createdBy`.
2. Comparación listado vs GET individual de un contacto:
- Listado `GET /contacts/`: keys incluyen `source` (=None), `attributions`, pero **NO** `createdBy`.
- Individual `GET /contacts/{id}`: trae `createdBy = {source: 'WEB_USER', sourceName: 'EUGENIA- 85974 MP', channel: 'APP', ...}` y `attributionSource = {medium: 'manual', sessionSource: 'CRM UI'}`.
3. Muestra de 20 GETs individuales en Eugenia: 3 WEB_USER + 17 INTEGRATION → el criterio discrimina perfecto. Proxy del listado: WEB_USER ≈ `attributions[0].medium == 'manual'`.
4. Audit log oficial del CRM para `maMw3C8QmhGVChRqL36y` (JUAN CARLOS RAMIREZ): "Action: Created, Modified by: Web user" → coincide con `createdBy.source == WEB_USER`.
5. Schema de contacto Eugenia (resolución de campos por alias, correcta):
- `Canal de Origen``KLEZyRNR0jrldccerErV` (name real "CANAL DE ORIGEN")
- `Fuente de Prospecto``QN1BNTKgCzcSOHa2wSZc`
- `Sucursal``pmrGTW3tIa7oz7rQJMVx`, `TIENDA``H3g8J4NbgbcM4glyW9GZ`
6. Distribución de valores en Eugenia (SQLite): `Canal de Origen` {SUCURSAL 84, FORMULARIO 28, FACEBOOK 14, vacío 3}; `Fuente de Prospecto` {SUCURSAL 84, LEAD DIGITAL 42, **ALIANZA 2**, **PROSPECCIÓN 1**}. → `Fuente de Prospecto` contiene valores de negocio que NO deben pisarse.
## CAUSA RAÍZ
1. **`createdBy` solo viene en el GET individual** del contacto; el listado paginado lo omite (ver [[createdby_only_in_individual_get]]). El script previo `scripts/fix_web_user_branch_contacts.py` lo leía del listado → roto silenciosamente (siempre 0).
2. Los contactos creados por empleado quedan con `createdBy.source` ∈ {`WEB_USER` (UI web), `MOBILE_USER` (app móvil)}; los replicados desde Marca por n8n quedan `INTEGRATION`.
## ACCIÓN
Super script nuevo `scripts/fix_branch_user_origin.py` (registrado en SCRIPTS_METADATA como "Origen Sucursal (contactos creados por usuario)"). Ver [[super_script_fix_branch_user_origin]]. Orden contacto→opp:
- Contacto: tag único `sucursal` (quita `formulario`/`facebook-ads`), `Canal de Origen` = SUCURSAL. Si falta `Sucursal`/`TIENDA`, se completan desde el Verificador CSV (`load_verifier_map`).
- TODAS las opps del contacto: `Canal de Origen de la Oportunidad` = Sucursal + propaga `Sucursal`/`TIENDA`.
- **NO toca `Fuente de Prospecto`** (decisión del owner: preserva ALIANZA/PROSPECCIÓN). No sincroniza a Marca.
Protocolo dry-run → piloto → batch ([[feedback_dry_run_protocol]]):
```
# Dry-run (Fase 1):
python scripts/fix_branch_user_origin.py --location nF1uEaYB3mCK5em9bPn2
# Piloto:
python scripts/fix_branch_user_origin.py --location nF1uEaYB3mCK5em9bPn2 --apply --run-id fbuo-cc20241b7a6f
# Batch:
python scripts/fix_branch_user_origin.py --all --apply --run-id fbuo-batch-8c31110b2d
```
## VERIFICACIÓN
- Piloto Eugenia (run `fbuo-cc20241b7a6f`, success): 89 creados por usuario, 6 contactos, 98 opps. JUAN CARLOS post-apply en vivo: tags=['sucursal'], Canal de Origen='SUCURSAL', Sucursal='Narvarte Oriente, Ciudad de México', TIENDA='EUGENIA', **Fuente de Prospecto='ALIANZA' intacto**; su opp Tpd964ztTwgNf1ipL5NC con Canal de Origen de la Oportunidad='Sucursal' + Sucursal propagado.
- Consistencia Sucursal: 126/129 contactos ya tenían 'Narvarte Oriente, Ciudad de México'; los 3 vacíos quedaron con el MISMO valor del Verificador. Sin divergencia.
- Batch (run `fbuo-batch-8c31110b2d`, success): 359 creados por usuario detectados en 47 sucursales, 48 contactos + 273 opps corregidos, 0 errores. Auditoría: 110 cambios contact + 284 opp, todos `applied`.
## EDGE-CASES / TRAMPAS
- **No leer `createdBy` del listado** → siempre 0. Hay que GET individual por contacto (costoso pero fiel; el dashboard paraleliza por sucursal).
- **No sobrescribir `Fuente de Prospecto`**: contiene ALIANZA/PROSPECCIÓN (valores de negocio), no solo SUCURSAL/LEAD DIGITAL.
- Incluir **MOBILE_USER** además de WEB_USER (ambos = creación manual por empleado).
- 8 sucursales tenían **0 contactos** (no tocadas) y 5 tienen **Verificador con Sucursal vacía** + 2 **no están en el Verificador**: si más adelante reciben contactos creados por usuario sin Sucursal, no se autocompletará hasta corregir el Verificador.
## REUTILIZABLE
```python
# createdBy SOLO en GET individual:
full = ghl_request("GET", f"/contacts/{cid}", token); inner = full.get("contact") or full
src = (inner.get("createdBy") or {}).get("source") # WEB_USER | MOBILE_USER | INTEGRATION
```
## PENDIENTES
- Corregir el Verificador para las sucursales con Sucursal vacía / ausentes (Isidro Fabela, SENDERO, Grand Plaza, Independencia, Morelia 3, + las 2 ausentes) por si reciben contactos creados por usuario.
- Confirmar si las 8 sucursales con 0 contactos es esperado (sucursales nuevas) o falta sync/acceso.
- Identificación de origen Facebook Ads / formulario en sucursal (fuera de alcance de este caso).
## ENLACES
- Memorias: [[createdby_only_in_individual_get]], [[super_script_fix_branch_user_origin]], [[feedback_dry_run_protocol]], [[name_account_with_location_id]]
- Scripts: `scripts/fix_branch_user_origin.py`, helpers de `scripts/tag_canal_origen_workflow.py`, `scripts/fill_sucursal_tienda_from_location.py` (`load_verifier_map`)
- Roto/superado: `scripts/fix_web_user_branch_contacts.py`
- Logs: `generated/logs/fbuo_batch_8c31110b2d.log`
@@ -0,0 +1,79 @@
---
id: CASE-2026-05-29-tienda-vacia-formulario-sitio-web
fecha: 2026-05-29
categoria: custom_field | cascada_n8n | config_location
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "rQYjjwsGnjEGagskOxix (85930 TULYEHUALCO)", "nRSeOhlhQ3vyirTKYhPi (85961 VILLAS DEL SOL)", "blRZ21GlzgUCA7bl2uVw (85975 Querétaro)", "R34lUVVpltnB8Z1RqnEB (85971 Satélite)", "uZnMH5bO6MXTHcgHeyq9 (85935 Pilares)"]
run_ids: ["26217ad9-934f-40d5-af69-bd0cbb5c02e4 (backfill TIENDA)", "bb27026c-1d99-458d-be1d-d34b7498b1a4 (delete Guco)"]
snapshots: []
status: parcial
memorias: [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]], [[sucursal_to_marca_cf_drop_on_create]], [[name_account_with_location_id]]
playbooks: [docs/PLAYBOOK_DESCUADRE.md]
---
## TRIGGERS
- `TIENDA vacía`, `campo TIENDA empty`, `lead sin tienda`, `Formulario - Sitio Web TIENDA`
- `Baserow 750 más de un resultado`, `Si hay más de un resultado`, `SUCURSAL contains ambiguo`
- `Toluca Estado de México 3 filas`, `Metepec 3 filas`, `750 filas duplicadas SUCURSAL`
- `createdBy INTEGRATION OAUTH`, `source Formulario - Sitio Web`, `CANAL DE ORIGEN FORMULARIO`
- contactos: `Juan Carlos espiritu`, `Adrian Garza`, `Gerardo Juárez`, `Luis Fernando mejía`, `Jesús Niño`, `Jorge Erick hernandez`, `Miguel Velasquez`, `Jorge Enrique ibarra`, `Guco Aseram`
## SÍNTOMA
Export de Marca: 9 leads `source="Formulario - Sitio Web"` con campo **TIENDA vacío**. Hipótesis inicial del owner: "creados antes de la optimización n8n". FALSA (ver causa).
## DIAGNÓSTICO (read-only)
1. GET live (no cache) de los 9 en Marca → TIENDA vacía confirmada; 8 traen `ID Contacto Sucursal`.
2. Ubicación de la sucursal por `ID Contacto Sucursal` (tabla `contacts` cache) + GET live → **TIENDA vacía TAMBIÉN en la sucursal** (no es gap de replicación; nunca se asignó en origen).
3. `createdBy.source=INTEGRATION` / `Formulario - Sitio Web` → ni [2004] ni `fix_branch_user_origin.py` los cubren (esos = WEB_USER/MOBILE_USER).
4. Conteo global: de **175** contactos `Formulario - Sitio Web`, solo **16 (9%)** con TIENDA vacía = estos 8 × 2 (Marca+sucursal). El 91% sí la tiene → NO sistémico por source.
5. TIENDA canónica por sucursal (consenso 85-119 contactos): se deriva del **branch físico**, no del texto `Sucursal`. (`object_schemas` + `contacts.custom_fields_json`.)
6. Workflow Marca→Sucursal V2 `4UMRwxJdHFfOGHBp` (webhook formulario): nodo Baserow `Obtener Info de cuenta objetivo - SUCURSAL` filtra **tabla 750, field 7240 (SUCURSAL), operador `contains` = `$json.Contacto.Sucursal`**, y hay IF **`Si hay más de un resultado`**.
7. Test directo Baserow 750 (`baserow_client.BaserowClient.from_credentials`, `list_rows(750)`):
- `Toluca, Estado de México`**3 filas** (SC `85935 - MP - Pilares`, TIENDA GRAND PLAZA / ISIDRO FABELA / INDEPENDENCIA).
- `Metepec, Estado de México`**3 filas** (Pilares/PILARES + 2× METEPEC/METEPEC dup).
- Tulyehualco / Playa del Carmen / Querétaro / Satélite → **1 fila** (resuelven bien).
## CAUSA RAÍZ (doble)
1. **Determinística (Luis Fernando=Toluca, Jorge Enrique=Metepec):** Baserow 750 tiene filas **ambiguas/duplicadas** donde el mismo `SUCURSAL` mapea a múltiples TIENDA → el filtro `contains` devuelve >1 → IF `Si hay más de un resultado` → el flujo no asigna TIENDA. **Recurrirá** en todo lead Toluca/Metepec hasta limpiar 750.
2. **Transitoria (los otros 6, match único en 750):** debían resolver; TIENDA vacía = fallo puntual de ejecución. Confirmación 100% requeriría logs n8n del momento (probablemente no retenidos).
- **DESCARTADO:** "pre-optimización" (7 de 8 son 2026-05-29, post-fix 05-28) y "name-mismatch de esquema".
## ACCIÓN
1. Backfill TIENDA (sucursal + Marca), valor = TIENDA canónica del branch físico, `run_id=26217ad9-934f-40d5-af69-bd0cbb5c02e4`:
TULYEHUALCO (Juan Carlos, Jorge Erick), VILLAS DEL SOL (Adrian), QUERETARO (Gerardo, Miguel), SATELITE (Jesús), PILARES (Luis Fernando, Jorge Enrique). 16 PUTs, piloto Juan Carlos → verificado → lote.
- Nota: a Luis Fernando/Jorge Enrique se les puso **PILARES** (consenso de su branch real), que es más correcto que las filas rotas de Baserow.
2. Delete contacto huérfano **Guco Aseram** `HEb1qBGilEReVITtq0GZ` (Marca; sucursal Villahermosa ya no existe, sin contraparte). `run_id=bb27026c-1d99-458d-be1d-d34b7498b1a4`. DELETE → `succeeded:true`; GET posterior → HTTP 400 (gone).
## VERIFICACIÓN
- Backfill: GET live de los 16 → TIENDA = valor esperado (16/16 OK).
- Delete: GET `/contacts/HEb1qBGilEReVITtq0GZ` → HTTP 400.
- Pendiente re-sync de las 5 sucursales + Marca para que la cache refleje (la verdad viva ya es correcta).
## EDGE-CASES / TRAMPAS
- TIENDA se deriva del **branch físico**, NO del texto libre `Sucursal` (Luis Fernando: Sucursal="Toluca" pero branch=Pilares → TIENDA=PILARES).
- Verificar SIEMPRE en vivo: la cache puede no reflejar asignaciones recientes.
- Baserow 750: el filtro del workflow es `contains` sobre `SUCURSAL` (field 7240), distinto del match por `SC BUCEFALO` (7247) que usa el corrector. Las filas ambiguas rompen este path aunque el corrector de nombres esté OK.
## REUTILIZABLE
```python
# Test de ambigüedad Baserow 750 por SUCURSAL (lo que ve el workflow)
from scripts.baserow_client import BaserowClient
c=BaserowClient.from_credentials(); rows=c.list_rows(750)
hits=[r for r in rows if r.get('SUCURSAL') and 'Toluca, Estado de México'.lower() in str(r['SUCURSAL']).lower()]
# >1 hit => el lead de esa Sucursal NO recibirá TIENDA
```
```python
# TIENDA canónica de un branch (consenso de sus contactos)
from collections import Counter; import json
# parse contacts.custom_fields_json, contar valores del field_id de TIENDA del branch
```
## PENDIENTES
- **Limpiar Baserow 750:** deduplicar / desambiguar filas con mismo `SUCURSAL` y distinta TIENDA (Toluca→3, Metepec→3). Mientras existan, los leads Toluca/Metepec seguirán sin TIENDA. Evaluar extender el corrector (`fix_baserow_verificador.py`) o añadir desambiguación en el workflow (`Si hay más de un resultado`).
- **6 casos transitorios:** si recurren, revisar ejecuciones de `4UMRwxJdHFfOGHBp` en la ventana de creación.
- **Sucursal text de Luis Fernando** ("Toluca") inconsistente con su branch (Pilares/Metepec). No tocado; reportado.
- Re-sync de las 5 sucursales + Marca.
## ENLACES
- Memorias: [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]], [[sucursal_to_marca_cf_drop_on_create]]
- Workflow: `4UMRwxJdHFfOGHBp` (Marca→Sucursal V2); helper `scripts/n8n_workflow_lib.py`, `scripts/baserow_client.py`
- Caso relacionado: [2026-05-29-corrector-baserow-verificador.md](2026-05-29-corrector-baserow-verificador.md)
@@ -0,0 +1,100 @@
---
id: CASE-2026-05-30-comparativa-auditoria-completa-buckets
fecha: 2026-05-30
categoria: descuadre | cascada_n8n | config_location
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)", "uZnMH5bO6MXTHcgHeyq9 (85935 - MP - Pilares, hub)", "NSDniGzjxotVDNa5YxqW (85937 - MP - METEPEC, shell)"]
run_ids: ["45554292-1b2d-491c-8107-b0ebf81c0b86 (cleanup Patricia)", "fill-temixco-20260530", "bf-cristhian-20260530", "bf-hugo-20260530", "isai-tienda-20260530", "isai-opp-20260530", "fix-identity-collisions-20260530 (sarahi name + miguel TIENDA)", "fix-sarahi-tienda-20260530", "cleanup link 9i1rDQa (isai dup rpDH)", "create-miguel-brand-opp-20260530"]
snapshots: ["generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_122326.json", "generated/migrations/fix_identity_collisions_20260530_130329.json", "generated/migrations/create_miguel_brand_opp_20260530_132718.json"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[verificador_tipo_de_tienda_colapso]]", "[[positive_descuadre_stale_cache]]", "[[audit_hub_map_metepec_pilares]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md"]
---
## TRIGGERS
- `Comparativa Marca vs Sucursales sigo viendo discrepancia`
- `auditar por completo los buckets`, `optimizar toda la sección Comparativa`
- `opps +1 descuadre positivo`, `opportunities_in_brand_duplicate_link 2`
- `contacts_in_brand_present_in_other_branch_not_assigned 84 falsos positivos`
- `TIENDA=METEPEC vive en Pilares 85935`, `Metepec 85937 vacío hub digital`
- `audit lee Verificador CSV no Baserow`, `falta fila METEPEC→85935 digital`
- `DIGITAL_HUB_BY_SHELL`, `hub-map en código audit_brand_vs_branches_totals`
- `PATRICIA PARRA NAVARRO zzBzWC4adBrdTA8WhQph`, `réplica abandoned $0 updatedAt==createdAt`
## SÍNTOMA
Comparativa del dashboard: contactos cuadraban (diff 0) pero **opps +1** (Marca 1341 vs sucursales 1340). El usuario reportó discrepancia persistente y pidió auditar TODOS los buckets + optimizar la sección. Cache fresco (sync 2026-05-30 11:1811:19).
## DIAGNÓSTICO
1. Frescura de cache: `sync_logs` → todas sincronizaron 11:1811:19. No es stale.
2. Audit completo a JSON (read-only):
```
python scripts/audit_brand_vs_branches_totals.py --json > generated/agent/runs/descuadre_audit_20260530.json
# OJO: el redirect en Windows escribe cp1252, no utf-8. Leer con .decode('cp1252').
```
3. Buckets con items: `opportunities_in_brand_duplicate_link=2`, `present_in_other_branch_not_assigned=84`, `contacts_missing_id_field=4`, `without_tienda=2`, `in_branch_not_in_brand=1`, `not_in_any_branch=1`, `opportunities_missing_id_field=1`. Resto 0.
4. **El +1 de opps** = bucket `opportunities_in_brand_duplicate_link`: 2 opps de Marca con el MISMO link `zzBzWC4adBrdTA8WhQph` (Morelia 1). `extra_opps=1`.
5. **El bucket 84** descompuesto por (expected→actual): **82/84 = TIENDA=METEPEC esperado 85937, actual Pilares 85935.** Verificador CSV mapea METEPEC→85937 (vacío, físico), pero los leads digitales viven en el hub Pilares. Las otras tiendas del cluster (GRAND PLAZA/ISIDRO/SENDERO) SÍ tienen fila digital →85935; METEPEC no. → falsos positivos.
6. Los contactos cuadraban a 0 **enmascarando** 2 problemas que se cancelan: `in_branch_not_in_brand=1` (sarahi sarabia, real) + `not_in_any_branch=1` (test21 harness, prueba).
## CAUSA RAÍZ
1. **+1 opps**: réplica duplicada en Marca (n8n `Cfgwp0bOtDW8zuKW` hizo CREATE en vez de UPDATE por carrera de indexado). La sobrante: `sAxBY01AQNwSr0OExQof` *abandoned* $0 created==updated 2026-05-30 03:02, colgada de contacto distinto (edgar morales). Mismo patrón que [[positive_opp_descuadre_double_replica]].
2. **84 ruido**: el audit lee el Verificador CSV, que NO codifica la consolidación de hub (Toluca/Metepec/Lerma→Pilares 85935) que Baserow 750 sí tiene ([[verificador_tipo_de_tienda_colapso]]). Falta la fila digital METEPEC→85935.
## ACCIÓN
**A) Optimización del audit (código, sin tocar Bucéfalo)** — autorizada "hub-map en código":
- En `scripts/audit_brand_vs_branches_totals.py`: constante `DIGITAL_HUB_BY_SHELL` (shell loc → hub Pilares `uZnMH5bO6MXTHcgHeyq9`) + check en el bucket `present_in_other_branch`: si la sucursal asignada es shell de un hub y el contacto está en el hub, se considera bien asignado.
- Resultado: bucket 84 → **2** (los 2 reales: luis fernando tienda=PILARES quirk; miguel angel EUGENIA→Temixco).
**B) Corrección +1 opps** — autorizada "borrar opp duplicada":
```
# dry-run (verifica en vivo, snapshot):
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link zzBzWC4adBrdTA8WhQph
# apply:
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --only-link zzBzWC4adBrdTA8WhQph
# run_id=45554292-1b2d-491c-8107-b0ebf81c0b86 borró sAxBY01AQNwSr0OExQof (conservó OGQtfmjFk31M5eXKDBpO open $70k)
# re-sync Marca para refrescar cache:
python -c "import sync_engine as se; a=next(x for x in se.parse_accounts_csv() if x['location_id']=='GbKkBpCmKu2QmloKFHy3'); se.sync_account(a['location_id'], a['token'])"
```
**C) Pendientes (2ª tanda, misma sesión)** — todos aplicados con dry-run+snapshot+audit:
- `fill_contact_id_sucursal.py --location Temixco --apply`: cristhian/hugo lado sucursal.
- `backfill_brand_contact_id_sucursal.py --only-contact <U9DWipe|bUNqMZaL> --apply`: cristhian/hugo lado Marca.
- `fix_brand_tienda_from_sucursal.py --only-contact JV9g9tWO --apply`: isai TIENDA→ECATEPEC (reporta "Errores:1" cosmético al persistir local; el PUT a GHL sí aplica, verificado en vivo).
- `backfill_opp_sucursal_link.py --only-opp 0eYLJ6 --apply`: enlazó la opp huérfana de isai → **destapó** que isai tenía 2 réplicas en Marca (rpDH+0eYLJ) de 1 opp en Ecatepec.
- `cleanup_brand_duplicate_replica_opps.py --only-link 9i1rDQa --apply`: borró rpDH (réplica nueva), conservó 0eYLJ (original, más antigua). → destapó el faltante 1.
- **sarahi** (colisión de teléfono con "luis enrique suchil"): el contacto Marca m6QK tenía email+opp de sarahi pero nombre de luis → UPDATE firstName/lastName→"Sarahí Sarabia" (NO sync, evita 3er duplicado) + TIENDA ATLACOMULCO→ATIZAPAN. jBaK (luis real) intacto.
- **miguel** (TIENDA EUGENIA→TEMIXCO) + el faltante 1 era su empeño físico Temixco OQBrOQN9 ($56,671) sin réplica. NO es multi-empeño: son 2 opps DISTINTAS (empeño físico ene-08 $56,671 vs lead digital Marca abr-24 $80,200 sin link, CF vacíos). Se **creó** réplica en Marca (8HITkGkOn3gN23Tl8LBr, con link a OQBrOQN9) SIN tocar la digital — script ad-hoc reusando `resolve_brand_pipeline_and_stage`+`create_opportunity`; el link se setea con PUT separado (el POST no guarda customFields). Gotcha: tras el PUT el GET inmediato da link=None (latencia indexado GHL); re-sync confirma válido.
- **luis fernando** (TIENDA=PILARES, vive en Pilares): se agregó 85940 Isidro Fabela (0 contactos) a `DIGITAL_HUB_BY_SHELL` → resuelto.
## VERIFICACIÓN
| | Inicial | Tras 1ª tanda | Final |
|---|---|---|---|
| Contactos diff | 0 | 0 | **0** |
| Opps diff | **+1** | 0 | **0** (1340=1340) |
| `opportunities_in_brand_duplicate_link` | 2 | 0 | 0 |
| `present_in_other_branch_not_assigned` | 84 | 2 | **0** |
| `contacts_missing_id_field` | 4 | 4 | **0** |
| `opportunities_missing_id_field` | 1 | 1 | **0** |
| `contacts_in_branch_not_in_brand` | 1 | 1 | **0** |
| Items accionables totales | ~95 | — | **2 (solo test21, fantasma)** |
> Lección clave: el "diff 0" inicial de opps era engañoso — enmascaraba el duplicado de isai (+1) contra el faltante de miguel (1). El faltante NO aparecía en `opportunities_in_branch_not_in_brand` (=0) porque el bucket matchea por contacto y miguel ya tenía una opp en Marca (gap multi-opp). Se cazó con un diff 1:1 de links sucursal↔Marca (parsear `fieldValueString`, no `value`).
## EDGE-CASES / TRAMPAS
- El redirect `> file.json` en Windows NO escribe utf-8; el JSON sale cp1252. Decodificar con `cp1252`.
- `sync_account(location_id, token)` — son DOS args posicionales, no el dict de cuenta.
- El audit lee SQLite (cache). Tras un DELETE en GHL hay que **re-sync de la location** antes de re-auditar o el número no cambia.
- El hub-map mapea varios shells→Pilares pero solo METEPEC generaba falsos positivos (los demás ya resolvían al hub vía CSV o tienen 0 contactos). Mapearlos todos es inocuo y a prueba de cambios de orden en el CSV.
## REUTILIZABLE
- Descomponer cualquier bucket grande por (expected_branch → actual_branch) con Counter antes de concluir "error de datos": muchas veces es mapeo del Verificador, no datos.
- `cleanup_brand_duplicate_replica_opps.py --only-link <link>` es el camino seguro para el descuadre positivo de opps por doble réplica (verifica en vivo + snapshot + script_audit).
## PENDIENTES
- **test21** (fantasma de índice): GET 400 / search 200. No accionable; se auto-corrige cuando GHL reconcilie el índice. Sigue en `without_tienda` + `not_in_any_branch`.
- **Lead digital de miguel** (opp Marca 1A3P5b $80,200, sin link): es un lead digital creado directo en Marca que nunca bajó a su sucursal (Temixco). Idealmente lo baja la cascada n8n Marca→Sucursal. Queda solo-Marca; el conteo cuadra igual. Revisar si ese lead digital es válido y debe cascar.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[verificador_tipo_de_tienda_colapso]], [[positive_descuadre_stale_cache]], [[name_account_with_location_id]], [[audit_hub_map_metepec_pilares]]
- Playbook: docs/PLAYBOOK_DESCUADRE.md
- Scripts: scripts/audit_brand_vs_branches_totals.py, scripts/cleanup_brand_duplicate_replica_opps.py
- Caso previo relacionado (réplica duplicada, quedó parcial): docs/casos/2026-05-30-descuadre-opp-replica-duplicada-marca.md
@@ -0,0 +1,87 @@
---
id: CASE-2026-05-30-descuadre-opp-deadlink
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)", "2eJPAdEGjC7iPhDDAeoy (85977 - MP - Interlomas)"]
run_ids: ["99365455-dee6-4f1f-b52a-9076683e02bb"]
snapshots: ["generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[positive_descuadre_stale_cache]]", "[[ghl_opportunity_search_quirks]]", "[[duplicate_resolution_rules]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md", "docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---
## TRIGGERS
- `DIFERENCIA OPORTUNIDADES +2` / descuadre positivo de opps Marca > Sucursales
- audit con **todos los buckets de opps en 0** (`opportunities_in_brand_duplicate_link: 0`, `opportunities_in_branch_not_in_brand: 0`, `opportunities_missing_id_field: 0`) pero `diff.opportunities` ≠ 0
- opp de Marca con "ID Oportunidad Sucursal" poblado pero `GET /opportunities/{link}` → HTTP **400** `"Opportunity doesn't exist or is deleted."`
- "link muerto" / dead-link / réplica obsoleta que el n8n no borró al rotar el id de la opp de sucursal
- contacto con 2 opps en Marca (una link válido, otra link muerto) y 1 sola opp viva en sucursal
## SÍNTOMA
Dashboard Comparativa: Marca 1,341 opps / Sucursales (suma) 1,339 → **+2 descuadre detectado**. Contactos cuadrados (0).
## DIAGNÓSTICO
1. **Frescura de caché** (sospechoso #1 de positivo): `sync_logs` → "Sincronizar Todo" recién corrido 2026-05-30 10:09; Marca synced 10:09:13 con 1341 opps. Caché FRESCO → +2 real, no stale. (Los 3 "running syncs" del metrics eran zombies del 2026-05-20, ignorar.)
2. **Audit completo** (`scripts/audit_brand_vs_branches_totals.py --json`): diff opps +2 pero **TODOS los buckets de opps = 0**. Ningún bucket lo explica → caso fuera de la cobertura del audit.
3. **Matching 1:1 robusto manual**: agrupar opps de Marca por su link (`extract_opp_link_value`, field `j029pu3OU02ATNccJR6l`) vs set de ids nativos de las 47 sucursales (excl. demos). Resultado: **4 opps de Marca con link MUERTO** (apunta a id que no está en ninguna sucursal); 0 sin link; 0 link duplicado; 1337 con link válido.
4. **Triage de las 4** (por contacto): 2 contactos (Ernesto, Gerardo) tienen una **2ª opp en Marca con link VÁLIDO** a la opp viva → la de link muerto sobra (+2). Los otros 2 (Patricia, Lizeth) tienen **1 sola opp** mal enlazada → 1:1, no inflan conteo (el audit las empareja vía contacto, por eso reportó 0 faltantes).
5. **Verificación EN VIVO**: `GET /opportunities/{deadlink}` → HTTP 400 "doesn't exist or is deleted" en los 4. `GET /opportunities/search?contact_id=` → cada contacto de sucursal tiene exactamente 1 opp viva (coincide con caché).
**Callejón descartado:** los 2 scripts de cleanup NO sirven aquí. `cleanup_brand_duplicate_replica_opps.py` agrupa por link COMPARTIDO (aquí cada link muerto es único). `cleanup_brand_orphan_opportunities.py` empareja por NOMBRE → ve la réplica obsoleta como "sincronizada" (mismo nombre que la opp viva) y no la toca.
## CAUSA RAÍZ
Réplicas **obsoletas** en Marca que el workflow n8n de sync de opps (`Cfgwp0bOtDW8zuKW`) dejó atrás cuando el id nativo de la opp de sucursal **rotó** (la opp original se borró/recreó en la sucursal). La réplica vieja quedó con el `ID Oportunidad Sucursal` apuntando a un id ya inexistente; el n8n creó una nueva réplica con el id nuevo en vez de actualizar la vieja → **doble réplica**. Patrón idéntico a [[positive_opp_descuadre_double_replica]].
## ACCIÓN
Confirmación explícita del usuario (borrar 2 + re-enlazar 2). Snapshot live previo en `generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json`. Todo bajo `run_id=99365455-dee6-4f1f-b52a-9076683e02bb` en `script_audit` (reversible desde dashboard).
- **DELETE** (réplicas obsoletas en Marca): `UNtCRNQHqLf4Vv4vdY39` (Ernesto Chavez, open $10k) + `Bj2bINlklDAoLmTsyg3r` (Gerardo Padilla, open $50k). `gc.delete_opportunity`.
- **RELINK** (`ID Oportunidad Sucursal` → id vivo, `gc.update_opportunity` customFields): `OGQtfmjFk31M5eXKDBpO` (Patricia) `RNwRPgWEpi0nWIBYKbeZ``zzBzWC4adBrdTA8WhQph`; `j0iKZoeeYb1wNOaWHwNN` (Lizeth) `wYLZJd4Xpj0K9HYyikWX``LGSPKoeeEQWEq39HpPLi`.
## VERIFICACIÓN
- Borradas: `GET` → HTTP 400 ambas. Re-enlaces: `GET` confirma el link nuevo en ambas.
- Re-sync Marca: `sync_account` → 1339 opps (antes 1341).
- Re-audit: **diff opps = 0** (1339 = 1339), contactos = 0. Descuadre resuelto.
## EDGE-CASES / TRAMPAS
- GHL devuelve **400 (no 404)** para opp borrada. Tratar 400 como "no existe".
- El campo link sale bajo clave `fieldValue` en `GET /opportunities/{id}` (no `value`). Ver [[ghl_opportunity_search_quirks]].
- No confundir este caso con stale cache: aquí el caché estaba fresco. Verificar SIEMPRE frescura primero igual.
- Patricia/Lizeth NO se borran (son la única réplica 1:1); borrarlas habría creado un faltante 2. Distinguir "sobra" (contacto tiene otra opp con link válido) de "mal enlazada" (única opp).
## REUTILIZABLE
Snippet de detección de dead-link replicas (no lo cubre ningún bucket del audit):
```python
import sqlite3, json, paths
BRAND='GbKkBpCmKu2QmloKFHy3'; DEMOS={'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'}
c=sqlite3.connect(str(paths.DB_PATH)); c.row_factory=sqlite3.Row
LINK='j029pu3OU02ATNccJR6l' # field_id opportunity.id_oportunidad_sucursal en Marca
def extract(cf,fid):
if not cf: return None
for f in (json.loads(cf) or []):
if isinstance(f,dict) and f.get('id')==fid: return f.get('value') or f.get('fieldValue')
branch_ids={r['id'] for r in c.execute("SELECT id FROM opportunities WHERE location_id NOT IN (?,?,?)",(BRAND,*DEMOS))}
for o in c.execute("SELECT id,name,contact_id,custom_fields_json FROM opportunities WHERE location_id=?",(BRAND,)):
lv=(extract(o['custom_fields_json'],LINK) or '').strip()
if lv and lv not in branch_ids: print('DEAD-LINK', o['id'], o['name'], '->', lv)
```
Verificación live de un dead-link: `GET https://services.leadconnectorhq.com/opportunities/{id}` con header `Version: 2021-07-28` → 400 = borrada.
## CAUSA DE FONDO (atacada 2026-05-30)
Investigación del workflow `Cfgwp0bOtDW8zuKW` (vía API n8n) reveló que **NO se puede arreglar en el workflow**: el trigger es un Webhook de creación/actualización de opp (payload con datos de vehículo/fuente); **GHL NO dispara evento de borrado de opp**. Por tanto una cascada de borrado en tiempo real (borrar la réplica de Marca cuando la opp de sucursal se borra) es **inviable** — no hay disparador. Además `Decidir Match` ya está correcto para opps vivas (Baserow global tabla 754 → fallback contacto → CREATE). Gap secundario detectado: `Crear Oportunidad - MARCA` y `Actualizar Oportunidad - MARCA (v2)` no tienen salida → no hacen upsert a Baserow en tiempo real (frescura solo por backfill cada 30 min); no es la causa del dead-link.
**Solución desplegada = reconciliador determinista periódico** (backstop que converge dead-links a 0 sin importar cómo surjan):
- `scripts/reconcile_brand_deadlink_opps.py`: detección cache (link no en set de ids de sucursal) → verificación EN VIVO (GET 400) → `classify()` puro (DELETE réplica obsoleta / RELINK id rotado / SKIP ambiguo o cache stale) → snapshot + script_audit. Dry-run default. Registrado en `SCRIPTS_METADATA` (dashboard, mutator).
- `scripts/scheduled_deadlink_check.py` + `run_deadlink_check.bat` + Tarea Programada Windows **"MP Deadlink Check"** (diaria 7:15am): corre dry-run con `--resync-first` y deja `generated/runtime/deadlink_check_alert.json` si hay accionables; el owner aplica desde el dashboard (protocolo dry-run).
- **Validación 100%:** self-test 8/8 (`--self-test`, lógica de decisión); E2E RELINK en vivo (corromper link→fake, detect→GET400→relink al id vivo, auto-reversible); E2E DELETE en vivo (opp desechable creada+borrada); dry-run real = 0; descuadre global = 0.
## GAP SECUNDARIO CERRADO (2026-05-30)
Cableado el **upsert a Baserow en tiempo real** tras CREATE/UPDATE en `Cfgwp0bOtDW8zuKW` (sus salidas estaban vacías). `n8n/_add_baserow_opp_upsert.py --apply` agregó 2 nodos (`Preparar Upsert Mapeo` + `Crear Mapeo - Baserow`), diseño create-only condicional con `onError=continueRegularOutput` (no puede romper la replicación). Cierra la ventana de 30 min del backfill: una opp recién replicada queda mapeada al instante → una re-ejecución hace UPDATE, no CREATE duplicado. **Validado E2E live** replicando el webhook real (exec 52872 crea la fila; exec 52873 re-dispara → match Baserow → no duplica). Ver [[n8n_opp_idempotency_baserow_mapping]].
## PENDIENTES
- Considerar agregar al audit un bucket "dead-link" (link de Marca no presente en ningún id nativo de sucursal) para que la Comparativa lo reporte sin correr el reconciliador.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[positive_descuadre_stale_cache]], [[ghl_opportunity_search_quirks]], [[duplicate_resolution_rules]], [[n8n_opp_sync_match]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/audit_brand_vs_branches_totals.py, scripts/backfill_opp_sucursal_link.py (patrón PUT relink)
- Snapshot: generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json
@@ -0,0 +1,85 @@
---
id: CASE-2026-05-30-descuadre-opp-replica-duplicada-marca
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca - Monte Providencia)"]
run_ids: ["cdo-pilot-180d299cdd (piloto ANSELMO, 1 borrado)", "cdo-batch-13ce0ae8c0 (lote, 7 borrados)"]
snapshots: ["generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092546.json (piloto)", "generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092637.json (lote)"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[n8n_opp_sync_match]]", "[[matching_rules]]", "[[form_submissions_source_of_truth]]", "[[duplicate_resolution_rules]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md", "docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---
## TRIGGERS
- `DIFERENCIA OPORTUNIDADES +10` / descuadre positivo Marca > Sucursales
- `réplicas duplicadas en Marca` / `mismo ID Oportunidad Sucursal` / dos opps de Marca apuntan a la misma opp de sucursal
- bucket `opportunities_in_brand_duplicate_link`
- workflow n8n `Cfgwp0bOtDW8zuKW` ("Sincronizar Oportunidad - Nodos Nuevos (Create/Update)") hace CREATE en vez de UPDATE
- nodo `Decidir Match (Create vs Update)`, `Buscar Oportunidades del Contacto - MARCA`
- `cleanup_brand_orphan_opportunities.py` reporta CERO huérfanas pero el descuadre sigue positivo
- caso antelmo↔anselmo / mismo teléfono distinto contacto
## SÍNTOMA
Dashboard 2026-05-30: OPORTUNIDADES MARCA 1349 vs SUCURSALES (suma) 1339 → **DIFERENCIA +10** (descuadre positivo). Contactos cuadrados (0).
## DIAGNÓSTICO
1. `python scripts/cleanup_brand_orphan_opportunities.py`**0 huérfanas**; 1345/1349 opps protegidas por el link "ID Oportunidad Sucursal". Si todas tienen contraparte y aun así Marca>Sucursales, la única explicación es **varias opps de Marca apuntando al MISMO id de sucursal**.
2. Query de unicidad del link (extract_opp_link_value + OPP_ID_PATTERN sobre `opportunities` location Marca, agrupando por valor): **8 valores de link con 2 opps de Marca cada uno → 8 opps sobrantes**. Confirmado.
3. `createdAt` en vivo (el cache SQLite NO guarda fecha — `opportunities.date_added` es NULL): cada par = 1 original (creado 2026-04-24..05-22) + 1 duplicado **creado en ráfaga 2026-05-29 ~20:4821:14 hora local (02:4803:14 UTC del 05-30)**, con `updated==created` (intactos).
4. n8n `Cfgwp0bOtDW8zuKW`: el nodo `Decidir Match (Create vs Update)` busca opps **acotado al contacto de Marca resuelto** (`/opportunities/search?contact_id=<Set Contact ID Resuelto>`) y compara el CF "ID Oportunidad Sucursal" (`fieldValueString ?? fieldValue`) contra el id de la opp de sucursal. Sin match → CREATE (sin fallback por nombre, intencional).
5. **Evidencia decisiva**: para 2 pares, `keep.contactId != delete.contactId`. La búsqueda del nodo por el contacto del KEEP SÍ devuelve el CF (bajo `fieldValueString`) → el código de match está bien. El fallo es que original y duplicado cuelgan de **contactos de Marca distintos**:
- ANSELMO: `lGfjbkKEB25jittwcKLd` "ANSELMO SANCHEZ" (tel +525523396616, temo6715@gmail.com, 05-20) vs `KEo4p3e5OvWAvosnYrtT` "Antelmo López Rodríguez" (**mismo tel**, cuauhplayer@gmail.com, 05-27).
- MIGUEL: `RwxMQr0Ywvydjr3veCYo` "Miguel Angel" (tel +527775114949, ene-08) vs `hE9U9Q62Xgd0wPeq6L80` "MIGUEL ANGEL" (**tel distinto, sin email**, 05-28).
## CAUSA RAÍZ
Una **misma opp de sucursal** se replicó a Marca **dos veces, cada vez sobre un contacto de Marca diferente**, porque la identidad del contacto en Marca es ambigua (mismo teléfono con nombre/email variante; o teléfono distinto sin email). El nodo `Decidir Match` evalúa la llave de idempotencia (id de opp de sucursal) **solo dentro del contacto resuelto ese run**, no de forma global en Marca. Al resolver un contacto distinto al que tiene la opp original, no la encuentra y hace CREATE → réplica duplicada. (No es un bug del lector de CF; es scope del match + duplicidad/ambigüedad de contacto.)
## ACCIÓN
- **Detección (read-only, aplicada):** nuevo bucket `opportunities_in_brand_duplicate_link` en `scripts/audit_brand_vs_branches_totals.py` (agrupa opps de Marca por valor de link; >1 = duplicado; recomienda keep/delete por jerarquía). Expuesto en dashboard (tarjeta + export CSV `bucket=brand_duplicate_link_opps`).
- **Limpieza (mutador, dry-run validado, PENDIENTE de aplicar):** `scripts/cleanup_brand_duplicate_replica_opps.py`. Detecta clusters vía audit, trae cada opp en vivo, conserva la canónica (jerarquía: valor → status → createdAt más antiguo) y borra las sobrantes. Snapshot en `generated/migrations/` + `script_audit` (reversible por run_id). Endpoint `POST /api/comparativa/cleanup-duplicate-opps` + botón "Limpiar duplicados" + registrado en `script_runner.py`.
- Dry-run: 8 clusters / 8 a borrar; en los 8 conserva el original antiguo y borra el duplicado del 2026-05-30. Comando: `python scripts/cleanup_brand_duplicate_replica_opps.py` (dry-run) → `--apply --run-id <uuid>`.
- **Raíz n8n (PENDIENTE de confirmación del owner):** endurecer `Cfgwp0bOtDW8zuKW` para que la idempotencia no dependa del contacto resuelto. Ver PENDIENTES.
## VERIFICACIÓN
- Antes: bucket `opportunities_in_brand_duplicate_link` = 16 items / 8 grupos / 8 sobrantes; `diff.opportunities` = +10; brand opps = 1349.
- **Después (CONFIRMADO 2026-05-30):** piloto borró 1 (ANSELMO `prRKgLINCgclX9V3O6R0`, verificado: GET da 400 "deleted", la canónica `yjiU8pjCkohiPpJGZlH6` permanece). Lote borró 7 (CESAR, JOSE LUIS ARQ, SANTIAGO FLORES, "d", Alfonso Mendoza, MARIA DE LOS ANGELES, MIGUEL ANGEL). Re-sync de Marca → **brand opps 1349 → 1341 (8)**, bucket duplicados = **0**, `diff.opportunities` = **+10 → +2**.
- El **+2 residual** es estructural: opps de Marca cuyo origen vive en cuenta demo/excluida de la suma filtrada (1339), NO duplicados (bucket=0 y orphan-check=0). Benigno; documentar si se desea cero absoluto.
- Comando de verificación: `python scripts/audit_brand_vs_branches_totals.py --json` y leer `missing.opportunities_in_brand_duplicate_link`.
- GHL responde **400** (no 404) al GET de una opp borrada → `fetch_opp_live` trata 400/404/"deleted"/"doesn't exist" como inexistente (fix tras el 1er intento de lote que crasheó en fase de planificación, sin borrar nada).
## EDGE-CASES / TRAMPAS
- `cleanup_brand_orphan_opportunities.py` NO ve este problema: trata el link como salvaguarda y nunca verifica unicidad. No concluir "todo limpio" con ese script en un descuadre positivo.
- `opportunities.date_added` es NULL en el cache → para fechar "cuándo surgió" hay que ir en vivo (`GET /opportunities/{id}``createdAt`). El limpiador desempata por createdAt en vivo, no por el cache.
- Al borrar la opp duplicada queda un **contacto de Marca posiblemente huérfano de opp** (p.ej. "Antelmo López"). Es un problema de contacto aparte (ver `delete_intra_brand_duplicates.py` + [[matching_rules]]); el limpiador de opps NO lo toca.
- Phone solo nunca es match ([[matching_rules]]): ANSELMO/Antelmo comparten tel pero son contactos distintos en Marca; no fusionar a ciegas.
## REUTILIZABLE
```bash
# Detectar opps de Marca con link duplicado (read-only):
python scripts/audit_brand_vs_branches_totals.py --json # -> missing.opportunities_in_brand_duplicate_link
# Limpiar (dry-run -> piloto -> lote):
python scripts/cleanup_brand_duplicate_replica_opps.py
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link <id_opp_sucursal> --apply --run-id <uuid>
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --run-id <uuid>
# Inspeccionar workflow n8n de sync de opps:
python -c "import sys;sys.path.insert(0,'scripts');import n8n_workflow_lib as l;c=l.N8NClient(*l.load_credentials());import json;print(json.dumps(c.get_workflow('Cfgwp0bOtDW8zuKW')['nodes'],ensure_ascii=False)[:2000])"
```
## FIX PREVENTIVO n8n (APLICADO 2026-05-30) — opción (c) mapeo Baserow
Idempotencia GLOBAL por id de opp de sucursal, independiente del contacto.
- **Tabla Baserow creada por API**: DB 63, **table_id=754** "Mapeo Opp Sucursal-Marca": `id_opp_sucursal` (primario, field 7280), `id_opp_marca` (7283), `location_id_sucursal`, `updated_at`. Se extendió `scripts/baserow_client.py` con `create_table/create_field/update_field/delete_field`.
- **Backfill**: `scripts/backfill_baserow_opp_mapping.py --table-id 754 --apply` → 1341 mapeos (0 duplicados). Gotcha: el JWT de Baserow EXPIRA a mitad (~1004 creates → 401 ERROR_INVALID_ACCESS_TOKEN); re-ejecutar (idempotente, `ya_ok`) completa el resto.
- **Rewire `Cfgwp0bOtDW8zuKW`** (`n8n/_add_baserow_opp_idempotency.py --apply`; backup `n8n/backup_pre_baserow_opp_idempotency_*.json`; versionId nuevo `9caa764a-...`): nodo Baserow `Buscar Mapeo Opp - Baserow` (getAll tabla 754, filtro field 7280 == opp_id sucursal, `alwaysOutputData`+`onError=continueRegularOutput`) insertado `Set Contact ID Resuelto → [lookup] → Buscar Oportunidades del Contacto - MARCA → Decidir Match`. `Decidir Match` reescrito: si mapeo Baserow → UPDATE global; si no, fallback CF por contacto; si nada → CREATE. Degrada a la lógica previa si Baserow falla (try/catch).
- **Validación (data-layer, segura)**: lookup por link mapeado devuelve 1 fila con `id_opp_marca` correcto (ANSELMO/MIGUEL); link inexistente → 0 filas. NO se disparó webhook real (evita mutar producción); la próxima sync real ejercita el path.
- **Frescura**: Tarea Programada Windows **"MP Baserow Opp Mapping"** (cada 30 min, `run_baserow_opp_mapping.bat`) corre el backfill idempotente para mapear opps nuevas.
## PENDIENTES
1. (Opcional) Confirmar con webhook real / próxima ejecución de producción que el path UPDATE-vía-Baserow funciona E2E (riesgo bajo: degrada a lógica previa si algo falla).
2. Investigar si el evento del 2026-05-29 ~21:00 (re-disparo masivo) fue corrida manual/replay y acotarlo.
3. (Mejora) Persistir `createdAt`/`updatedAt` de GHL en `opportunities` (`db.py`) para no depender de GET en vivo al fechar.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[n8n_opp_sync_match]], [[matching_rules]], [[form_submissions_source_of_truth]], [[duplicate_resolution_rules]], [[name_account_with_location_id]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/cleanup_brand_duplicate_replica_opps.py, scripts/audit_brand_vs_branches_totals.py
- Workflow: n8n Cfgwp0bOtDW8zuKW
@@ -0,0 +1,72 @@
---
id: CASE-2026-05-30-sucursal-tag-en-leads-digitales
fecha: 2026-05-30
categoria: custom_field, cascada_n8n
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "HvDw9Eg3rjrwkbQJXqfi (Marina Nacional)", "R34lUVVpltnB8Z1RqnEB (Satélite)", "KEZ7dAhgwzK4uZfMvZuj (Puebla)", "nRSeOhlhQ3vyirTKYhPi (Villas del Sol)", "nF1uEaYB3mCK5em9bPn2 (Eugenia)", "rQYjjwsGnjEGagskOxix (Tulyehualco)", "XkduzafvwsrWcEFg6Qlj (Cd. Carmen)"]
run_ids: ["pilot-sucursal-tag-20260530013645", "batch-sucursal-tag-20260530013808", "fix-miza-canal-fuente-20260530015547"]
snapshots: []
status: resuelto
memorias: ["[[createdby_only_in_individual_get]]", "[[super_script_fix_branch_user_origin]]", "[[n8n_realtime_replication]]", "[[ghl_tags_api]]", "[[custom_fields_picklist_alignment]]", "[[feedback_dry_run_protocol]]"]
playbooks: []
---
## TRIGGERS
- `tag sucursal y facebook-ads juntos`, `conflicto facebook-ads sucursal`, `falsa atribución digital`
- `createdBy.source INTEGRATION channel OAUTH`, `origen real facebook ads vs creado por sucursal`
- `quitar tag sucursal lead digital`, `audit_origin_for_contact_list.py`, `fix_remove_sucursal_tag_digital.py`
- `n8n no re-añade tag sucursal`, `tag se queda quitado tras 60s`
- `Miza Olguin Puebla`, `contacto Canal SUCURSAL pero opp FORMULARIO`, `Fuente PROSPECCIÓN no sobrescribir`
## SÍNTOMA
Export de 87 contactos de Marca con tags `facebook-ads` + `sucursal` simultáneos, `CANAL DE ORIGEN=FACEBOOK`, `Fuente de Prospecto=LEAD DIGITAL`. El usuario sospechaba que algunos eran **creados a mano por staff de sucursal** que pusieron apariencia digital falsa (tag facebook-ads + Fuente "redes sociales/LEAD DIGITAL").
## DIAGNÓSTICO
La columna `source` de SQLite guarda el `source` NATIVO ("Facebook"/"Formulario"), NO `createdBy.source` ([db.py:300](../../db.py#L300)). Y es poco confiable (un contacto de sucursal tenía `source=null` mientras Marca decía "Formulario"). `createdBy.source` SOLO viene en `GET /contacts/{id}` individual ([[createdby_only_in_individual_get]]).
Como los contactos del export son de **Marca** (réplica n8n → su createdBy siempre es INTEGRATION), el origen real está en el **contacto de la sucursal**. Resolución Marca→Sucursal por CF `id_contacto_sucursal` (id Marca `E6lI9ykWhqpj7Pmi7Qd3`) → buscar la location en SQLite (`SELECT location_id FROM contacts WHERE id=?`) → GET en vivo en esa sucursal.
Script: `python scripts/audit_origin_for_contact_list.py --csv generated/reports/origin_audit/_input_ids.csv` (read-only). Resultado: **87/87 = INTEGRATION (REAL_DIGITAL)**, 0 manuales. Verificación cruda: `createdBy = {"source":"INTEGRATION","channel":"OAUTH",...}`. El detector SÍ distingue manuales (un contacto de control de otra cuenta salió `WEB_USER`).
**Callejón descartado:** la nota de agente + Canal=SUCURSAL de Miza NO prueban creación manual; su contacto de sucursal es INTEGRATION (entró digital, un agente la reetiquetó/anotó después).
## CAUSA RAÍZ
1. Los 87 son leads de **origen digital** (FB Ads/formulario → integración). El tag `facebook-ads` y `LEAD DIGITAL` son **correctos**. La hipótesis de "fake digital" quedó **refutada**.
2. La etiqueta que sobra y genera el conflicto es **`sucursal`** (en la taxonomía MP `sucursal` y `facebook-ads` son tags de ORIGEN mutuamente excluyentes — ver `fix_branch_user_origin.py`).
3. Caso aislado: **Miza Olguin** tenía CANAL/Fuente del CONTACTO = SUCURSAL, pero su **opp** ya estaba en Canal=FORMULARIO / Fuente=PROSPECCIÓN. Su origen real es **formulario** (no facebook-ads).
## ACCIÓN
Dry-run → piloto (1 contacto) → batch, protocolo [[feedback_dry_run_protocol]].
1. **Quitar tag `sucursal`** de los 87 en **ambos lados** (Marca + sucursal), `scripts/fix_remove_sucursal_tag_digital.py` (mutador, dry-run default, bucle quitar→esperar 60s→verificar para vencer la carrera n8n):
- Piloto: `--only iR4fS0f2fOGB75vtVZMP --apply --run-id pilot-sucursal-tag-20260530013645` → Ronda 2 = 0 con tag (n8n NO lo re-añade).
- Batch: `--apply --run-id batch-sucursal-tag-20260530013808` → 172 lados limpiados, Ronda 2 = 0.
2. **Miza** (inline, `run_id fix-miza-canal-fuente-20260530015547`): contacto Canal SUCURSAL→**FORMULARIO**, Fuente SUCURSAL→**LEAD DIGITAL** en Marca + Puebla. **La opp NO se tocó** (ya correcta: FORMULARIO/PROSPECCIÓN). Cero opps creadas/modificadas.
## VERIFICACIÓN
Sweep final independiente (174 lados, GET vivo): **0 con tag `sucursal`**, **0 perdieron `facebook-ads`**. Miza verificada: Canal=FORMULARIO, Fuente=LEAD DIGITAL en ambos lados tras 60s. Resultado: cambio 100% efectivo.
## EDGE-CASES / TRAMPAS
- **No basta el `source` nativo ni la caché**: usar `createdBy.source` en vivo del contacto de **sucursal** (no de Marca).
- **`facebook-ads` era CORRECTO**: la instrucción inicial era quitar facebook-ads; los datos dijeron quitar `sucursal`. Confirmar dirección con el usuario antes de mutar evitó corromper 87 leads legítimos.
- **Opp vs contacto pueden divergir**: Miza tenía contacto=SUCURSAL pero opp=FORMULARIO. Investigar la opp ANTES de elegir el valor del contacto (puse FORMULARIO, no FACEBOOK, para no inyectar inconsistencia contacto-vs-opp).
- **No sobrescribir Fuente de opp** = PROSPECCIÓN (valor de negocio).
- **n8n NO re-añade el tag `sucursal`** a contactos INTEGRATION (piloto lo probó). Quitar un tag no crea opps → cero riesgo de duplicados de opp.
- `DELETE /contacts/{id}/tags` con body `{"tags":[...]}` (el path `/tags/{name}` da 404) — ya manejado por `remove_contact_tag` ([[ghl_tags_api]]).
## REUTILIZABLE
```bash
# Auditar origen real de una lista de contactos de Marca (read-only)
python scripts/audit_origin_for_contact_list.py --ids "ID1,ID2,..." # o --csv <ruta>
# Quitar tag sucursal de los REAL_DIGITAL (dry-run -> piloto -> batch)
python scripts/fix_remove_sucursal_tag_digital.py # dry-run
python scripts/fix_remove_sucursal_tag_digital.py --only <marca_id> --apply --run-id pilot-...
python scripts/fix_remove_sucursal_tag_digital.py --apply --run-id batch-...
```
## PENDIENTES
- Investigar **por qué** estos digitales recibieron el tag `sucursal` históricamente (qué workflow/migración lo puso). No se re-añade hoy, pero conviene cerrar la fuente para que no reaparezca en leads futuros.
## ENLACES
- Scripts: `scripts/audit_origin_for_contact_list.py`, `scripts/fix_remove_sucursal_tag_digital.py`
- Artefactos: `generated/reports/origin_audit/origin_audit_20260530_012600.csv` (+ `_input_ids.csv`)
- Memorias: [[createdby_only_in_individual_get]], [[super_script_fix_branch_user_origin]], [[n8n_realtime_replication]], [[ghl_tags_api]]
@@ -0,0 +1,80 @@
---
id: CASE-2026-05-30-verificador-tipo-de-tienda-colapso
fecha: 2026-05-30
categoria: config_location | cascada_n8n | custom_field
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "uZnMH5bO6MXTHcgHeyq9 (85935 Pilares = hub digital Toluca/Metepec/Lerma)", "NSDniGzjxotVDNa5YxqW (85937 Metepec, VACÍA)", "jE41bVhhnb5T505BFm4F (85964 Morelia 1)"]
run_ids: []
snapshots: ["generated/migrations/baserow_verificador_pre_tipo_tienda_750_20260530_005359.json", "generated/migrations/baserow_verificador_pre_restore_nodigital_suc_750_20260530_011528.json", "n8n/backup_add_tipo_filter_*_20260530_0115*.json (3 workflows)"]
status: resuelto
memorias: [[verificador_tipo_de_tienda_colapso]], [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]]
playbooks: []
---
## TRIGGERS
- `TIPO DE TIENDA`, `field 7279`, `NO DIGITAL`, `PARCIAL`, `FULL AUTOS`, `Verificador 750 columna tipo de tienda`
- `colapsar filas digitales`, `1 fila canónica por location`, `SUCURSAL contains más de un resultado`
- `Toluca 3 filas Pilares`, `hub digital Pilares`, `Metepec 85937 vacío`, `Metepec routing Pilares`
- `no-digital 0 contactos`, `premisa formulario no-digital`, `fix_baserow_tipo_de_tienda.py`, `baserow delete_row`
## SÍNTOMA
Leads "Formulario - Sitio Web" de zonas multi-tienda (Toluca/Metepec/Lerma) con TIENDA inconsistente/vacía (ver caso 2026-05-29). El owner agregó columna `TIPO DE TIENDA` a Baserow 750 y propuso que n8n excluya `NO DIGITAL`. Pidió estudiar implicaciones e iterar hasta confirmar funcionamiento en vivo.
## DIAGNÓSTICO (read-only)
1. **3 workflows** leen 750 (no 2): `ddUEORBEtZLzsQF2` [2004] (escribe `contact.tienda` por SC BUCEFALO `equal` limit 1), `4UMRwxJdHFfOGHBp` [1604] Marca→Sucursal (por SUCURSAL `contains` limit 3, toma row[0]), `EuPdIkCORyh0skoB` [SUCURSAL] (2 nodos 750). `x4DqZ5FtSc43tdzB` y `Cfgwp0bOtDW8zuKW` NO tocan 750.
2. **Premisa validada en vivo** (`get_all_contacts`): 85938/85939/85940/85941/85965 = **0 contactos**. Y **85937 Metepec = 0**. Pilares 85935 = 87 contactos, TIENDA consenso `PILARES` (85/87). Morelia 1 = 60, `MORELIA 1`.
3. **Contradicción CSV vs datos:** el CSV editado enrutaba Metepec→85937, pero 85937 está vacío; los leads de la zona caen en Pilares. Decisión del owner: **Metepec→Pilares** (modelo hub).
4. Estado live 750 (52 filas): Toluca = 3 filas →Pilares (GRAND PLAZA/ISIDRO FABELA/INDEPENDENCIA, TIPO vacío) = **ambiguo**. Metepec = 1 digital + 2 NO DIGITAL dup. Morelia/otros = OK. Fila 258 = junk (TIPO literal="TIPO DE TIENDA"). Filas NO DIGITAL de Toluca (Isidro/Grand/Indep) ya existían huérfanas (sin SUCURSAL).
## CAUSA RAÍZ
Ambigüedad estructural en Baserow 750: **varias filas por el mismo texto SUCURSAL** (sin colapsar). El filtro NO DIGITAL solo, NO basta para Toluca (las 3 filas eran todas digitales →Pilares con TIENDA distinta). Hace falta **colapsar a 1 fila canónica por location digital**.
## ACCIÓN (Baserow 750, reversible vía backup)
Script `scripts/fix_baserow_tipo_de_tienda.py` (dry-run→apply, `backup_table` previo). Se agregó `delete_row()` a `scripts/baserow_client.py`.
1. **Routing (piloto):** fila 241 Toluca→`TIENDA=PILARES, TIPO=FULL AUTOS`; **borrar** 242, 243 (dups Toluca). fila 244 Lerma→`PILARES/FULL AUTOS`. **borrar** 254 (dup Metepec) y 258 (junk).
2. **Labels (batch):** `TIPO DE TIENDA=PARCIAL` en 31 filas single-store vacías.
3. **Consistencia:** blanquear `SUCURSAL` de la fila 240 (Metepec NO DIGITAL) → patrón referencia-only.
### ACCIÓN 2 — Filtro en n8n (2026-05-30, a pedido del owner)
Tras el fix de Baserow, el owner pidió **implementar el filtro explícito** y dejar las filas NO DIGITAL CON su SUCURSAL para que el filtro actúe de verdad (load-bearing). Script `n8n/add_tipo_de_tienda_filter.py` (backup por workflow + dry-run + apply + activate + verify, idempotente).
1. **Operador confirmado empíricamente:** query directa Baserow REST → `not_equal` OK (excluye las 6 NO DIGITAL, 0 fugas); `is_not``ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST` (la suposición del agente era falsa); `contains_not` también sirve pero `not_equal` es el semántico.
2. Se agregó `{field:7279, operator:"not_equal", value:"NO DIGITAL"}` a `additionalOptions.filters.fields` (AND con el filtro existente) en: `4UMRwxJdHFfOGHBp` "Obtener Info de cuenta objetivo - SUCURSAL" (**load-bearing**), `ddUEORBEtZLzsQF2` "Buscar Sucursal en Verificador de Sucursales" (defensa), `EuPdIkCORyh0skoB` "Buscar Cuenta Sucursal Bucefalo" (defensa). Los 3 quedaron `active=true`, verificado post-PUT.
3. Se **restauró la SUCURSAL** de las 6 filas NO DIGITAL (240/252/253/255/256/257) → ahora el `contains` matchea digital+no-digital y el filtro excluye las no-digital.
## VERIFICACIÓN
- `list_rows(750)` (48 filas tras borrar 4): **las 41 SUCURSAL resuelven a exactamente 1 fila digital** (0 ambiguas). Toluca/Metepec/Lerma `contains=1`→PILARES; Morelia→MORELIA 1.
- SC BUCEFALO `85935 - MP - Pilares` → 3 filas (226/241/244) **todas TIENDA=PILARES** → [2004] limit 1 determinista.
- Pilares live: 64 leads formulario, **0 con TIENDA vacía** (sin rezagados; no requiere backfill nuevo).
- **Filtro (con/sin) por query directa:** Toluca 4→1, Metepec 2→1, Lerma 2→1, Morelia 2→1 → en todas la fila que queda es la digital correcta. Prueba que el filtro es load-bearing y funciona.
- **E2E REAL en vivo:** POST al webhook de [1604] con lead `qa-test` Sucursal=Metepec → ejecución `52776` success, nodo Baserow devolvió **1 fila PILARES** (excluyó la NO DIGITAL de Metepec), réplica creada en Pilares (`DjOIZgekf3Sy2B49AWKp`) con **TIENDA=PILARES** (escrita por [2004]). Contactos de prueba (Marca `h9VXb9jy6ix9v0e5KHaY` + réplica) **borrados y verificados** (GET→error). El nodo "Envio a tienda" mandó 1 correo a la tienda (autorizado por el owner).
## EDGE-CASES / TRAMPAS
- El store **Metepec 85937 está vacío en vivo**; NO es el target digital de Metepec (lo es Pilares). El backfill de Jorge Enrique→Pilares (caso 2026-05-29) era correcto; la edición del CSV→85937 se descartó.
- Las filas NO DIGITAL se dejan **sin SUCURSAL** a propósito (referencia-only) para que el `contains` no las matchee → fix sin depender de un filtro en el workflow.
- **Operador Baserow para "≠" = `not_equal`** (NO `is_not`, que no existe). Validar SIEMPRE el operador con query directa antes de PUTear el nodo.
- El nodo Baserow n8n pasa `operator` tal cual al filter type de Baserow; el E2E confirmó que `not_equal` funciona en el nodo en vivo.
- E2E real del [1604] dispara "Envio a tienda" → **1 correo a la tienda** por cada lead de prueba. Usar `qa-test` y nombre "QA TEST" visible, y borrar Marca+réplica al terminar.
- `baserow_client` no tenía `delete_row` (se agregó). Mutaciones reversibles vía `backup_table`/`backup_workflow`.
## REUTILIZABLE
```python
# Unicidad de resolución por SUCURSAL (lo que ve el workflow)
from collections import defaultdict
from scripts.baserow_client import BaserowClient
rows = BaserowClient.from_credentials().list_rows(750)
by = defaultdict(list)
for r in rows:
s = str(r.get("SUCURSAL") or "").strip()
if s and s != "-": by[s].append(r)
amb = {s: rs for s, rs in by.items()
if len([r for r in rs if str(r.get("TIPO DE TIENDA") or "").upper() != "NO DIGITAL"]) != 1}
# amb vacío => toda SUCURSAL resuelve a 1 fila digital
```
## PENDIENTES
- Ninguno. Filtro desplegado en los 3 workflows + E2E en vivo OK. (Si en el futuro se agregan filas, recordar: NO DIGITAL nunca recibe formulario; el filtro `not_equal` las excluye.)
- Rollback disponible: `n8n/backup_add_tipo_filter_*` (workflows) y `generated/migrations/baserow_verificador_pre_*` (Baserow).
## ENLACES
- Memorias: [[verificador_tipo_de_tienda_colapso]], [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]]
- Scripts: `scripts/fix_baserow_tipo_de_tienda.py`, `scripts/baserow_client.py` (+`delete_row`)
- Caso previo: [2026-05-29-tienda-vacia-formulario-sitio-web.md](2026-05-29-tienda-vacia-formulario-sitio-web.md)
+47
View File
@@ -0,0 +1,47 @@
# Registro de casos — MP Manager
> **Qué es esto:** bitácora cronológica de operaciones e investigaciones reales sobre Bucéfalo (GHL).
> Optimizada para **recall del agente**, no para humanos. Cada caso = síntoma → diagnóstico (con
> comandos exactos) → causa raíz → acción (run_ids) → verificación → edge-cases → snippets reutilizables.
>
> **Cómo encaja con el resto:**
> - `docs/PLAYBOOK_*.md` = **teoría atemporal** (metodología, taxonomía). No cambian por caso.
> - `memory/*.md` = **hechos atómicos** recuperables (reglas, gotchas, bugs). Indexados en `MEMORY.md`.
> - **Este registro** = **narrativa investigable** de cada operación, con comandos y artefactos. Liga todo.
>
> No dupliques: enlaza al playbook para la teoría y a la memoria para los hechos; aquí va lo específico del caso.
## Cuándo registrar un caso (disparador)
Crea una entrada **siempre que**:
1. **Mutes Bucéfalo** (cualquier escritura a GHL con `run_id`/snapshot), o
2. **Cierres una investigación no trivial** que llegue a una **causa raíz** (aunque no haya mutación).
No registres: lecturas triviales, consultas de un solo dato, o trabajo puramente de código del repo (eso va a git).
## Cómo registrar
1. Copia [`_PLANTILLA.md`](_PLANTILLA.md) a `docs/casos/YYYY-MM-DD-<slug>.md` y rellena (denso, comandos exactos, ids literales).
2. Agrega la fila a la tabla de abajo (más reciente arriba).
3. Crea/actualiza la(s) **memoria** relacionada(s) y enlázala(s) con `[[slug]]` desde el caso.
4. Si el caso revela teoría nueva reutilizable, considera además actualizar el `PLAYBOOK_*` correspondiente.
## Categorías
`descuadre` · `enlace_opp` · `duplicado` · `fantasma` · `cascada_n8n` · `custom_field` · `config_location` · `playwright` · `otro`
## Casos (cronológico inverso)
| Fecha | ID | Categoría | Triggers (keywords para grep) | Status | Enlace |
|---|---|---|---|---|---|
| 2026-05-30 | CASE-2026-05-30-comparativa-auditoria-completa-buckets | descuadre, cascada_n8n, config_location | `Comparativa Marca vs Sucursales auditar todos los buckets`, `opps +1 opportunities_in_brand_duplicate_link`, `present_in_other_branch_not_assigned 84 falsos positivos`, `TIENDA=METEPEC vive en Pilares 85935`, `DIGITAL_HUB_BY_SHELL hub-map en código`, `audit lee Verificador CSV no Baserow`, `PATRICIA PARRA zzBzWC4adBrdTA8WhQph réplica abandoned $0`, `redirect Windows cp1252 no utf-8`, `re-sync Marca antes de re-auditar` | resuelto | [caso](2026-05-30-comparativa-auditoria-completa-buckets.md) |
| 2026-05-30 | CASE-2026-05-30-descuadre-opp-deadlink | descuadre, cascada_n8n | `DIFERENCIA OPORTUNIDADES +2`, `descuadre positivo todos los buckets de opps en 0`, `opp Marca link muerto GET 400 Opportunity doesn't exist or is deleted`, `réplica obsoleta n8n no borró id rotado`, `contacto 2 opps Marca 1 sola viva sucursal`, `cleanup scripts no atrapan dead-link único`, `re-enlazar vs borrar opp 1:1` | resuelto | [caso](2026-05-30-descuadre-opp-deadlink.md) |
| 2026-05-30 | CASE-2026-05-30-descuadre-opp-replica-duplicada-marca | descuadre, cascada_n8n, duplicado | `DIFERENCIA OPORTUNIDADES +10`, `descuadre positivo Marca > Sucursales`, `réplicas duplicadas mismo ID Oportunidad Sucursal`, `dos opps Marca misma opp sucursal`, `opportunities_in_brand_duplicate_link`, `Cfgwp0bOtDW8zuKW CREATE en vez de UPDATE`, `Decidir Match Create vs Update`, `cleanup_brand_orphan cero huérfanas pero descuadre`, `antelmo anselmo mismo teléfono distinto contacto`, `cleanup_brand_duplicate_replica_opps.py` | parcial | [caso](2026-05-30-descuadre-opp-replica-duplicada-marca.md) |
| 2026-05-30 | CASE-2026-05-30-sucursal-tag-en-leads-digitales | custom_field, cascada_n8n | `tag sucursal y facebook-ads juntos`, `falsa atribución digital`, `createdBy.source INTEGRATION channel OAUTH`, `origen real facebook ads vs creado por sucursal`, `quitar tag sucursal lead digital`, `audit_origin_for_contact_list.py`, `fix_remove_sucursal_tag_digital.py`, `n8n no re-añade tag sucursal`, `Miza Olguin contacto SUCURSAL opp FORMULARIO`, `no sobrescribir Fuente PROSPECCIÓN` | resuelto | [caso](2026-05-30-sucursal-tag-en-leads-digitales.md) |
| 2026-05-30 | CASE-2026-05-30-verificador-tipo-de-tienda-colapso | config_location, cascada_n8n, custom_field | `TIPO DE TIENDA`, `field 7279`, `NO DIGITAL`, `colapsar filas digitales`, `1 fila canónica por location`, `hub digital Pilares`, `Metepec 85937 vacío`, `premisa no-digital 0 contactos`, `fix_baserow_tipo_de_tienda.py`, `baserow delete_row` | resuelto | [caso](2026-05-30-verificador-tipo-de-tienda-colapso.md) |
| 2026-05-29 | CASE-2026-05-29-tienda-vacia-formulario-sitio-web | custom_field, cascada_n8n, config_location | `TIENDA vacía`, `Formulario - Sitio Web TIENDA`, `Baserow 750 más de un resultado`, `Si hay más de un resultado`, `Toluca Metepec 3 filas`, `SUCURSAL contains ambiguo`, `createdBy INTEGRATION`, `delete Guco Villahermosa` | parcial | [caso](2026-05-29-tienda-vacia-formulario-sitio-web.md) |
| 2026-05-29 | CASE-2026-05-29-backfill-cf-vehiculo-temixco-marca | custom_field, cascada_n8n | `contacto Marca sin datos de vehículo`, `customFields count 0 en Marca`, `réplica Sucursal→Marca sin custom fields`, `Sincronización Sucursal CFs vacíos`, `nombres CF idénticos descarta name-mismatch`, `Temixco`, `Cristhian Hugo`, `cache stale synced_at anterior` | parcial | [caso](2026-05-29-backfill-cf-vehiculo-temixco-marca.md) |
| 2026-05-29 | CASE-2026-05-29-corrector-baserow-verificador | config_location | `Baserow`, `tabla 749 750`, `SC BUCEFALO`, `Verificador desactualizado`, `n8n no encuentra sucursal`, `PLAZA EL SALADO 85932 85956`, `cuentas_oficiales.csv`, `corrector baserow` | resuelto | [caso](2026-05-29-corrector-baserow-verificador.md) |
| 2026-05-29 | CASE-2026-05-29-n8n-2004-canal-origen-tiempo-real | cascada_n8n | `[2004]`, `ddUEORBEtZLzsQF2`, `webhook 8d574598`, `Canal de Origen tiempo real`, `Verificador 750 devuelve 0`, `Eugenia no está en Baserow 750`, `esUsuario IF n8n`, `n8n executions includeData` | resuelto | [caso](2026-05-29-n8n-2004-canal-origen-tiempo-real.md) |
| 2026-05-29 | CASE-2026-05-29-origen-sucursal-contactos-usuario | custom_field | `createdBy.source`, `WEB_USER`, `MOBILE_USER`, `createdBy no viene en el listado`, `fix_web_user_branch_contacts roto`, `origen sucursal`, `no sobrescribir Fuente de Prospecto`, `ALIANZA` | resuelto | [caso](2026-05-29-origen-sucursal-contactos-usuario.md) |
| 2026-05-29 | CASE-2026-05-29-descuadre-opp-multiempeno | descuadre, config_location, duplicado | `descuadre +1 opp`, `Can not create duplicate opportunity`, `allowDuplicateOpportunity`, `multi-empeño no replica`, `link muerto`, `réplica obsoleta` | resuelto (5 mislinks pendientes) | [caso](2026-05-29-descuadre-opp-multiempeno.md) |
+54
View File
@@ -0,0 +1,54 @@
---
id: CASE-YYYY-MM-DD-<slug>
fecha: YYYY-MM-DD
categoria: descuadre | enlace_opp | duplicado | fantasma | cascada_n8n | custom_field | config_location | playwright | otro
location_ids: [] # cuentas tocadas (incluye nombre al lado del id, ver [[name_account_with_location_id]])
run_ids: [] # script_audit run_ids generados (rollback)
snapshots: [] # rutas generated/migrations/*.json
status: resuelto | parcial | pendiente | escalado
memorias: [] # [[slug]] de memorias relacionadas
playbooks: [] # docs/PLAYBOOK_*.md relevantes
---
<!--
COMO USAR ESTA PLANTILLA (para mi, el agente):
- Copia este archivo a docs/casos/YYYY-MM-DD-<slug>.md y rellena.
- Escribe DENSO y para MI uso: comandos exactos copiables, ids literales, errores literales.
- TRIGGERS es lo mas importante: pon las frases que un grep futuro buscaria.
- Documenta tambien las atribuciones EQUIVOCADAS y por que se descartaron — ahorran horas.
- Al terminar: agrega la fila a INDEX.md y enlaza/actualiza la(s) memoria(s).
-->
## TRIGGERS
<!-- Frases/keywords/errores LITERALES que en un caso futuro me harian buscar este caso.
Incluye: sintoma del dashboard, mensajes de error de GHL, nombres de flags/campos, numeros. -->
- `...`
## SÍNTOMA
<!-- El punto de entrada: que se observo, donde, con que numero. -->
## DIAGNÓSTICO
<!-- Pasos READ-ONLY con comandos EXACTOS y que revelo cada uno.
Incluye los callejones sin salida y por que se descartaron. -->
## CAUSA RAÍZ
<!-- La causa CONFIRMADA (no la aparente). Una o varias, numeradas. -->
## ACCIÓN
<!-- Que se muto. Orden dry-run -> piloto -> lote. Comandos exactos, run_ids, snapshots.
Si no se muto (solo investigacion): "Ninguna mutacion; solo diagnostico." -->
## VERIFICACIÓN
<!-- Antes -> despues con numeros. El comando que lo confirma. -->
## EDGE-CASES / TRAMPAS
<!-- Lo que casi sale mal. Falsos positivos. Por que NO hacer X. -->
## REUTILIZABLE
<!-- Snippets/comandos directamente copiables para el proximo caso similar. -->
## PENDIENTES
<!-- Lo que quedo abierto y como retomarlo. -->
## ENLACES
<!-- Memorias [[slug]], playbooks docs/..., scripts scripts/..., artefactos generated/... -->