Primer commit
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sync branch contacts into the main brand account.
|
||||
|
||||
Dry-run is the default. Use --apply to write changes in GHL.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import unicodedata
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
import script_audit # noqa: E402
|
||||
import sync_engine # noqa: E402
|
||||
from common import FIELD_ALIASES, match_contacts # noqa: E402
|
||||
|
||||
MATCH_THRESHOLD = 0.80
|
||||
|
||||
|
||||
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
||||
VERIFIER_CSV = os.path.join(ROOT_DIR, "Monte Providencia - Verificador de sucursales y correos - Sucursales.csv")
|
||||
|
||||
SCALAR_STANDARD_FIELDS = [
|
||||
"firstName", "lastName", "name",
|
||||
"email", "phone",
|
||||
"address1", "city", "state", "country", "postalCode",
|
||||
"dateOfBirth", "companyName", "website", "timezone",
|
||||
"source", "type", "dnd",
|
||||
# NOTA: assignedTo NO se sincroniza cross-location porque los userIds son
|
||||
# por location en GHL. Asignar un user de la sucursal a Marca devuelve 400.
|
||||
]
|
||||
BOOLEAN_STANDARD_FIELDS = {"dnd"}
|
||||
LIST_STANDARD_FIELDS = ["tags"]
|
||||
# NOTA: additionalEmails NO se sincroniza porque GHL lo devuelve como lista de
|
||||
# objetos {validEmailDate, email}, no como lista plana de strings.
|
||||
|
||||
CUSTOM_VALUE_KEYS = (
|
||||
"value", "fieldValueString", "fieldValueDate",
|
||||
"fieldValueNumber", "fieldValueArray",
|
||||
"fieldValueOptions", "fieldValueFile",
|
||||
)
|
||||
|
||||
DIGITAL_SOURCE_KEYWORDS = (
|
||||
"facebook", "instagram", "form", "tiktok", "google",
|
||||
"youtube", "whatsapp", "ads", "lead", "digital",
|
||||
"messenger", "linkedin", "api", "landing",
|
||||
)
|
||||
def clean(value):
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def normalize_phone(phone):
|
||||
digits = re.sub(r"\D+", "", str(phone or ""))
|
||||
return digits[-10:] if len(digits) >= 10 else digits
|
||||
|
||||
|
||||
def normalize_email(email):
|
||||
return clean(email).lower()
|
||||
|
||||
|
||||
def remove_accents(text):
|
||||
if not text:
|
||||
return ""
|
||||
accents_map = {
|
||||
"á": "a", "é": "e", "í": "i", "ó": "o", "ú": "u", "ü": "u", "ñ": "n",
|
||||
"Á": "a", "É": "e", "Í": "i", "Ó": "o", "Ú": "u", "Ü": "u", "Ñ": "n",
|
||||
}
|
||||
return "".join(accents_map.get(char, char.lower()) for char in str(text))
|
||||
|
||||
|
||||
def contact_name(contact):
|
||||
full_name = contact.get("contactName") or contact.get("name")
|
||||
if full_name:
|
||||
return clean(full_name)
|
||||
return clean(f"{contact.get('firstName') or ''} {contact.get('lastName') or ''}")
|
||||
|
||||
|
||||
def normalize_name(contact):
|
||||
name = remove_accents(contact_name(contact))
|
||||
name = re.sub(r"[^\w\s]", " ", name)
|
||||
return " ".join(name.split())
|
||||
|
||||
|
||||
def get_contact_id(contact):
|
||||
return contact.get("id") or contact.get("contactId")
|
||||
|
||||
|
||||
def norm_field_name(name):
|
||||
text = unicodedata.normalize("NFKD", str(name or "").strip().lower())
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def build_alias_lookup():
|
||||
lookup = {}
|
||||
for canonical, variants in FIELD_ALIASES.items():
|
||||
for variant in variants:
|
||||
lookup[norm_field_name(variant)] = canonical
|
||||
return lookup
|
||||
|
||||
|
||||
ALIAS_LOOKUP = build_alias_lookup()
|
||||
|
||||
|
||||
def build_brand_field_indexes(brand_schema):
|
||||
by_norm = {norm_field_name(name): (name, fid) for name, fid in brand_schema.items()}
|
||||
by_canonical = {}
|
||||
for name, fid in brand_schema.items():
|
||||
canonical = ALIAS_LOOKUP.get(norm_field_name(name))
|
||||
if canonical and canonical not in by_canonical:
|
||||
by_canonical[canonical] = (name, fid)
|
||||
return by_norm, by_canonical
|
||||
|
||||
|
||||
def resolve_brand_field(branch_field_name, brand_schema, brand_norm, brand_by_canonical):
|
||||
if branch_field_name in brand_schema:
|
||||
return branch_field_name, brand_schema[branch_field_name]
|
||||
norm = norm_field_name(branch_field_name)
|
||||
if norm in brand_norm:
|
||||
return brand_norm[norm]
|
||||
canonical = ALIAS_LOOKUP.get(norm)
|
||||
if canonical and canonical in brand_by_canonical:
|
||||
return brand_by_canonical[canonical]
|
||||
return None, None
|
||||
|
||||
|
||||
def extract_custom_value(field):
|
||||
for key in CUSTOM_VALUE_KEYS:
|
||||
if key not in field:
|
||||
continue
|
||||
value = field[key]
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
if isinstance(value, (list, dict)) and not value:
|
||||
continue
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def list_value(value):
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return []
|
||||
|
||||
|
||||
def merge_unique(existing, incoming):
|
||||
seen = set()
|
||||
result = []
|
||||
for item in list(existing) + list(incoming):
|
||||
key = item.lower() if isinstance(item, str) else item
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def values_equal(a, b):
|
||||
if isinstance(a, list) and isinstance(b, list):
|
||||
return sorted(map(str, a)) == sorted(map(str, b))
|
||||
return clean(a) == clean(b)
|
||||
|
||||
|
||||
def should_copy_custom_value(source_value, target_value, overwrite):
|
||||
if source_value is None:
|
||||
return False
|
||||
if isinstance(source_value, str) and not source_value.strip():
|
||||
return False
|
||||
if isinstance(source_value, list) and not source_value:
|
||||
return False
|
||||
if values_equal(source_value, target_value):
|
||||
return False
|
||||
if overwrite:
|
||||
return True
|
||||
if target_value is None:
|
||||
return True
|
||||
if isinstance(target_value, str) and not target_value.strip():
|
||||
return True
|
||||
if isinstance(target_value, list) and not target_value:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def custom_fields_by_id(contact):
|
||||
result = {}
|
||||
for field in contact.get("customFields", []) or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
fid = field.get("id") or field.get("fieldId")
|
||||
if not fid:
|
||||
continue
|
||||
value = extract_custom_value(field)
|
||||
if value is not None:
|
||||
result[fid] = value
|
||||
return result
|
||||
|
||||
|
||||
def is_digital_source(source_value):
|
||||
if not source_value:
|
||||
return False
|
||||
text = str(source_value).lower()
|
||||
return any(keyword in text for keyword in DIGITAL_SOURCE_KEYWORDS)
|
||||
|
||||
|
||||
def extract_tienda_label(account_nombre):
|
||||
if not account_nombre:
|
||||
return None
|
||||
parts = str(account_nombre).split(" - ")
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
raw = " - ".join(parts[2:]).strip()
|
||||
return raw.upper() if raw else None
|
||||
|
||||
|
||||
def load_branch_verifier(path=VERIFIER_CSV):
|
||||
"""Carga el verificador: {location_id: {tienda, sucursal, sc}}.
|
||||
|
||||
Descarta filas ambiguas: si varias filas apuntan al mismo location_id con
|
||||
distintos TIENDA, se conserva la primera y se reportan los conflictos.
|
||||
"""
|
||||
mapping = {}
|
||||
conflicts = {}
|
||||
if not os.path.exists(path):
|
||||
return mapping, conflicts
|
||||
try:
|
||||
with open(path, encoding="utf-8-sig", newline="") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
loc = clean(row.get("ID LOCATION BUCEFALO"))
|
||||
if not loc:
|
||||
continue
|
||||
tienda_raw = clean(row.get("TIENDA"))
|
||||
sucursal_raw = clean(row.get("SUCURSAL"))
|
||||
sc_raw = clean(row.get("SC BUCEFALO"))
|
||||
entry = {
|
||||
"tienda": tienda_raw.upper() if tienda_raw else None,
|
||||
"sucursal": sucursal_raw if sucursal_raw and sucursal_raw != "-" else None,
|
||||
"sc": sc_raw,
|
||||
}
|
||||
if loc in mapping:
|
||||
if mapping[loc]["tienda"] != entry["tienda"]:
|
||||
conflicts.setdefault(loc, [mapping[loc]]).append(entry)
|
||||
continue
|
||||
mapping[loc] = entry
|
||||
except Exception as exc:
|
||||
print(f"WARN no se pudo cargar verificador de sucursales: {exc}")
|
||||
return mapping, conflicts
|
||||
|
||||
|
||||
def validate_verifier_against_accounts(verifier_map, accounts):
|
||||
"""Marca como ambiguous las entries cuyo SC BUCEFALO no coincide con el nombre real."""
|
||||
invalid_locs = set()
|
||||
for acc in accounts:
|
||||
loc = acc["location_id"]
|
||||
info = verifier_map.get(loc)
|
||||
if not info:
|
||||
continue
|
||||
expected_norm = norm_field_name(acc.get("nombre"))
|
||||
sc_norm = norm_field_name(info.get("sc"))
|
||||
if sc_norm and expected_norm and sc_norm != expected_norm:
|
||||
invalid_locs.add(loc)
|
||||
return invalid_locs
|
||||
|
||||
|
||||
def resolve_branch_labels(branch_account, verifier_map):
|
||||
"""Devuelve (tienda, sucursal) usando el verificador, fallback al nombre de la cuenta."""
|
||||
loc = branch_account.get("location_id")
|
||||
info = verifier_map.get(loc) if verifier_map else None
|
||||
tienda = (info or {}).get("tienda")
|
||||
sucursal = (info or {}).get("sucursal")
|
||||
if not tienda:
|
||||
tienda = extract_tienda_label(branch_account.get("nombre"))
|
||||
if not sucursal:
|
||||
sucursal = tienda
|
||||
return tienda, sucursal
|
||||
|
||||
|
||||
def brand_field_id_by_alias(brand_by_canonical, alias):
|
||||
entry = brand_by_canonical.get(alias)
|
||||
return entry[1] if entry else None
|
||||
|
||||
|
||||
def plan_bi_autofill(branch_contact, brand_contact, branch_account,
|
||||
brand_by_canonical, existing_custom_payload, verifier_map):
|
||||
in_payload_ids = {item["id"] for item in existing_custom_payload}
|
||||
brand_custom_by_id = custom_fields_by_id(brand_contact or {})
|
||||
additions = []
|
||||
changes = []
|
||||
|
||||
def add(alias_key, value, label):
|
||||
field_id = brand_field_id_by_alias(brand_by_canonical, alias_key)
|
||||
if not field_id or field_id in in_payload_ids:
|
||||
return
|
||||
existing_value = brand_custom_by_id.get(field_id)
|
||||
if existing_value is not None and clean(existing_value):
|
||||
return
|
||||
additions.append({"id": field_id, "value": value})
|
||||
changes.append((field_id, label, existing_value, value))
|
||||
in_payload_ids.add(field_id)
|
||||
|
||||
tienda, sucursal = resolve_branch_labels(branch_account, verifier_map)
|
||||
if tienda:
|
||||
add("tienda", tienda, "TIENDA (auto)")
|
||||
if sucursal:
|
||||
add("sucursal", sucursal, "Sucursal (auto)")
|
||||
|
||||
source_value = branch_contact.get("source") or ""
|
||||
raw_tags = branch_contact.get("tags") or []
|
||||
tags_lower = {str(t).lower() for t in raw_tags if t}
|
||||
has_sucursal_tag = "sucursal" in tags_lower
|
||||
if not is_digital_source(source_value) and (has_sucursal_tag or not source_value):
|
||||
add("canal_origen", "SUCURSAL", "Canal de Origen (auto)")
|
||||
add("fuente_prospecto", "SUCURSAL", "Fuente de Prospecto (auto)")
|
||||
|
||||
return additions, changes
|
||||
|
||||
|
||||
def has_corrupt_encoding(name):
|
||||
if not name:
|
||||
return False
|
||||
return "�" in str(name)
|
||||
|
||||
|
||||
def detect_corrupt_schema_fields(schema):
|
||||
return sorted(name for name in schema if has_corrupt_encoding(name))
|
||||
|
||||
|
||||
def matches_field_filter(field_filter, field_filter_norm, name):
|
||||
if not field_filter:
|
||||
return True
|
||||
if name in field_filter:
|
||||
return True
|
||||
return norm_field_name(name) in field_filter_norm
|
||||
|
||||
|
||||
def build_match_index(contacts):
|
||||
index = {"phone": {}, "email": {}, "name": {}}
|
||||
for contact in contacts:
|
||||
index_add_contact(index, contact)
|
||||
return index
|
||||
|
||||
|
||||
def index_add_contact(index, contact):
|
||||
phone = normalize_phone(contact.get("phone"))
|
||||
email = normalize_email(contact.get("email"))
|
||||
name = normalize_name(contact)
|
||||
if phone:
|
||||
index["phone"].setdefault(phone, []).append(contact)
|
||||
if email:
|
||||
index["email"].setdefault(email, []).append(contact)
|
||||
if name:
|
||||
index["name"].setdefault(name, []).append(contact)
|
||||
|
||||
|
||||
def find_brand_contact(branch_contact, brand_index):
|
||||
"""Devuelve (match, label, ambiguous, collision).
|
||||
|
||||
Cambio respecto a la versión anterior: cuando hay match único por
|
||||
teléfono, ahora se exige también que el nombre coincida con similitud
|
||||
>= MATCH_THRESHOLD (vía common.match_contacts). Si el teléfono coincide
|
||||
pero el nombre diverge, se devuelve el candidato como `collision` (no
|
||||
como match) para que el caller lo reporte sin mergear. Esto evita el
|
||||
caso real de pareja con mismo número siendo tratados como duplicado.
|
||||
"""
|
||||
match_order = (
|
||||
("phone", normalize_phone(branch_contact.get("phone")), "telefono"),
|
||||
("email", normalize_email(branch_contact.get("email")), "email"),
|
||||
("name", normalize_name(branch_contact), "nombre"),
|
||||
)
|
||||
last_ambiguous_label = None
|
||||
phone_collision = None
|
||||
for key, value, label in match_order:
|
||||
if not value:
|
||||
continue
|
||||
matches = brand_index[key].get(value, [])
|
||||
if len(matches) == 1:
|
||||
candidate = matches[0]
|
||||
if key == "phone":
|
||||
result = match_contacts(branch_contact, candidate, threshold=MATCH_THRESHOLD)
|
||||
if result["level"] in ("strong", "medium"):
|
||||
return candidate, label, False, None
|
||||
phone_collision = candidate
|
||||
continue
|
||||
return candidate, label, False, None
|
||||
if len(matches) > 1:
|
||||
last_ambiguous_label = label
|
||||
continue
|
||||
if last_ambiguous_label:
|
||||
return None, last_ambiguous_label, True, None
|
||||
if phone_collision is not None:
|
||||
return None, "telefono", False, phone_collision
|
||||
return None, None, False, None
|
||||
|
||||
|
||||
def parse_field_filter(raw_fields):
|
||||
if not raw_fields:
|
||||
return None
|
||||
fields = {clean(field) for field in raw_fields.split(",") if clean(field)}
|
||||
return fields or None
|
||||
|
||||
|
||||
def should_copy_value(source_value, target_value, overwrite):
|
||||
if source_value is None:
|
||||
return False
|
||||
if isinstance(source_value, str) and not source_value.strip():
|
||||
return False
|
||||
if clean(source_value) == clean(target_value):
|
||||
return False
|
||||
if overwrite:
|
||||
return True
|
||||
return not clean(target_value)
|
||||
|
||||
|
||||
def select_accounts(args, accounts):
|
||||
if args.location:
|
||||
selected = [a for a in accounts if a["location_id"] == args.location and a["location_id"] != BRAND_LOCATION_ID]
|
||||
if not selected:
|
||||
raise SystemExit(f"Location {args.location} no existe como sucursal en el CSV de tokens")
|
||||
return selected
|
||||
if args.all:
|
||||
return [a for a in accounts if a["location_id"] != BRAND_LOCATION_ID and "demo" not in a["nombre"].lower()]
|
||||
raise SystemExit("Especifica --location <id> o --all")
|
||||
|
||||
|
||||
def record_planned_change(args, location_id, object_id, field_id, field_name, old_value, new_value):
|
||||
return script_audit.record_change(args.run_id, location_id, "contact", object_id, field_id, field_name, old_value, new_value)
|
||||
|
||||
|
||||
def plan_contact_payload(branch_contact, brand_contact, branch_id_to_name, brand_schema, brand_norm, brand_by_canonical, branch_account, args):
|
||||
field_filter = args.field_filter
|
||||
field_filter_norm = args.field_filter_norm
|
||||
overwrite = args.overwrite
|
||||
payload = {}
|
||||
changes = []
|
||||
dropped = []
|
||||
|
||||
for field_name in SCALAR_STANDARD_FIELDS:
|
||||
if not matches_field_filter(field_filter, field_filter_norm, field_name):
|
||||
continue
|
||||
raw_value = branch_contact.get(field_name)
|
||||
target_raw = brand_contact.get(field_name) if brand_contact else None
|
||||
if field_name in BOOLEAN_STANDARD_FIELDS:
|
||||
if raw_value is None:
|
||||
continue
|
||||
source_value = bool(raw_value)
|
||||
if source_value == bool(target_raw):
|
||||
continue
|
||||
if not overwrite and target_raw is not None and target_raw != "":
|
||||
continue
|
||||
payload[field_name] = source_value
|
||||
changes.append((field_name, field_name, target_raw, source_value))
|
||||
continue
|
||||
source_value = clean(raw_value)
|
||||
target_value = clean(target_raw)
|
||||
if should_copy_value(source_value, target_value, overwrite):
|
||||
payload[field_name] = source_value
|
||||
changes.append((field_name, field_name, target_value, source_value))
|
||||
|
||||
for field_name in LIST_STANDARD_FIELDS:
|
||||
if not matches_field_filter(field_filter, field_filter_norm, field_name):
|
||||
continue
|
||||
source_list = list_value(branch_contact.get(field_name))
|
||||
target_list = list_value(brand_contact.get(field_name) if brand_contact else None)
|
||||
if not source_list:
|
||||
continue
|
||||
merged = merge_unique(target_list, source_list)
|
||||
if set(map(str, merged)) != set(map(str, target_list)):
|
||||
payload[field_name] = merged
|
||||
changes.append((field_name, field_name, target_list, merged))
|
||||
|
||||
brand_custom_by_id = custom_fields_by_id(brand_contact or {})
|
||||
custom_payload = []
|
||||
seen_brand_field_ids = set()
|
||||
for field in branch_contact.get("customFields", []) or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
branch_fid = field.get("id") or field.get("fieldId")
|
||||
branch_field_name = branch_id_to_name.get(branch_fid)
|
||||
if not branch_field_name:
|
||||
continue
|
||||
if not matches_field_filter(field_filter, field_filter_norm, branch_field_name):
|
||||
continue
|
||||
source_value = extract_custom_value(field)
|
||||
if source_value is None:
|
||||
continue
|
||||
|
||||
brand_name, brand_field_id = resolve_brand_field(
|
||||
branch_field_name, brand_schema, brand_norm, brand_by_canonical
|
||||
)
|
||||
if not brand_field_id:
|
||||
dropped.append(branch_field_name)
|
||||
continue
|
||||
if brand_field_id in seen_brand_field_ids:
|
||||
continue
|
||||
|
||||
target_value = brand_custom_by_id.get(brand_field_id)
|
||||
if should_copy_custom_value(source_value, target_value, overwrite):
|
||||
custom_payload.append({"id": brand_field_id, "value": source_value})
|
||||
changes.append((brand_field_id, brand_name, target_value, source_value))
|
||||
seen_brand_field_ids.add(brand_field_id)
|
||||
|
||||
if args.auto_bi:
|
||||
bi_additions, bi_changes = plan_bi_autofill(
|
||||
branch_contact, brand_contact, branch_account, brand_by_canonical, custom_payload,
|
||||
args.verifier_map,
|
||||
)
|
||||
custom_payload.extend(bi_additions)
|
||||
changes.extend(bi_changes)
|
||||
|
||||
if custom_payload:
|
||||
payload["customFields"] = custom_payload
|
||||
return payload, changes, dropped
|
||||
|
||||
|
||||
def create_payload(branch_contact, branch_id_to_name, brand_schema, brand_norm, brand_by_canonical, branch_account, args):
|
||||
payload = {"locationId": BRAND_LOCATION_ID}
|
||||
field_filter = args.field_filter
|
||||
field_filter_norm = args.field_filter_norm
|
||||
dropped = []
|
||||
|
||||
for field_name in SCALAR_STANDARD_FIELDS:
|
||||
if not matches_field_filter(field_filter, field_filter_norm, field_name):
|
||||
continue
|
||||
raw_value = branch_contact.get(field_name)
|
||||
if field_name in BOOLEAN_STANDARD_FIELDS:
|
||||
if raw_value is None:
|
||||
continue
|
||||
payload[field_name] = bool(raw_value)
|
||||
continue
|
||||
value = clean(raw_value)
|
||||
if value:
|
||||
payload[field_name] = value
|
||||
|
||||
for field_name in LIST_STANDARD_FIELDS:
|
||||
if not matches_field_filter(field_filter, field_filter_norm, field_name):
|
||||
continue
|
||||
source_list = list_value(branch_contact.get(field_name))
|
||||
if source_list:
|
||||
payload[field_name] = source_list
|
||||
|
||||
custom_payload = []
|
||||
seen_brand_field_ids = set()
|
||||
for field in branch_contact.get("customFields", []) or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
branch_fid = field.get("id") or field.get("fieldId")
|
||||
branch_field_name = branch_id_to_name.get(branch_fid)
|
||||
if not branch_field_name:
|
||||
continue
|
||||
if not matches_field_filter(field_filter, field_filter_norm, branch_field_name):
|
||||
continue
|
||||
source_value = extract_custom_value(field)
|
||||
if source_value is None:
|
||||
continue
|
||||
_, brand_field_id = resolve_brand_field(
|
||||
branch_field_name, brand_schema, brand_norm, brand_by_canonical
|
||||
)
|
||||
if not brand_field_id:
|
||||
dropped.append(branch_field_name)
|
||||
continue
|
||||
if brand_field_id in seen_brand_field_ids:
|
||||
continue
|
||||
custom_payload.append({"id": brand_field_id, "value": source_value})
|
||||
seen_brand_field_ids.add(brand_field_id)
|
||||
|
||||
if args.auto_bi:
|
||||
bi_additions, _ = plan_bi_autofill(
|
||||
branch_contact, None, branch_account, brand_by_canonical, custom_payload,
|
||||
args.verifier_map,
|
||||
)
|
||||
custom_payload.extend(bi_additions)
|
||||
|
||||
if custom_payload:
|
||||
payload["customFields"] = custom_payload
|
||||
return payload, dropped
|
||||
|
||||
|
||||
def apply_update(brand_token, brand_contact_id, payload):
|
||||
return sync_engine.ghl_client.update_contact(brand_token, brand_contact_id, payload)
|
||||
|
||||
|
||||
def apply_create(brand_token, payload):
|
||||
return sync_engine.ghl_client.create_contact(brand_token, payload)
|
||||
|
||||
|
||||
def process_branch(branch_account, brand_state, args):
|
||||
location_id = branch_account["location_id"]
|
||||
branch_token = branch_account["token"]
|
||||
brand_token = brand_state["account"]["token"]
|
||||
dry_run = not args.apply
|
||||
stats = {
|
||||
"contacts": 0,
|
||||
"matched": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"planned_creates": 0,
|
||||
"planned_updates": 0,
|
||||
"ambiguous": 0,
|
||||
"phone_collisions_unresolved": 0,
|
||||
"unchanged": 0,
|
||||
"errors": 0,
|
||||
"dropped_unmapped_fields": 0,
|
||||
}
|
||||
dropped_field_counter = {}
|
||||
|
||||
print(f"\n=== {branch_account['nombre']} ({location_id}) ===")
|
||||
branch_schema = sync_engine.ghl_client.get_object_schema(branch_token, location_id, "contact")
|
||||
branch_id_to_name = {field_id: name for name, field_id in branch_schema.items()}
|
||||
brand_schema = brand_state["schema"]
|
||||
brand_norm = brand_state["schema_norm"]
|
||||
brand_by_canonical = brand_state["schema_by_canonical"]
|
||||
branch_contacts = sync_engine.ghl_client.get_all_contacts(branch_token, location_id)
|
||||
if args.limit:
|
||||
branch_contacts = branch_contacts[:args.limit]
|
||||
print(f"Contactos de sucursal a revisar: {len(branch_contacts)}")
|
||||
|
||||
seen_phones = {}
|
||||
seen_emails = {}
|
||||
internal_dups = 0
|
||||
for c in branch_contacts:
|
||||
p = normalize_phone(c.get("phone"))
|
||||
if p:
|
||||
seen_phones.setdefault(p, []).append(get_contact_id(c))
|
||||
e = normalize_email(c.get("email"))
|
||||
if e:
|
||||
seen_emails.setdefault(e, []).append(get_contact_id(c))
|
||||
dup_phones = {k: v for k, v in seen_phones.items() if len(v) > 1}
|
||||
dup_emails = {k: v for k, v in seen_emails.items() if len(v) > 1}
|
||||
if dup_phones or dup_emails:
|
||||
internal_dups = len(dup_phones) + len(dup_emails)
|
||||
print(f"WARN [{location_id}] duplicados internos detectados: {len(dup_phones)} por telefono, {len(dup_emails)} por email")
|
||||
for p, ids in list(dup_phones.items())[:3]:
|
||||
print(f" phone {p}: {ids}")
|
||||
for e, ids in list(dup_emails.items())[:3]:
|
||||
print(f" email {e}: {ids}")
|
||||
|
||||
brand_lock = brand_state["lock"]
|
||||
|
||||
for branch_contact in branch_contacts:
|
||||
if not script_audit.wait_if_paused_or_stopped(args.run_id):
|
||||
break
|
||||
stats["contacts"] += 1
|
||||
branch_contact_id = get_contact_id(branch_contact)
|
||||
label = contact_name(branch_contact) or branch_contact_id or "Sin nombre"
|
||||
|
||||
with brand_lock:
|
||||
brand_contact, match_method, ambiguous, collision = find_brand_contact(branch_contact, brand_state["index"])
|
||||
|
||||
if ambiguous:
|
||||
stats["ambiguous"] += 1
|
||||
print(f"SKIP ambiguo por {match_method}: {label}")
|
||||
continue
|
||||
|
||||
if collision is not None:
|
||||
# Phone match único pero nombre diverge — caso pareja con mismo
|
||||
# número. NO crear ni actualizar: probablemente es otra persona.
|
||||
stats["phone_collisions_unresolved"] += 1
|
||||
colliding_id = get_contact_id(collision)
|
||||
print(f"SKIP colision telefono con Marca {colliding_id}: {label}")
|
||||
if args.run_id:
|
||||
try:
|
||||
script_audit.record_change(
|
||||
args.run_id, BRAND_LOCATION_ID, "contact",
|
||||
branch_contact_id or f"branch:{label}",
|
||||
"", "skipped_phone_collision", None,
|
||||
{
|
||||
"branch_contact_id": branch_contact_id,
|
||||
"branch_location_id": location_id,
|
||||
"colliding_brand_contact_id": colliding_id,
|
||||
"phone": branch_contact.get("phone"),
|
||||
},
|
||||
)
|
||||
except Exception as audit_exc:
|
||||
print(f" WARN no se pudo registrar la colision: {audit_exc}")
|
||||
continue
|
||||
|
||||
if not brand_contact:
|
||||
if args.no_create:
|
||||
stats["unchanged"] += 1
|
||||
continue
|
||||
payload, create_dropped = create_payload(
|
||||
branch_contact, branch_id_to_name, brand_schema, brand_norm, brand_by_canonical, branch_account, args
|
||||
)
|
||||
for fname in create_dropped:
|
||||
dropped_field_counter[fname] = dropped_field_counter.get(fname, 0) + 1
|
||||
stats["dropped_unmapped_fields"] += 1
|
||||
source_ref = branch_contact_id or label
|
||||
change_id = record_planned_change(
|
||||
args, BRAND_LOCATION_ID, f"branch:{source_ref}", "__create__", "__create__", None, payload
|
||||
)
|
||||
if dry_run:
|
||||
stats["planned_creates"] += 1
|
||||
print(f"PLAN crear en Marca: {label}")
|
||||
continue
|
||||
with brand_lock:
|
||||
recheck_contact, _, _, _ = find_brand_contact(branch_contact, brand_state["index"])
|
||||
if recheck_contact:
|
||||
script_audit.mark_change(change_id, "applied", "skipped: created by another worker")
|
||||
stats["unchanged"] += 1
|
||||
print(f"SKIP doble-create evitado: {label}")
|
||||
continue
|
||||
try:
|
||||
response = apply_create(brand_token, payload)
|
||||
created = response.get("contact", response if isinstance(response, dict) else {})
|
||||
created_id = created.get("id")
|
||||
if created_id:
|
||||
created["id"] = created_id
|
||||
brand_state["contacts"].append(created)
|
||||
index_add_contact(brand_state["index"], created)
|
||||
script_audit.mark_change(change_id, "applied")
|
||||
stats["created"] += 1
|
||||
print(f"OK creado en Marca: {label} -> {created_id or '(sin id en respuesta)'}")
|
||||
except Exception as exc:
|
||||
script_audit.mark_change(change_id, "failed", str(exc))
|
||||
stats["errors"] += 1
|
||||
print(f"ERROR crear {label}: {exc}")
|
||||
continue
|
||||
|
||||
stats["matched"] += 1
|
||||
if args.no_update:
|
||||
stats["unchanged"] += 1
|
||||
continue
|
||||
|
||||
with brand_lock:
|
||||
brand_snapshot = dict(brand_contact)
|
||||
brand_snapshot["customFields"] = [dict(f) for f in (brand_contact.get("customFields") or []) if isinstance(f, dict)]
|
||||
|
||||
payload, changes, plan_dropped = plan_contact_payload(
|
||||
branch_contact, brand_snapshot, branch_id_to_name,
|
||||
brand_schema, brand_norm, brand_by_canonical, branch_account, args,
|
||||
)
|
||||
for fname in plan_dropped:
|
||||
dropped_field_counter[fname] = dropped_field_counter.get(fname, 0) + 1
|
||||
stats["dropped_unmapped_fields"] += 1
|
||||
if not changes:
|
||||
stats["unchanged"] += 1
|
||||
continue
|
||||
|
||||
brand_contact_id = get_contact_id(brand_contact)
|
||||
change_ids = []
|
||||
change_labels = []
|
||||
for field_id, field_name, old_value, new_value in changes:
|
||||
change_labels.append(field_name)
|
||||
cid = record_planned_change(
|
||||
args, BRAND_LOCATION_ID, brand_contact_id, field_id, field_name, old_value, new_value
|
||||
)
|
||||
if cid is not None:
|
||||
change_ids.append(cid)
|
||||
|
||||
if dry_run:
|
||||
stats["planned_updates"] += 1
|
||||
print(f"PLAN actualizar Marca ({match_method}) {label}: {', '.join(change_labels)}")
|
||||
continue
|
||||
|
||||
with brand_lock:
|
||||
try:
|
||||
apply_update(brand_token, brand_contact_id, payload)
|
||||
stats["updated"] += 1
|
||||
for key, value in payload.items():
|
||||
if key == "customFields":
|
||||
existing = {f.get("id") or f.get("fieldId"): f for f in brand_contact.get("customFields", []) or []}
|
||||
for cf in value:
|
||||
existing[cf["id"]] = {"id": cf["id"], "value": cf["value"]}
|
||||
brand_contact["customFields"] = list(existing.values())
|
||||
else:
|
||||
brand_contact[key] = value
|
||||
for cid in change_ids:
|
||||
script_audit.mark_change(cid, "applied")
|
||||
print(f"OK actualizado Marca ({match_method}) {label}: {len(changes)} cambio(s)")
|
||||
except Exception as exc:
|
||||
for cid in change_ids:
|
||||
script_audit.mark_change(cid, "failed", str(exc))
|
||||
stats["errors"] += 1
|
||||
print(f"ERROR actualizar {label}: {exc}")
|
||||
|
||||
if dropped_field_counter:
|
||||
top_dropped = sorted(dropped_field_counter.items(), key=lambda kv: kv[1], reverse=True)[:10]
|
||||
summary = ", ".join(f"{name}({count})" for name, count in top_dropped)
|
||||
print(f"WARN [{location_id}] custom fields sin contraparte en Marca: {summary}")
|
||||
|
||||
print(f"Stats {location_id}: {stats}")
|
||||
return stats
|
||||
|
||||
|
||||
def add_stats(total, current):
|
||||
for key, value in current.items():
|
||||
total[key] = total.get(key, 0) + value
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Sincroniza contactos de sucursales hacia la Marca Principal. Dry-run por defecto.")
|
||||
parser.add_argument("--location", help="Location ID de una sucursal")
|
||||
parser.add_argument("--all", action="store_true", help="Procesa todas las sucursales no demo del CSV")
|
||||
parser.add_argument("--apply", action="store_true", help="Aplica POST/PUT reales en GHL. Sin este flag solo simula")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Sobrescribe valores existentes en Marca cuando difieren")
|
||||
parser.add_argument("--no-create", action="store_true", help="No crea contactos faltantes en Marca")
|
||||
parser.add_argument("--no-update", action="store_true", help="No actualiza contactos existentes en Marca")
|
||||
parser.add_argument("--fields", help="Campos a sincronizar separados por coma. Default: campos BI/vehiculo + nombre/email/telefono")
|
||||
parser.add_argument("--limit", type=int, help="Limite de contactos por sucursal para esta corrida (escape hatch para debugging; sin default procesa todo)")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Paralelismo para procesar sucursales. Default: 1")
|
||||
parser.add_argument("--run-id", help="Audit run ID suministrado por dashboard")
|
||||
parser.add_argument("--yes", action="store_true", help="Salta la confirmacion interactiva. Requerido para --apply --all en entornos no TTY (dashboard).")
|
||||
parser.add_argument("--no-auto-bi", action="store_true", help="Desactiva el auto-poblado de TIENDA/Sucursal/Canal/Fuente al sincronizar. Por defecto se rellenan cuando estan vacios en Marca.")
|
||||
args = parser.parse_args()
|
||||
if args.limit is not None and args.limit < 1:
|
||||
raise SystemExit("--limit debe ser mayor a 0")
|
||||
if args.workers < 1:
|
||||
raise SystemExit("--workers debe ser mayor a 0")
|
||||
args.field_filter = parse_field_filter(args.fields)
|
||||
args.field_filter_norm = {norm_field_name(f) for f in args.field_filter} if args.field_filter else None
|
||||
args.auto_bi = not args.no_auto_bi
|
||||
args.verifier_map, args.verifier_conflicts = load_branch_verifier()
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
args = parse_args()
|
||||
accounts = sync_engine.parse_accounts_csv()
|
||||
brand_account = next((a for a in accounts if a["location_id"] == BRAND_LOCATION_ID), None)
|
||||
if not brand_account:
|
||||
raise SystemExit("No se encontro la cuenta de Marca Principal en el CSV de tokens")
|
||||
branch_accounts = select_accounts(args, accounts)
|
||||
|
||||
if args.apply and args.all and not args.yes:
|
||||
if sys.stdin.isatty():
|
||||
print(f"\nVas a aplicar cambios reales en {len(branch_accounts)} sucursales contra GHL.")
|
||||
response = input("Escribe CONFIRMO para continuar: ").strip()
|
||||
if response != "CONFIRMO":
|
||||
raise SystemExit("Cancelado por el usuario.")
|
||||
else:
|
||||
raise SystemExit(
|
||||
"--apply --all requiere --yes en entornos no TTY (dashboard/subprocess). Abortando por seguridad."
|
||||
)
|
||||
|
||||
if args.run_id:
|
||||
script_audit.create_run(
|
||||
args.run_id,
|
||||
os.path.basename(__file__),
|
||||
arguments=" ".join(sys.argv[1:]),
|
||||
locations=[a["location_id"] for a in branch_accounts],
|
||||
execution_mode="parallel" if args.workers > 1 else "sequential",
|
||||
)
|
||||
|
||||
print("=" * 78)
|
||||
print("SYNC CONTACTS SUCURSAL -> MARCA")
|
||||
print("=" * 78)
|
||||
print(f"Modo: {'APPLY (POST/PUT real en GHL)' if args.apply else 'DRY-RUN (sin cambios)'}")
|
||||
print(f"Sucursales: {len(branch_accounts)} | Workers: {min(args.workers, len(branch_accounts))}")
|
||||
print(f"Update: {not args.no_update} | Create: {not args.no_create} | Overwrite: {args.overwrite}")
|
||||
if args.field_filter:
|
||||
print(f"Campos filtrados: {', '.join(sorted(args.field_filter))}")
|
||||
else:
|
||||
print("Campos custom: match por nombre exacto, normalizado (mayus/acentos) y por aliases del manual.")
|
||||
print(f"Estandar copiados: {', '.join(SCALAR_STANDARD_FIELDS)}")
|
||||
print(f"Listas unidas (no overwrite): {', '.join(LIST_STANDARD_FIELDS)}")
|
||||
|
||||
print("\nCargando contactos live de Marca Principal...")
|
||||
brand_schema = sync_engine.ghl_client.get_object_schema(brand_account["token"], BRAND_LOCATION_ID, "contact")
|
||||
brand_norm, brand_by_canonical = build_brand_field_indexes(brand_schema)
|
||||
|
||||
corrupt = detect_corrupt_schema_fields(brand_schema)
|
||||
if corrupt:
|
||||
print(f"WARN Marca tiene {len(corrupt)} campo(s) con encoding corrupto en el schema:")
|
||||
for name in corrupt:
|
||||
print(f" - {repr(name)} (id: {brand_schema[name]})")
|
||||
print(" Estos campos se sincronizan pero conviene renombrarlos en GHL.")
|
||||
|
||||
if args.verifier_map:
|
||||
if args.verifier_conflicts:
|
||||
print(f"WARN verificador con {len(args.verifier_conflicts)} location_id(s) duplicados con TIENDA contradictorias (data quality del CSV):")
|
||||
for loc, entries in args.verifier_conflicts.items():
|
||||
tiendas = [e.get("tienda") for e in entries]
|
||||
print(f" - {loc}: {tiendas}")
|
||||
invalid = validate_verifier_against_accounts(args.verifier_map, branch_accounts)
|
||||
for loc in invalid:
|
||||
args.verifier_map.pop(loc, None)
|
||||
missing_in_verifier = [a for a in branch_accounts if a["location_id"] not in args.verifier_map]
|
||||
if missing_in_verifier:
|
||||
print(f"WARN {len(missing_in_verifier)} sucursal(es) usaran fallback al nombre (sin verificador o SC BUCEFALO no coincide):")
|
||||
for a in missing_in_verifier:
|
||||
print(f" - {a['location_id']} {a['nombre']}")
|
||||
else:
|
||||
print("WARN no se cargo el verificador de sucursales. TIENDA/Sucursal se derivaran del nombre.")
|
||||
brand_contacts = sync_engine.ghl_client.get_all_contacts(brand_account["token"], BRAND_LOCATION_ID)
|
||||
brand_state = {
|
||||
"account": brand_account,
|
||||
"schema": brand_schema,
|
||||
"schema_norm": brand_norm,
|
||||
"schema_by_canonical": brand_by_canonical,
|
||||
"contacts": brand_contacts,
|
||||
"index": build_match_index(brand_contacts),
|
||||
"lock": threading.Lock(),
|
||||
}
|
||||
print(f"Contactos de Marca cargados: {len(brand_contacts)} (tope {brand_max})")
|
||||
|
||||
total = {}
|
||||
if args.workers > 1 and len(branch_accounts) > 1:
|
||||
with ThreadPoolExecutor(max_workers=min(args.workers, len(branch_accounts))) as executor:
|
||||
futures = {executor.submit(process_branch, account, brand_state, args): account for account in branch_accounts}
|
||||
for future in as_completed(futures):
|
||||
add_stats(total, future.result())
|
||||
else:
|
||||
for account in branch_accounts:
|
||||
add_stats(total, process_branch(account, brand_state, args))
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print(f"TOTAL: {json.dumps(total, ensure_ascii=False)}")
|
||||
print("=" * 78)
|
||||
print("HEALTH SUMMARY")
|
||||
print(f" Sucursales procesadas: {len(branch_accounts)}")
|
||||
if args.verifier_conflicts:
|
||||
print(f" Conflictos en verificador: {len(args.verifier_conflicts)} location_id(s)")
|
||||
missing = sum(1 for a in branch_accounts if a["location_id"] not in args.verifier_map)
|
||||
if missing:
|
||||
print(f" Sucursales con fallback (sin verificador): {missing}")
|
||||
if total.get("ambiguous"):
|
||||
print(f" Contactos no sincronizados por ambiguedad: {total['ambiguous']}")
|
||||
if total.get("phone_collisions_unresolved"):
|
||||
print(f" Colisiones telefono sin match por nombre (revision manual): {total['phone_collisions_unresolved']}")
|
||||
if total.get("dropped_unmapped_fields"):
|
||||
print(f" Campos custom descartados sin contraparte en Marca: {total['dropped_unmapped_fields']}")
|
||||
if total.get("errors"):
|
||||
print(f" Errores: {total['errors']}")
|
||||
coverage_pct = 0
|
||||
if total.get("contacts"):
|
||||
synced = total.get("matched", 0) + total.get("planned_creates", 0) + total.get("created", 0)
|
||||
coverage_pct = round(100.0 * synced / total["contacts"], 2)
|
||||
print(f" Cobertura de sincronizacion: {coverage_pct}% ({total.get('matched', 0)} matched + {total.get('planned_creates', 0) + total.get('created', 0)} crear)")
|
||||
|
||||
if not args.apply:
|
||||
print("\nDry-run terminado. Usa --apply para escribir cambios en GHL.")
|
||||
if args.run_id:
|
||||
final_status = "failed" if total.get("errors") else "success"
|
||||
script_audit.update_run_status(args.run_id, final_status)
|
||||
if total.get("errors"):
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user