""" Kommunikation Mapper: Advoware ↔ EspoCRM Mapping-Strategie: - Marker in Advoware bemerkung: [ESPOCRM:hash:kommKz] - Typ-Erkennung: Marker > Top-Level > Wert > Default - Bidirektional mit Slot-Wiederverwendung """ import hashlib import base64 import re from typing import Optional, Dict, Any, List, Tuple # kommKz Enum KOMMKZ_TEL_GESCH = 1 KOMMKZ_FAX_GESCH = 2 KOMMKZ_MOBIL = 3 KOMMKZ_MAIL_GESCH = 4 KOMMKZ_INTERNET = 5 KOMMKZ_TEL_PRIVAT = 6 KOMMKZ_FAX_PRIVAT = 7 KOMMKZ_MAIL_PRIVAT = 8 KOMMKZ_AUTO_TELEFON = 9 KOMMKZ_SONSTIGE = 10 KOMMKZ_EPOST = 11 KOMMKZ_BEA = 12 # EspoCRM phone type mapping KOMMKZ_TO_PHONE_TYPE = { KOMMKZ_TEL_GESCH: 'Office', KOMMKZ_FAX_GESCH: 'Fax', KOMMKZ_MOBIL: 'Mobile', KOMMKZ_TEL_PRIVAT: 'Home', KOMMKZ_FAX_PRIVAT: 'Fax', KOMMKZ_AUTO_TELEFON: 'Mobile', KOMMKZ_SONSTIGE: 'Other', } # Reverse mapping: EspoCRM phone type to kommKz PHONE_TYPE_TO_KOMMKZ = { 'Office': KOMMKZ_TEL_GESCH, 'Fax': KOMMKZ_FAX_GESCH, 'Mobile': KOMMKZ_MOBIL, 'Home': KOMMKZ_TEL_PRIVAT, 'Other': KOMMKZ_SONSTIGE, } # Email kommKz values EMAIL_KOMMKZ = [KOMMKZ_MAIL_GESCH, KOMMKZ_MAIL_PRIVAT, KOMMKZ_EPOST, KOMMKZ_BEA] # Phone kommKz values PHONE_KOMMKZ = [KOMMKZ_TEL_GESCH, KOMMKZ_FAX_GESCH, KOMMKZ_MOBIL, KOMMKZ_TEL_PRIVAT, KOMMKZ_FAX_PRIVAT, KOMMKZ_AUTO_TELEFON, KOMMKZ_SONSTIGE] def encode_value(value: str) -> str: """Encodiert Wert mit Base64 (URL-safe) für Marker""" return base64.urlsafe_b64encode(value.encode('utf-8')).decode('ascii').rstrip('=') def decode_value(encoded: str) -> str: """Decodiert Base64-kodierten Wert aus Marker""" # Add padding if needed padding = 4 - (len(encoded) % 4) if padding != 4: encoded += '=' * padding return base64.urlsafe_b64decode(encoded.encode('ascii')).decode('utf-8') def calculate_hash(value: str) -> str: """Legacy: Hash-Berechnung (für Rückwärtskompatibilität mit alten Markern)""" return hashlib.sha256(value.encode()).hexdigest()[:8] def parse_marker(bemerkung: str) -> Optional[Dict[str, Any]]: """ Parse ESPOCRM-Marker aus bemerkung Returns: {'synced_value': '...', 'kommKz': 4, 'is_slot': False, 'user_text': '...'} oder None (synced_value ist decoded, nicht base64) """ if not bemerkung: return None # Match SLOT: [ESPOCRM-SLOT:kommKz] slot_pattern = r'\[ESPOCRM-SLOT:(\d+)\](.*)' slot_match = re.match(slot_pattern, bemerkung) if slot_match: return { 'synced_value': '', 'kommKz': int(slot_match.group(1)), 'is_slot': True, 'user_text': slot_match.group(2).strip() } # Match: [ESPOCRM:base64_value:kommKz] pattern = r'\[ESPOCRM:([^:]+):(\d+)\](.*)' match = re.match(pattern, bemerkung) if not match: return None encoded_value = match.group(1) # Decode Base64 value try: synced_value = decode_value(encoded_value) except Exception as e: # Fallback: Könnte alter Hash-Marker sein synced_value = encoded_value return { 'synced_value': synced_value, 'kommKz': int(match.group(2)), 'is_slot': False, 'user_text': match.group(3).strip() } def create_marker(value: str, kommkz: int, user_text: str = '') -> str: """Erstellt ESPOCRM-Marker mit Base64-encodiertem Wert""" encoded = encode_value(value) suffix = f" {user_text}" if user_text else "" return f"[ESPOCRM:{encoded}:{kommkz}]{suffix}" def create_slot_marker(kommkz: int) -> str: """Erstellt Slot-Marker für gelöschte Einträge""" return f"[ESPOCRM-SLOT:{kommkz}]" def detect_kommkz(value: str, beteiligte: Optional[Dict] = None, bemerkung: Optional[str] = None, espo_type: Optional[str] = None) -> int: """ Erkenne kommKz mit mehrstufiger Strategie Priorität: 1. Aus bemerkung-Marker (wenn vorhanden) 2. Aus EspoCRM type (wenn von EspoCRM kommend) 3. Aus Top-Level Feldern in beteiligte 4. Aus Wert (Email vs. Phone) 5. Default Args: espo_type: EspoCRM phone type ('Office', 'Mobile', 'Fax', etc.) oder 'email' """ # 1. Aus Marker if bemerkung: marker = parse_marker(bemerkung) if marker: import logging logger = logging.getLogger(__name__) logger.info(f"[KOMMKZ] Detected from marker: kommKz={marker['kommKz']}") return marker['kommKz'] # 2. Aus EspoCRM type (für EspoCRM->Advoware Sync) if espo_type: if espo_type == 'email': import logging logger = logging.getLogger(__name__) logger.info(f"[KOMMKZ] Detected from espo_type 'email': kommKz={KOMMKZ_MAIL_GESCH}") return KOMMKZ_MAIL_GESCH elif espo_type in PHONE_TYPE_TO_KOMMKZ: kommkz = PHONE_TYPE_TO_KOMMKZ[espo_type] import logging logger = logging.getLogger(__name__) logger.info(f"[KOMMKZ] Detected from espo_type '{espo_type}': kommKz={kommkz}") return kommkz # 3. Aus Top-Level Feldern (für genau EINEN Eintrag pro Typ) if beteiligte: top_level_map = { 'telGesch': KOMMKZ_TEL_GESCH, 'faxGesch': KOMMKZ_FAX_GESCH, 'mobil': KOMMKZ_MOBIL, 'emailGesch': KOMMKZ_MAIL_GESCH, 'email': KOMMKZ_MAIL_GESCH, 'internet': KOMMKZ_INTERNET, 'telPrivat': KOMMKZ_TEL_PRIVAT, 'faxPrivat': KOMMKZ_FAX_PRIVAT, 'autotelefon': KOMMKZ_AUTO_TELEFON, 'ePost': KOMMKZ_EPOST, 'bea': KOMMKZ_BEA, } for field, kommkz in top_level_map.items(): if beteiligte.get(field) == value: return kommkz # 3. Aus Wert (Email vs. Phone) if '@' in value: return KOMMKZ_MAIL_GESCH # Default Email elif value.strip(): return KOMMKZ_TEL_GESCH # Default Phone return 0 def is_email_type(kommkz: int) -> bool: """Prüft ob kommKz ein Email-Typ ist""" return kommkz in EMAIL_KOMMKZ def is_phone_type(kommkz: int) -> bool: """Prüft ob kommKz ein Telefon-Typ ist""" return kommkz in PHONE_KOMMKZ def advoware_to_espocrm_email(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]: """ Konvertiert Advoware Kommunikation zu EspoCRM emailAddressData Args: advo_komm: Advoware Kommunikation beteiligte: Vollständiger Beteiligte (für Top-Level Felder) Returns: EspoCRM emailAddressData Element """ value = (advo_komm.get('tlf') or '').strip() return { 'emailAddress': value, 'lower': value.lower(), 'primary': advo_komm.get('online', False), 'optOut': False, 'invalid': False } def advoware_to_espocrm_phone(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]: """ Konvertiert Advoware Kommunikation zu EspoCRM phoneNumberData Args: advo_komm: Advoware Kommunikation beteiligte: Vollständiger Beteiligte (für Top-Level Felder) Returns: EspoCRM phoneNumberData Element """ value = (advo_komm.get('tlf') or '').strip() bemerkung = advo_komm.get('bemerkung') # Erkenne kommKz kommkz = detect_kommkz(value, beteiligte, bemerkung) # Mappe zu EspoCRM type phone_type = KOMMKZ_TO_PHONE_TYPE.get(kommkz, 'Other') return { 'phoneNumber': value, 'type': phone_type, 'primary': advo_komm.get('online', False), 'optOut': False, 'invalid': False } def find_matching_advoware(espo_value: str, advo_kommunikationen: List[Dict]) -> Optional[Dict]: """ Findet passende Advoware-Kommunikation für EspoCRM Wert Matching via synced_value in bemerkung-Marker """ for k in advo_kommunikationen: bemerkung = k.get('bemerkung') or '' marker = parse_marker(bemerkung) if marker and not marker['is_slot'] and marker['synced_value'] == espo_value: return k return None def find_empty_slot(kommkz: int, advo_kommunikationen: List[Dict]) -> Optional[Dict]: """ Findet leeren Slot mit passendem kommKz Leere Slots haben: tlf='' und bemerkung='[ESPOCRM-SLOT:kommKz]' """ for k in advo_kommunikationen: tlf = (k.get('tlf') or '').strip() bemerkung = k.get('bemerkung') or '' if not tlf: # Leer marker = parse_marker(bemerkung) if marker and marker['is_slot'] and marker['kommKz'] == kommkz: return k return None def should_sync_to_espocrm(advo_komm: Dict) -> bool: """ Prüft ob Advoware-Kommunikation zu EspoCRM synchronisiert werden soll Nur wenn: - Wert vorhanden - Kein leerer Slot """ tlf = (advo_komm.get('tlf') or '').strip() if not tlf: return False bemerkung = advo_komm.get('bemerkung') or '' marker = parse_marker(bemerkung) # Keine leeren Slots if marker and marker['is_slot']: return False return True def get_user_bemerkung(advo_komm: Dict) -> str: """Extrahiert User-Bemerkung (ohne Marker)""" bemerkung = advo_komm.get('bemerkung') or '' marker = parse_marker(bemerkung) if marker: return marker['user_text'] return bemerkung def set_user_bemerkung(marker: str, user_text: str) -> str: """Fügt User-Bemerkung zu Marker hinzu""" if user_text: return f"{marker} {user_text}" return marker