- Added KommunikationSyncManager class to handle synchronization logic. - Implemented methods for loading data, computing diffs, and applying changes between Advoware and EspoCRM. - Introduced 3-way diffing mechanism to intelligently resolve conflicts. - Added helper methods for creating empty slots and detecting changes in communications. - Enhanced logging for better traceability during synchronization processes.
337 lines
9.5 KiB
Python
337 lines
9.5 KiB
Python
"""
|
|
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
|