# Kommunikation-Sync - Bidirektionale Synchronisation EspoCRM ↔ Advoware **Erstellt**: 8. Februar 2026 **Status**: ✅ Implementiert und getestet --- ## Übersicht Bidirektionale Synchronisation der **Kommunikationsdaten** (Telefon, Email, Fax) zwischen EspoCRM (CBeteiligte) und Advoware (Kommunikationen). **Scope**: Telefonnummern, Email-Adressen, Fax-Nummern **Trigger**: Automatisch nach jedem Beteiligte-Stammdaten-Sync **Change Detection**: Hash-basiert (MD5 von kommunikation rowIds) --- ## Architektur ### Integration in Beteiligte-Sync ``` ┌─────────────────┐ │ Beteiligte Sync │ (Stammdaten) │ Event Handler │ └────────┬────────┘ │ ✅ Stammdaten synced ↓ ┌─────────────────────────────┐ │ Kommunikation Sync Manager │ │ sync_bidirectional() │ │ │ │ 1. Load Data (1x) │ │ 2. Compute Diff (3-Way) │ │ 3. Apply Changes │ │ 4. Update Hash │ └─────────────────────────────┘ │ ↓ ┌─────────────────┐ │ Lock Release │ └─────────────────┘ ``` **WICHTIG**: Lock wird erst NACH Kommunikation-Sync freigegeben! ### Komponenten 1. **KommunikationSyncManager** ([kommunikation_sync_utils.py](../services/kommunikation_sync_utils.py)) - Bidirektionale Sync-Logik - 3-Way Diffing - Hash-basierte Änderungserkennung - Konflikt-Behandlung 2. **KommunikationMapper** ([kommunikation_mapper.py](../services/kommunikation_mapper.py)) - Base64-Marker Encoding/Decoding - kommKz Detection (4-Stufen) - Type Mapping (EspoCRM ↔ Advoware) 3. **Helper Function** ([beteiligte_sync_event_step.py](../steps/vmh/beteiligte_sync_event_step.py)) - `run_kommunikation_sync()` mit Error Handling - Direction-Parameter für Konflikt-Handling --- ## Change Detection: Hash-basiert ### Problem Advoware Beteiligte-rowId ändert sich **NICHT**, wenn nur Kommunikation geändert wird! **Beispiel**: ``` Beteiligte: rowId = "ABCD1234..." Kommunikation 1: "max@example.com" → Email zu "new@example.com" ändern Beteiligte: rowId = "ABCD1234..." ← UNCHANGED! Kommunikation 1: "new@example.com" ``` ### Lösung: MD5-Hash der Kommunikation-rowIds ```python # Hash-Berechnung komm_rowids = sorted([k['rowId'] for k in kommunikationen]) komm_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16] # Beispiel: komm_rowids = [ "FBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOEAFAAAA", "GBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOEAFAAAA" ] → Hash: "a3f5d2e8b1c4f6a9" ``` **Speicherort**: `kommunikationHash` in EspoCRM CBeteiligte (varchar, 16) **Vergleich**: ```python stored_hash = espo_bet.get('kommunikationHash') current_hash = calculate_hash(advo_kommunikationen) if stored_hash != current_hash: # Kommunikation hat sich geändert! advo_changed = True ``` --- ## 3-Way Diffing ### Konflikt-Erkennung ```python # EspoCRM: Timestamp-basiert espo_modified = espo_bet.get('modifiedAt') last_sync = espo_bet.get('advowareLastSync') espo_changed = espo_modified > last_sync # Advoware: Hash-basiert stored_hash = espo_bet.get('kommunikationHash') current_hash = calculate_hash(advo_kommunikationen) advo_changed = stored_hash != current_hash # Konflikt? if espo_changed AND advo_changed: espo_wins = True # EspoCRM gewinnt IMMER! ``` ### Direction-Parameter ```python async def sync_bidirectional(entity_id, betnr, direction='both'): """ direction: - 'both': Bidirektional (normal) - 'to_espocrm': Nur Advoware→EspoCRM - 'to_advoware': Nur EspoCRM→Advoware (bei Konflikt!) """ ``` **Bei Konflikt**: ```python # Beteiligte Sync Event Handler if comparison == 'conflict': # Stammdaten: EspoCRM → Advoware await advoware.put_beteiligte(...) # Kommunikation: NUR EspoCRM → Advoware await run_kommunikation_sync( entity_id, betnr, komm_sync, context, direction='to_advoware' # ← Blockiert Advoware→EspoCRM! ) ``` **Ohne Konflikt**: ```python # Normal: Bidirektional await run_kommunikation_sync( entity_id, betnr, komm_sync, context, direction='both' # ← Default ) ``` --- ## 6 Sync-Varianten (Var1-6) ### Var1: Neu in EspoCRM → CREATE in Advoware **Trigger**: EspoCRM Entry ohne Marker-Match in Advoware ```python # EspoCRM phoneNumberData: [{ phoneNumber: "+49 511 123456", type: "Mobile", primary: true }] # → Advoware POST /Beteiligte/{betnr}/Kommunikationen { "tlf": "+49 511 123456", "kommKz": 3, # Mobile "bemerkung": "[ESPOCRM:KzQ5IDUxMSAxMjM0NTY=:3] ", "online": false } ``` **Empty Slot Reuse**: Prüft zuerst leere Slots mit passendem kommKz! ### Var2: Gelöscht in EspoCRM → Empty Slot in Advoware **Problem**: Advoware DELETE gibt 403 Forbidden! **Lösung**: Update zu Empty Slot ```python # Advoware PUT /Beteiligte/{betnr}/Kommunikationen/{id} { "tlf": "", "bemerkung": "[ESPOCRM-SLOT:3]", # kommKz=3 gespeichert "online": false } ``` **Wiederverwendung**: Var1 prüft Empty Slots vor neuem CREATE ### Var3: Gelöscht in Advoware → DELETE in EspoCRM **Trigger**: Marker in Advoware vorhanden, aber keine Sync-relevante Kommunikation ```python # Marker vorhanden: [ESPOCRM:...:4] # Aber: tlf="" oder should_sync_to_espocrm() = False # → EspoCRM # Entferne aus emailAddressData[] oder phoneNumberData[] ``` ### Var4: Neu in Advoware → CREATE in EspoCRM **Trigger**: Advoware Entry ohne [ESPOCRM:...] Marker ```python # Advoware { "tlf": "info@firma.de", "kommKz": 4, # MailGesch "bemerkung": "Allgemeine Anfragen" } # → EspoCRM emailAddressData: [{ emailAddress: "info@firma.de", primary: false, optOut: false }] # → Advoware Marker Update "bemerkung": "[ESPOCRM:aW5mb0BmaXJtYS5kZQ==:4] Allgemeine Anfragen" ``` ### Var5: Geändert in EspoCRM → UPDATE in Advoware **Trigger**: Marker-dekodierter Wert ≠ EspoCRM Wert, aber Marker vorhanden ```python # Marker: [ESPOCRM:b2xkQGV4YW1wbGUuY29t:4] # Dekodiert: "old@example.com" # EspoCRM: "new@example.com" # → Advoware PUT /Beteiligte/{betnr}/Kommunikationen/{id} { "tlf": "new@example.com", "bemerkung": "[ESPOCRM:bmV3QGV4YW1wbGUuY29t:4] ", "online": true } ``` **Primary-Änderungen**: Auch `online` Flag wird aktualisiert ### Var6: Geändert in Advoware → UPDATE in EspoCRM **Trigger**: Marker vorhanden, aber Advoware tlf ≠ Marker-Wert ```python # Marker: [ESPOCRM:b2xkQGV4YW1wbGUuY29t:4] # Advoware: "new@example.com" # → EspoCRM # Update emailAddressData[] # Update Marker mit neuem Base64 ``` --- ## Base64-Marker Strategie ### Marker-Format ``` [ESPOCRM:base64_encoded_value:kommKz] user_text [ESPOCRM-SLOT:kommKz] ``` **Beispiele**: ``` [ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Geschäftliche Email [ESPOCRM:KzQ5IDUxMSAxMjM0NTY=:3] Mobil Herr Müller [ESPOCRM-SLOT:1] ``` ### Encoding/Decoding ```python import base64 def encode_value(value: str) -> str: return base64.b64encode(value.encode()).decode() def decode_value(encoded: str) -> str: return base64.b64decode(encoded.encode()).decode() ``` ### Vorteile 1. **Bidirektionales Matching**: Alter Wert im Marker → Findet Match auch bei Änderung 2. **Konflikt-freies Merge**: User-Text bleibt erhalten 3. **Type Information**: kommKz im Marker gespeichert ### Parsing ```python def parse_marker(bemerkung: str) -> Optional[Dict]: """ Pattern: [ESPOCRM:base64:kommKz] user_text """ import re pattern = r'\[ESPOCRM:([A-Za-z0-9+/=]+):(\d+)\](.*)' match = re.match(pattern, bemerkung) if match: return { 'synced_value': decode_value(match.group(1)), 'kommKz': int(match.group(2)), 'user_text': match.group(3).strip() } # Empty Slot? slot_pattern = r'\[ESPOCRM-SLOT:(\d+)\]' slot_match = re.match(slot_pattern, bemerkung) if slot_match: return { 'is_empty_slot': True, 'kommKz': int(slot_match.group(1)) } return None ``` --- ## kommKz Detection (4-Stufen) ### Problem: Advoware API-Limitierungen 1. **GET Response**: kommKz ist IMMER 0 (Bug oder Permission) 2. **PUT Request**: kommKz ist READ-ONLY (wird ignoriert) → **Lösung**: Multi-Level Detection mit EspoCRM als Source of Truth ### Prioritäts-Kaskade ```python def detect_kommkz(value, beteiligte=None, bemerkung=None, espo_type=None): """ 1. Marker (höchste Priorität) 2. EspoCRM Type (bei EspoCRM→Advoware) 3. Top-Level Fields 4. Value Pattern 5. Default """ # 1. Marker if bemerkung: marker = parse_marker(bemerkung) if marker and marker.get('kommKz'): return marker['kommKz'] # 2. EspoCRM Type (NEU!) if espo_type: mapping = { 'Office': 1, # TelGesch 'Fax': 2, # FaxGesch 'Mobile': 3, # Mobil 'Home': 6, # TelPrivat 'Other': 10 # Sonstige } if espo_type in mapping: return mapping[espo_type] # 3. Top-Level Fields if beteiligte: if value == beteiligte.get('mobil'): return 3 # Mobil if value == beteiligte.get('tel'): return 1 # TelGesch if value == beteiligte.get('fax'): return 2 # FaxGesch # ... weitere Felder # 4. Value Pattern if '@' in value: return 4 # MailGesch (Email) # 5. Default if '@' in value: return 4 # MailGesch else: return 1 # TelGesch ``` ### Type Mapping: EspoCRM ↔ Advoware **EspoCRM phoneNumberData.type**: - `Office` → kommKz 1 (TelGesch) - `Fax` → kommKz 2 (FaxGesch) - `Mobile` → kommKz 3 (Mobil) - `Home` → kommKz 6 (TelPrivat) - `Other` → kommKz 10 (Sonstige) **kommKz Enum** (vollständig): ```python KOMMKZ_TEL_GESCH = 1 # Geschäftstelefon KOMMKZ_FAX_GESCH = 2 # Geschäftsfax KOMMKZ_MOBIL = 3 # Mobiltelefon KOMMKZ_MAIL_GESCH = 4 # Geschäfts-Email KOMMKZ_INTERNET = 5 # Website/URL KOMMKZ_TEL_PRIVAT = 6 # Privattelefon KOMMKZ_FAX_PRIVAT = 7 # Privatfax KOMMKZ_MAIL_PRIVAT = 8 # Private Email KOMMKZ_AUTO_TEL = 9 # Autotelefon KOMMKZ_SONSTIGE = 10 # Sonstige KOMMKZ_EPOST = 11 # E-Post (DE-Mail) KOMMKZ_BEA = 12 # BeA ``` **Email vs Phone**: ```python def is_email_type(kommkz: int) -> bool: return kommkz in [4, 8, 11, 12] # Emails def is_phone_type(kommkz: int) -> bool: return kommkz in [1, 2, 3, 6, 7, 9, 10] # Phones ``` --- ## Empty Slot Management ### Problem: DELETE gibt 403 Forbidden Advoware API erlaubt kein DELETE auf Kommunikationen! ### Lösung: Empty Slots **Create Empty Slot**: ```python async def _create_empty_slot(komm_id: int, kommkz: int): """Var2: Gelöscht in EspoCRM → Empty Slot in Advoware""" slot_marker = f"[ESPOCRM-SLOT:{kommkz}]" await advoware.update_kommunikation(betnr, komm_id, { 'tlf': '', 'bemerkung': slot_marker, 'online': False if is_phone_type(kommkz) else True }) ``` **Reuse Empty Slot**: ```python def find_empty_slot(advo_kommunikationen, kommkz): """Findet leeren Slot mit passendem kommKz""" for komm in advo_kommunikationen: marker = parse_marker(komm.get('bemerkung', '')) if marker and marker.get('is_empty_slot'): if marker.get('kommKz') == kommkz: return komm return None ``` **Var1 mit Slot-Reuse**: ```python # Neu in EspoCRM empty_slot = find_empty_slot(advo_kommunikationen, kommkz) if empty_slot: # UPDATE statt CREATE await advoware.update_kommunikation(betnr, empty_slot['id'], { 'tlf': value, 'bemerkung': create_marker(value, kommkz, ''), 'online': online }) else: # CREATE new await advoware.create_kommunikation(betnr, {...}) ``` --- ## Performance ### Single Data Load ```python # Optimiert: Lade Daten nur 1x advo_bet = await advoware.get_beteiligter(betnr) espo_bet = await espocrm.get_entity('CBeteiligte', entity_id) # Enthalten bereits alle Kommunikationen: advo_kommunikationen = advo_bet.get('kommunikation', []) espo_emails = espo_bet.get('emailAddressData', []) espo_phones = espo_bet.get('phoneNumberData', []) ``` **Vorteil**: Keine separaten API-Calls für Kommunikationen nötig ### Hash-Update Strategie ```python # Update Hash nur bei Änderungen if total_changes > 0 or is_initial_sync: # Re-load Advoware (rowIds könnten sich geändert haben) advo_result_final = await advoware.get_beteiligter(betnr) new_hash = calculate_hash(advo_result_final['kommunikation']) await espocrm.update_entity('CBeteiligte', entity_id, { 'kommunikationHash': new_hash }) ``` ### Latency | Operation | API Calls | Latency | |-----------|-----------|---------| | Bidirectional Sync | 2-4 | ~300-500ms | | - Load Data | 2 | ~200ms | | - Apply Changes | 0-N | ~50ms/change | | - Update Hash | 0-1 | ~100ms | --- ## Error Handling ### Logging mit context.logger ```python class KommunikationSyncManager: def __init__(self, advoware, espocrm, context=None): self.logger = context.logger if context else logger ``` **Wichtig**: `context.logger` statt module `logger` für Workbench-sichtbare Logs! ### Log-Prefix Convention ```python self.logger.info(f"[KOMM] ===== DIFF RESULTS =====") self.logger.info(f"[KOMM] Diff: {len(diff['advo_changed'])} Advoware changed...") self.logger.info(f"[KOMM] ⚠️ CONFLICT: EspoCRM wins - skipping Advoware→EspoCRM sync") ``` **Prefix `[KOMM]`**: Identifiziert Kommunikation-Sync Logs ### Varianten-Logging ```python # Var1 self.logger.info(f"[KOMM] ➕ Var1: New in EspoCRM '{value[:30]}...', type={espo_type}") # Var2 self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM - komm_id={komm_id}, value='{tlf[:30]}...'") # Var3 self.logger.info(f"[KOMM] Var3: Deleted in Advoware '{value}', removing from EspoCRM") # Var4 self.logger.info(f"[KOMM] ➕ Var4: New in Advoware '{tlf}', syncing to EspoCRM") # Var5 self.logger.info(f"[KOMM] ✏️ Var5: EspoCRM changed '{value[:30]}...', primary={espo_primary}") # Var6 self.logger.info(f"[KOMM] ✏️ Var6: Advoware changed '{old_value}' → '{new_value}'") ``` --- ## Testing ### Unit Tests ```bash cd /opt/motia-app/bitbylaw source python_modules/bin/activate python scripts/test_kommunikation_sync_implementation.py ``` ### Manual Test ```python # Test Bidirectional Sync from services.kommunikation_sync_utils import KommunikationSyncManager komm_sync = KommunikationSyncManager(advoware, espocrm, context) result = await komm_sync.sync_bidirectional( beteiligte_id='68e3e7eab49f09adb', betnr=104860, direction='both' ) print(f"Advoware→EspoCRM: {result['advoware_to_espocrm']}") print(f"EspoCRM→Advoware: {result['espocrm_to_advoware']}") print(f"Total Changes: {result['summary']['total_changes']}") ``` ### Expected Log Output ``` 📞 Starte Kommunikation-Sync (direction=both)... [KOMM] Bidirectional Sync: betnr=104860, bet_id=68e3e7eab49f09adb [KOMM] Geladen: 5 Advoware, 2 EspoCRM emails, 3 EspoCRM phones [KOMM] ===== DIFF RESULTS ===== [KOMM] Diff: 1 Advoware changed, 0 EspoCRM changed, 0 Advoware new, 1 EspoCRM new, 0 Advoware deleted, 0 EspoCRM deleted [KOMM] ===== CONFLICT STATUS: espo_wins=False ===== [KOMM] ✅ Applying Advoware→EspoCRM changes... [KOMM] ✏️ Var6: Advoware changed 'old@example.com' → 'new@example.com' [KOMM] ✅ Updated EspoCRM: 1 emails, 0 phones [KOMM] ➕ Var1: New in EspoCRM '+49 511 123456', type=Mobile [KOMM] 🔍 kommKz detected: espo_type=Mobile, kommKz=3 [KOMM] ✅ Created new kommunikation with kommKz=3 [KOMM] ✅ Updated kommunikationHash: a3f5d2e8b1c4f6a9 [KOMM] ✅ Bidirectional Sync complete: 2 total changes ✅ Kommunikation synced: {'advoware_to_espocrm': {'emails_synced': 1, 'phones_synced': 0, 'markers_updated': 1, 'errors': []}, 'espocrm_to_advoware': {'created': 1, 'updated': 0, 'deleted': 0, 'errors': []}, 'summary': {'total_changes': 2}} ``` --- ## Troubleshooting ### Hash bleibt unverändert trotz Änderungen **Problem**: `kommunikationHash` wird nicht aktualisiert **Ursachen**: 1. `total_changes = 0` (keine Änderungen erkannt) 2. Exception beim Hash-Update **Lösung**: ```python # Debug-Logging aktivieren self.logger.info(f"[KOMM] Total changes: {total_changes}, Initial sync: {is_initial_sync}") ``` ### kommKz-Erkennung fehlerhaft **Problem**: Falscher Typ zugewiesen (z.B. Office statt Mobile) **Ursachen**: 1. `espo_type` nicht übergeben 2. Marker fehlt oder fehlerhaft 3. Top-Level Field mismatch **Lösung**: ```python # Bei EspoCRM→Advoware: espo_type explizit übergeben kommkz = detect_kommkz( value=phone_number, espo_type=espo_item.get('type'), # ← WICHTIG! bemerkung=existing_marker ) ``` ### Empty Slots nicht wiederverwendet **Problem**: Neue CREATEs statt UPDATE von Empty Slots **Ursache**: `find_empty_slot()` findet keinen passenden kommKz **Lösung**: ```python # Debug self.logger.info(f"[KOMM] Looking for empty slot with kommKz={kommkz}") empty_slot = find_empty_slot(advo_kommunikationen, kommkz) if empty_slot: self.logger.info(f"[KOMM] ♻️ Found empty slot: {empty_slot['id']}") ``` ### Konflikt nicht erkannt **Problem**: Bei gleichzeitigen Änderungen wird kein Konflikt gemeldet **Ursachen**: 1. Hash-Vergleich fehlerhaft 2. Timestamp-Vergleich fehlerhaft **Debug**: ```python self.logger.info(f"[KOMM] 🔍 Konflikt-Check:") self.logger.info(f"[KOMM] - EspoCRM changed: {espo_changed}") self.logger.info(f"[KOMM] - Advoware changed: {advo_changed}") self.logger.info(f"[KOMM] - stored_hash={stored_hash}, current_hash={current_hash}") ``` --- ## Siehe auch - [BETEILIGTE_SYNC.md](BETEILIGTE_SYNC.md) - Integration in Stammdaten-Sync - [KOMMUNIKATION_SYNC_ANALYSE.md](KOMMUNIKATION_SYNC_ANALYSE.md) - Detaillierte API-Tests - [kommunikation_mapper.py](../services/kommunikation_mapper.py) - Implementation Details - [kommunikation_sync_utils.py](../services/kommunikation_sync_utils.py) - Sync Manager