#!/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 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()