#!/usr/bin/env python3 """Correct branch contacts created manually by WEB_USER to sucursal origin.""" import argparse from urllib.parse import quote from tag_canal_origen_workflow import ( contact_display_name, get_all_contacts, get_custom_field_value, get_schemas, get_opportunity, ghl_request, load_locations, safe_update_contact_field, safe_update_opportunity_field, script_audit, ) SUCURSAL_TAG = "sucursal" DIGITAL_TAGS = ["formulario", "facebook-ads"] WEB_USER_SOURCE = "WEB_USER" CONTACT_CANAL_VALUE = "SUCURSAL" CONTACT_FUENTE_VALUE = "SUCURSAL" OPPORTUNITY_CANAL_VALUE = "Sucursal" OPPORTUNITY_FUENTE_VALUE = "SUCURSAL" LEAD_DIGITAL_VALUE = "LEAD DIGITAL" def select_branch_locations(args): branches = load_locations(include_main=False) if args.location: matches = [acc for acc in branches if acc["location_id"] == args.location] if not matches: raise SystemExit(f"Location {args.location} is not a branch location in the CSV") return matches if args.all: return branches raise SystemExit("Specify --location or --all. Use --dry-run to preview without changes.") def is_web_user_contact(contact): created_by = contact.get("createdBy") or {} return created_by.get("source") == WEB_USER_SOURCE def normalized_tags(contact): return {str(tag).lower(): str(tag) for tag in contact.get("tags", []) or []} def update_contact_tags(contact, tags): contact["tags"] = tags def add_contact_tag(contact_id, tag, token): ghl_request("POST", f"/contacts/{contact_id}/tags", token, json_body={"tags": [tag]}) def remove_contact_tag(location_id, contact_id, tag, token): encoded_tag = quote(tag, safe="") ghl_request("DELETE", f"/contacts/{contact_id}/tags/{encoded_tag}", token, params={"locationId": location_id}) def safe_add_contact_tag(run_id, location_id, contact, tag, token, dry_run=False): tag_map = normalized_tags(contact) if tag.lower() in tag_map: return False old_tags = list(contact.get("tags", []) or []) new_tags = old_tags + [tag] change_id = script_audit.record_change(run_id, location_id, "contact", contact["id"], f"tag:{tag}", "tags", old_tags, new_tags) if dry_run: update_contact_tags(contact, new_tags) return True try: add_contact_tag(contact["id"], tag, token) script_audit.mark_change(change_id, "applied") update_contact_tags(contact, new_tags) return True except Exception as exc: script_audit.mark_change(change_id, "failed", str(exc)) raise def safe_remove_contact_tag(run_id, location_id, contact, tag, token, dry_run=False): tag_map = normalized_tags(contact) existing_tag = tag_map.get(tag.lower()) if not existing_tag: return False old_tags = list(contact.get("tags", []) or []) new_tags = [current for current in old_tags if str(current).lower() != tag.lower()] change_id = script_audit.record_change(run_id, location_id, "contact", contact["id"], f"tag:{tag}", "tags", old_tags, new_tags) if dry_run: update_contact_tags(contact, new_tags) return True try: remove_contact_tag(location_id, contact["id"], existing_tag, token) script_audit.mark_change(change_id, "applied") update_contact_tags(contact, new_tags) return True except Exception as exc: script_audit.mark_change(change_id, "failed", str(exc)) raise def get_contact_opportunities(location_id, contact_id, token): try: data = ghl_request( "POST", "/opportunities/search", token, json_body={"locationId": location_id, "contactId": contact_id, "limit": 100}, ) except Exception as exc: print(f" WARN no se pudieron consultar oportunidades: {exc}") return [] return data.get("opportunities", []) or [] def oldest_opportunity(opportunities): if not opportunities: return None return sorted(opportunities, key=lambda opp: opp.get("dateAdded") or "9999-12-31T23:59:59Z")[0] def update_oldest_opportunity(run_id, location_id, contact, opportunity_schema, token, dry_run=False): opportunities = get_contact_opportunities(location_id, contact["id"], token) oldest = oldest_opportunity(opportunities) if not oldest: return False opportunity_id = oldest.get("id") opportunity = get_opportunity(location_id, opportunity_id, token) or oldest updated = False field_updates = [ ("Canal de Origen de la Oportunidad", OPPORTUNITY_CANAL_VALUE, "always"), ("Fuente de Prospecto", OPPORTUNITY_FUENTE_VALUE, "always"), ("Fuente del cliente potencial", OPPORTUNITY_FUENTE_VALUE, "lead_digital_only"), ] for field_name, value, mode in field_updates: field_id = opportunity_schema.get(field_name) if not field_id: continue current_value = get_custom_field_value(opportunity, field_id) if mode == "lead_digital_only" and current_value != LEAD_DIGITAL_VALUE: continue if safe_update_opportunity_field(run_id, location_id, opportunity_id, opportunity, field_id, field_name, value, token, dry_run): updated = True return updated def process_location(account, dry_run=False, run_id=None): location_id = account["location_id"] token = account["token"] name = account["nombre"] schemas = get_schemas(location_id, token, "contact", "opportunity") contact_schema = schemas["contact"] opportunity_schema = schemas["opportunity"] contact_canal_id = contact_schema.get("Canal de Origen") contact_fuente_id = contact_schema.get("Fuente de Prospecto") if not contact_canal_id or not contact_fuente_id: print( f"\n{name} - missing required contact fields " f"(Canal: {bool(contact_canal_id)}, Fuente: {bool(contact_fuente_id)})" ) return 0 contacts = get_all_contacts(location_id, token) web_user_contacts = 0 corrected_contacts = 0 updated_opportunities = 0 skipped_opportunities = 0 for contact in contacts: if not script_audit.wait_if_paused_or_stopped(run_id): print("\nDetención segura solicitada. Saliendo antes del siguiente contacto.") break if not is_web_user_contact(contact): continue web_user_contacts += 1 contact_changed = False if safe_add_contact_tag(run_id, location_id, contact, SUCURSAL_TAG, token, dry_run): contact_changed = True for tag in DIGITAL_TAGS: if safe_remove_contact_tag(run_id, location_id, contact, tag, token, dry_run): contact_changed = True if safe_update_contact_field( run_id, location_id, contact, contact_canal_id, "Canal de Origen", CONTACT_CANAL_VALUE, token, dry_run, ): contact_changed = True if safe_update_contact_field( run_id, location_id, contact, contact_fuente_id, "Fuente de Prospecto", CONTACT_FUENTE_VALUE, token, dry_run, ): contact_changed = True if not contact_changed: continue corrected_contacts += 1 print(f" OK contacto {contact_display_name(contact)} | WEB_USER -> sucursal") if update_oldest_opportunity(run_id, location_id, contact, opportunity_schema, token, dry_run): updated_opportunities += 1 print(" OK oportunidad mas antigua actualizada") else: skipped_opportunities += 1 print( f"\n{name}: {web_user_contacts} WEB_USER detectados, " f"{corrected_contacts} contactos corregidos, " f"{updated_opportunities} oportunidades actualizadas, " f"{skipped_opportunities} sin cambio de oportunidad" ) return corrected_contacts def main(): parser = argparse.ArgumentParser(description="Correct WEB_USER branch contacts to sucursal origin") parser.add_argument("--location", help="Specific branch location ID to process") parser.add_argument("--all", action="store_true", help="Process all branch locations from the CSV") parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing to GHL") parser.add_argument("--run-id", help="Audit run ID supplied by the dashboard") args = parser.parse_args() print("\n" + "=" * 60) print("GHL FIX - WEB_USER BRANCH CONTACTS -> SUCURSAL") print("=" * 60) if args.dry_run: print("DRY RUN - no changes will be made\n") total = 0 for account in select_branch_locations(args): try: total += process_location(account, args.dry_run, args.run_id) except Exception as exc: print(f"\nERROR {account['nombre']}: {exc}") print(f"\n{'=' * 60}") print(f"TOTAL: {total} contactos WEB_USER corregidos") if __name__ == "__main__": main()